From 27ed3639f117d757f920c4910482130baf3dac9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 12:02:09 +0000 Subject: [PATCH 01/38] chore: add CLAUDE.md with project overview and development guidelines https://claude.ai/code/session_0115Vp2KXGzZC7XCFt93Wu2F --- CLAUDE.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..7f7a0e8032 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# KeyMapper + +## Project Overview + +Android app for custom key/gamepad remapping, macros, and on-screen buttons. Supports Android 8.0+ (minSdk 26), targets API 36. Distributed on Google Play and F-Droid (FOSS flavor). + +- **Package:** `io.github.sds100.keymapper` +- **Languages:** Kotlin (primary), Rust (event device handling), C++ (system bridge JNI) + +## Build Commands + +```bash +./gradlew assembleDebug # debug APK +./gradlew assembleCi # CI build (minified) +./gradlew assembleRelease # release build (requires KEYSTORE_PASSWORD and KEY_PASSWORD env vars) +./gradlew clean # clean build outputs +``` + +## Test & Lint Commands + +```bash +# Kotlin unit tests +./gradlew testDebugUnitTest +./gradlew :base:testDebugUnitTest # single module + +# Kotlin lint +./gradlew ktlintCheck # check +./gradlew ktlintFormat # auto-fix + +# Rust (run from evdev/src/main/rust/evdev_manager) +cargo fmt --check +cargo test --package evdev_manager_core +``` + +CI runs all of the above on every PR (see `.github/workflows/pull-request.yml`). + +## Architecture & Modules + +Multi-module Gradle project using MVVM, Hilt DI, Room, StateFlow/Flow, Jetpack Compose, and Coroutines. + +| Module | Responsibility | +|---|---| +| `app/` | Entry point: `MainActivity`, `KeyMapperApp`, `MyAccessibilityService` | +| `base/` | UI screens and ViewModels (actions, constraints, triggers, keymaps, settings) | +| `common/` | Shared utilities and models | +| `data/` | Room database, repositories, DataStore, migrations | +| `system/` | Device APIs, permission management | +| `sysbridge/` | C++ JNI via CMake for elevated system access | +| `evdev/` | Rust event device handling + JNI bindings | +| `api/` | AIDL public interface definitions | +| `systemstubs/` | Mock stubs for system classes used in tests | + +Most business logic lives in `base/src/main/java/io/github/sds100/keymapper/`. + +## Code Style + +- **Kotlin:** ktlint with android_studio code style (configured via `.editorconfig`) + - No function expression bodies + - Trailing commas allowed + - Run `./gradlew ktlintFormat` before committing +- **Rust:** `cargo fmt` + +## Commit Message Convention + +``` +# : +``` + +Types: `feat`, `fix`, `chore`, `refactor`, `style` + +Example: `#2025 feat: add button to report bug on home screen` + +## Compose Guidelines + +- `Modifier` is always the first parameter after required params +- Use `LocalCustomColorsPalette` (not `MaterialTheme.colorScheme`) for non-standard colors +- Check `KeyMapperIcons` before using other icon sources +- Use `LocalUriHandler.openUriSafe` extension for URL launching — do not hoist URL launching logic up the call stack +- Use import statements; never use fully qualified names in Compose code + +## Adding a New Action (10-step checklist) + +First determine whether the action is editable. Then: + +1. Add a new ID to `ActionId` +2. Create a new `ActionData` sealed class variant +3. Map to/from entity in `ActionDataEntityMapper` +4. Assign a category in `ActionUtils` +5. If editable, add to `isEditable` in `ActionUtils` +6. Add a title string in `strings.xml` +7. Add title and Compose icon in `ActionUtils` (ignore drawables) +8. Add title in `ActionUiHelper` +9. Stub out execution in `PerformActionsUseCase` +10. Handle creation in `CreateActionDelegate` + +Do not delete existing action code. Follow existing naming conventions exactly. Add new code near similar existing actions. From b6b3799a9572bdaae24e4e744d7c8df3869ef444 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 12:18:46 +0000 Subject: [PATCH 02/38] chore: add Compose Preview guideline to CLAUDE.md https://claude.ai/code/session_0115Vp2KXGzZC7XCFt93Wu2F --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7f7a0e8032..2518fdbe69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,7 @@ Example: `#2025 feat: add button to report bug on home screen` - Check `KeyMapperIcons` before using other icon sources - Use `LocalUriHandler.openUriSafe` extension for URL launching — do not hoist URL launching logic up the call stack - Use import statements; never use fully qualified names in Compose code +- Write `@Preview` composables for every screen ## Adding a New Action (10-step checklist) From 712d4e402dc8f789890ae986688dd16de6830c2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 12:27:22 +0000 Subject: [PATCH 03/38] #2105 don't wrap tile labels on Discover screen Remove the 56dp max width constraint on ShortcutButton tiles and reduce maxLines from 2 to 1 so labels render on a single line instead of wrapping awkwardly. https://claude.ai/code/session_01W3yFTWbyc9dxMPumNUhsS8 --- .../sds100/keymapper/base/trigger/TriggerDiscoverScreen.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverScreen.kt index 9f83da30e9..51815ff739 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -208,7 +207,7 @@ private fun ShortcutButton( onClick: () -> Unit, ) { Column( - modifier = modifier.widthIn(max = 56.dp), + modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -235,7 +234,7 @@ private fun ShortcutButton( style = MaterialTheme.typography.labelMedium, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, + maxLines = 1, overflow = TextOverflow.Ellipsis, ) } From 069cd08f45a79bd3dfdec5b4e4a6ef4980126c4e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 12:29:41 +0000 Subject: [PATCH 04/38] Add Expert Mode debug screen for raw getevent output (issue #2081) Creates a new debug screen accessible from Settings > Debugging that shows raw getevent output using the system bridge. Displays getevent -il on open (device info) and supports recording getevent -lt events via a red record/stop FAB. Output can be copied to clipboard or saved to file. Requires Expert Mode. https://claude.ai/code/session_01QG84he6gs9tqgY9QrFhtKb --- .../sds100/keymapper/base/BaseMainNavHost.kt | 9 + .../keymapper/base/debug/GetEventScreen.kt | 287 ++++++++++++++++++ .../keymapper/base/debug/GetEventViewModel.kt | 157 ++++++++++ .../keymapper/base/settings/SettingsScreen.kt | 9 + .../base/settings/SettingsViewModel.kt | 6 + .../base/utils/navigation/NavDestination.kt | 6 + base/src/main/res/values/strings.xml | 11 + 7 files changed, 485 insertions(+) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt index 0b22cc858e..acc0092714 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt @@ -27,6 +27,7 @@ import io.github.sds100.keymapper.base.constraints.ChooseConstraintScreen import io.github.sds100.keymapper.base.constraints.ChooseConstraintViewModel import io.github.sds100.keymapper.base.expertmode.ExpertModeScreen import io.github.sds100.keymapper.base.expertmode.ExpertModeSetupScreen +import io.github.sds100.keymapper.base.debug.GetEventScreen import io.github.sds100.keymapper.base.logging.LogScreen import io.github.sds100.keymapper.base.onboarding.HandleAccessibilityServiceDialogs import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegateImpl @@ -165,6 +166,14 @@ fun BaseMainNavHost( ) } + composable { + GetEventScreen( + modifier = Modifier.fillMaxSize(), + viewModel = hiltViewModel(), + onBackClick = { navController.popBackStack() }, + ) + } + composable { ChooseSettingScreen( modifier = Modifier.fillMaxSize(), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt new file mode 100644 index 0000000000..fc675cd7c0 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt @@ -0,0 +1,287 @@ +package io.github.sds100.keymapper.base.debug + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material.icons.rounded.FiberManualRecord +import androidx.compose.material.icons.rounded.Stop +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ExpertModeStatus + +@Composable +fun GetEventScreen( + modifier: Modifier = Modifier, + viewModel: GetEventViewModel = hiltViewModel(), + onBackClick: () -> Unit, +) { + GetEventScreen( + modifier = modifier, + state = viewModel.state, + onBackClick = onBackClick, + onToggleRecordClick = viewModel::onToggleRecordClick, + onClearClick = viewModel::onClearClick, + onCopyToClipboardClick = viewModel::onCopyToClipboardClick, + onSaveToFileClick = viewModel::onSaveToFileClick, + onSetupExpertModeClick = viewModel::onSetupExpertModeClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GetEventScreen( + modifier: Modifier = Modifier, + state: GetEventViewModel.State, + onBackClick: () -> Unit = {}, + onToggleRecordClick: () -> Unit = {}, + onClearClick: () -> Unit = {}, + onCopyToClipboardClick: () -> Unit = {}, + onSaveToFileClick: () -> Unit = {}, + onSetupExpertModeClick: () -> Unit = {}, +) { + val hasOutput = state.output.isNotEmpty() + + Scaffold( + modifier = modifier.displayCutoutPadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_debug_getevent)) }, + ) + }, + bottomBar = { + BottomAppBar( + floatingActionButton = { + if (state.expertModeStatus == ExpertModeStatus.ENABLED) { + val containerColor = if (state.isRecording) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + } + val contentColor = if (state.isRecording) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + } + FloatingActionButton( + onClick = onToggleRecordClick, + containerColor = containerColor, + contentColor = contentColor, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + ) { + if (state.isRecording) { + Icon( + imageVector = Icons.Rounded.Stop, + contentDescription = stringResource(R.string.debug_getevent_stop_recording), + ) + } else { + Icon( + imageVector = Icons.Rounded.FiberManualRecord, + contentDescription = stringResource(R.string.debug_getevent_start_recording), + ) + } + } + } + }, + actions = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.action_go_back), + ) + } + Spacer(modifier = Modifier.weight(1f)) + IconButton( + onClick = onCopyToClipboardClick, + enabled = hasOutput, + ) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = stringResource(R.string.debug_getevent_copy), + ) + } + IconButton( + onClick = onSaveToFileClick, + enabled = hasOutput, + ) { + Icon( + imageVector = Icons.Outlined.Share, + contentDescription = stringResource(R.string.debug_getevent_save), + ) + } + IconButton( + onClick = onClearClick, + enabled = hasOutput, + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.debug_getevent_clear), + ) + } + }, + ) + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + Content( + modifier = Modifier.fillMaxSize(), + state = state, + onSetupExpertModeClick = onSetupExpertModeClick, + ) + } + } +} + +@Composable +private fun Content( + modifier: Modifier = Modifier, + state: GetEventViewModel.State, + onSetupExpertModeClick: () -> Unit, +) { + val scrollState = rememberScrollState() + + LaunchedEffect(state.output) { + if (state.output.isNotEmpty()) { + scrollState.animateScrollTo(scrollState.maxValue) + } + } + + Column( + modifier = modifier + .verticalScroll(scrollState) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (state.expertModeStatus != ExpertModeStatus.ENABLED) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.debug_getevent_expert_mode_required), + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedButton( + onClick = onSetupExpertModeClick, + colors = ButtonDefaults.outlinedButtonColors(), + ) { + Text(stringResource(R.string.action_shell_command_setup_expert_mode)) + } + } + } + } + + if (state.isLoadingDeviceInfo) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + if (state.isRecording) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + if (state.output.isNotEmpty()) { + SelectionContainer { + Text( + text = state.output, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Preview +@Composable +private fun PreviewWithOutput() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + output = "add device 1: /dev/input/event0\n name: \"gpio-keys\"\n\nadd device 2: /dev/input/event1\n name: \"sec_touchscreen\"", + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewRecording() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + output = "add device 1: /dev/input/event0\n name: \"gpio-keys\"", + isRecording = true, + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewExpertModeDisabled() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + expertModeStatus = ExpertModeStatus.DISABLED, + ), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt new file mode 100644 index 0000000000..89acde9931 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt @@ -0,0 +1,157 @@ +package io.github.sds100.keymapper.base.debug + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.base.utils.ExpertModeStatus +import io.github.sds100.keymapper.base.utils.ShareUtils +import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.navigation.NavDestination +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.base.actions.ExecuteShellCommandUseCase +import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.models.ShellExecutionMode +import io.github.sds100.keymapper.common.utils.handle +import io.github.sds100.keymapper.system.clipboard.ClipboardAdapter +import io.github.sds100.keymapper.system.files.FileAdapter +import io.github.sds100.keymapper.system.files.FileUtils +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@HiltViewModel +class GetEventViewModel @Inject constructor( + private val executeShellCommandUseCase: ExecuteShellCommandUseCase, + private val navigationProvider: NavigationProvider, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val clipboardAdapter: ClipboardAdapter, + private val fileAdapter: FileAdapter, + private val buildConfigProvider: BuildConfigProvider, + @ApplicationContext private val context: Context, + resourceProvider: ResourceProvider, +) : ViewModel(), NavigationProvider by navigationProvider, ResourceProvider by resourceProvider { + + data class State( + val output: String = "", + val isLoadingDeviceInfo: Boolean = false, + val isRecording: Boolean = false, + val expertModeStatus: ExpertModeStatus = ExpertModeStatus.DISABLED, + ) + + var state: State by mutableStateOf(State()) + private set + + init { + viewModelScope.launch { + systemBridgeConnectionManager.connectionState.map { connectionState -> + when (connectionState) { + is SystemBridgeConnectionState.Connected -> ExpertModeStatus.ENABLED + is SystemBridgeConnectionState.Disconnected -> ExpertModeStatus.DISABLED + } + }.collect { status -> + val wasDisabled = state.expertModeStatus != ExpertModeStatus.ENABLED + state = state.copy(expertModeStatus = status) + if (status == ExpertModeStatus.ENABLED && wasDisabled && state.output.isEmpty()) { + loadDeviceInfo() + } + } + } + } + + private fun loadDeviceInfo() { + viewModelScope.launch { + state = state.copy(isLoadingDeviceInfo = true) + val result = executeShellCommandUseCase.execute( + command = "getevent -il", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 30_000L, + ) + val output = result.handle( + onSuccess = { it.stdout }, + onError = { "Error: ${it.getFullMessage(this@GetEventViewModel)}" }, + ) + state = state.copy(output = output, isLoadingDeviceInfo = false) + } + } + + fun onToggleRecordClick() { + if (state.isRecording) { + stopRecording() + } else { + startRecording() + } + } + + private fun startRecording() { + state = state.copy(isRecording = true) + viewModelScope.launch { + val result = executeShellCommandUseCase.execute( + command = "getevent -lt", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 300_000L, + ) + val newOutput = result.handle( + onSuccess = { it.stdout }, + onError = { "" }, + ) + if (newOutput.isNotEmpty()) { + state = state.copy( + output = state.output + "\n--- Recording ---\n" + newOutput, + ) + } + state = state.copy(isRecording = false) + } + } + + private fun stopRecording() { + viewModelScope.launch { + executeShellCommandUseCase.execute( + command = "pkill -x getevent || true", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 5_000L, + ) + } + } + + fun onClearClick() { + state = state.copy(output = "") + } + + fun onCopyToClipboardClick() { + clipboardAdapter.copy("getevent output", state.output) + } + + fun onSaveToFileClick() { + viewModelScope.launch(Dispatchers.IO) { + val fileName = "getevent/key_mapper_getevent_${FileUtils.createFileDate()}.txt" + val file = fileAdapter.getPrivateFile(fileName) + file.createFile() + file.outputStream()?.bufferedWriter()?.use { it.write(state.output) } + val publicUri = fileAdapter.getPublicUriForPrivateFile(file).toUri() + ShareUtils.shareFile(context, publicUri, buildConfigProvider.packageName) + } + } + + fun onBackClick() { + viewModelScope.launch { + navigationProvider.popBackStack() + } + } + + fun onSetupExpertModeClick() { + viewModelScope.launch { + navigationProvider.navigate("getevent_setup_expert_mode", NavDestination.ExpertModeSetup) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt index 5d79cd6838..b4435f13e2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt @@ -152,6 +152,7 @@ fun SettingsScreen(modifier: Modifier = Modifier, viewModel: SettingsViewModel) onForceVibrateToggled = viewModel::onForceVibrateToggled, onLoggingToggled = viewModel::onLoggingToggled, onViewLogClick = viewModel::onViewLogClick, + onGetEventClick = viewModel::onGetEventClick, onHideHomeScreenAlertsToggled = viewModel::onHideHomeScreenAlertsToggled, onShowDeviceDescriptorsToggled = viewModel::onShowDeviceDescriptorsToggled, onAutomaticBackupClick = { @@ -235,6 +236,7 @@ private fun Content( onForceVibrateToggled: (Boolean) -> Unit = { }, onLoggingToggled: (Boolean) -> Unit = { }, onViewLogClick: () -> Unit = { }, + onGetEventClick: () -> Unit = { }, onShareLogcatClick: () -> Unit = { }, onHideHomeScreenAlertsToggled: (Boolean) -> Unit = { }, onShowDeviceDescriptorsToggled: (Boolean) -> Unit = { }, @@ -398,6 +400,13 @@ private fun Content( onClick = onShareLogcatClick, ) + OptionPageButton( + title = stringResource(R.string.title_pref_get_event_debug), + text = stringResource(R.string.summary_pref_get_event_debug), + icon = Icons.Rounded.Keyboard, + onClick = onGetEventClick, + ) + Spacer(modifier = Modifier.height(8.dp)) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt index 7628eb1edc..1a2ef5dbda 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsViewModel.kt @@ -291,6 +291,12 @@ class SettingsViewModel @Inject constructor( } } + fun onGetEventClick() { + viewModelScope.launch { + navigate("get_event_debug", NavDestination.GetEvent) + } + } + fun onShareLogcatClick() { viewModelScope.launch { if (shareLogcatUseCase.isPermissionGranted()) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index f13888bccd..6aae4a0db8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -45,6 +45,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { const val ID_EXPERT_MODE = "expert_mode" const val ID_LOG = "log" const val ID_ADVANCED_TRIGGERS = "advanced_triggers" + const val ID_GET_EVENT = "get_event" } @Serializable @@ -211,4 +212,9 @@ abstract class NavDestination(val isCompose: Boolean = false) { data object AdvancedTriggers : NavDestination(isCompose = true) { override val id: String = ID_ADVANCED_TRIGGERS } + + @Serializable + data object GetEvent : NavDestination(isCompose = true) { + override val id: String = ID_GET_EVENT + } } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 9bb7dc5d2f..002e8da6e4 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -635,6 +635,17 @@ Share the entire system log Sharing logcat failed + Expert Mode debug + View raw input device info and events + Expert Mode Debug + Expert Mode is required to use getevent. Start Expert Mode and then return to this screen. + Record + Stop Recording + Clear output + Loading device info\u2026 + Copy to clipboard + Save to file + Report issue Delete sound files From af5f558aaffe0987e64706f577fd901141eb0de8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 12:36:27 +0000 Subject: [PATCH 05/38] #2045 Add 'Perform IME action' action (Android 13+) Adds a new keyboard action that triggers the IME action (e.g. Send, Submit, Done) on the currently focused input field. Uses the accessibility service's ACTION_IME_ENTER node action, which requires Android 13+ (API 33 / TIRAMISU). https://claude.ai/code/session_01WYByQnTBo1Hudb7zmGkfj3 --- .../github/sds100/keymapper/base/actions/ActionData.kt | 5 +++++ .../keymapper/base/actions/ActionDataEntityMapper.kt | 2 ++ .../github/sds100/keymapper/base/actions/ActionId.kt | 1 + .../sds100/keymapper/base/actions/ActionUiHelper.kt | 1 + .../sds100/keymapper/base/actions/ActionUtils.kt | 8 ++++++++ .../keymapper/base/actions/PerformActionsUseCase.kt | 10 ++++++++++ base/src/main/res/values/strings.xml | 2 ++ 7 files changed, 29 insertions(+) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index eb24dd579a..e3903bd094 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -833,6 +833,11 @@ sealed class ActionData : Comparable { override val id = ActionId.SHOW_KEYBOARD_PICKER } + @Serializable + data object PerformImeAction : ActionData() { + override val id = ActionId.PERFORM_IME_ACTION + } + @Serializable data object CopyText : ActionData() { override val id = ActionId.TEXT_COPY diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index a4485f9813..a8fc7a24d0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -545,6 +545,7 @@ object ActionDataEntityMapper { ActionId.SHOW_KEYBOARD -> ActionData.ShowKeyboard ActionId.HIDE_KEYBOARD -> ActionData.HideKeyboard ActionId.SHOW_KEYBOARD_PICKER -> ActionData.ShowKeyboardPicker + ActionId.PERFORM_IME_ACTION -> ActionData.PerformImeAction ActionId.TEXT_CUT -> ActionData.CutText ActionId.TEXT_COPY -> ActionData.CopyText ActionId.TEXT_PASTE -> ActionData.PasteText @@ -1322,6 +1323,7 @@ object ActionDataEntityMapper { ActionId.SHOW_KEYBOARD to "show_keyboard", ActionId.HIDE_KEYBOARD to "hide_keyboard", ActionId.SHOW_KEYBOARD_PICKER to "show_keyboard_picker", + ActionId.PERFORM_IME_ACTION to "perform_ime_action", ActionId.TEXT_CUT to "text_cut", ActionId.TEXT_COPY to "text_copy", ActionId.TEXT_PASTE to "text_paste", diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index 70cbf87a59..2ce2fd0eb3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt @@ -125,6 +125,7 @@ enum class ActionId { SHOW_KEYBOARD, HIDE_KEYBOARD, SHOW_KEYBOARD_PICKER, + PERFORM_IME_ACTION, SWITCH_KEYBOARD, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index 98b53064b4..b97425adf2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -595,6 +595,7 @@ class ActionUiHelper( ActionData.SelectWordAtCursor -> getString(R.string.action_select_word_at_cursor) ActionData.ShowKeyboard -> getString(R.string.action_show_keyboard) ActionData.ShowKeyboardPicker -> getString(R.string.action_show_keyboard_picker) + ActionData.PerformImeAction -> getString(R.string.action_perform_ime_action) ActionData.ShowPowerMenu -> getString(R.string.action_show_power_menu) ActionData.StatusBar.Collapse -> getString(R.string.action_collapse_status_bar) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt index a5bfaa6454..e71653eba6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt @@ -8,6 +8,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.Message import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.automirrored.outlined.Send import androidx.compose.material.icons.automirrored.outlined.ShortText import androidx.compose.material.icons.automirrored.outlined.Undo import androidx.compose.material.icons.automirrored.outlined.VolumeDown @@ -225,6 +226,7 @@ object ActionUtils { ActionId.HIDE_KEYBOARD -> ActionCategory.KEYBOARD ActionId.SHOW_KEYBOARD_PICKER -> ActionCategory.KEYBOARD ActionId.SELECT_WORD_AT_CURSOR -> ActionCategory.KEYBOARD + ActionId.PERFORM_IME_ACTION -> ActionCategory.KEYBOARD ActionId.SWITCH_KEYBOARD -> ActionCategory.KEYBOARD ActionId.LOCK_DEVICE -> ActionCategory.INTERFACE ActionId.POWER_ON_OFF_DEVICE -> ActionCategory.INTERFACE @@ -424,6 +426,8 @@ object ActionUtils { ActionId.SELECT_WORD_AT_CURSOR -> R.string.action_select_word_at_cursor + ActionId.PERFORM_IME_ACTION -> R.string.action_perform_ime_action + ActionId.SWITCH_KEYBOARD -> R.string.action_switch_keyboard ActionId.TOGGLE_AIRPLANE_MODE -> R.string.action_toggle_airplane_mode @@ -599,6 +603,7 @@ object ActionUtils { ActionId.TEXT_COPY -> R.drawable.ic_content_copy ActionId.TEXT_PASTE -> R.drawable.ic_content_paste ActionId.SELECT_WORD_AT_CURSOR -> null + ActionId.PERFORM_IME_ACTION -> null ActionId.SWITCH_KEYBOARD -> R.drawable.ic_outline_keyboard_24 ActionId.TOGGLE_AIRPLANE_MODE -> R.drawable.ic_outline_airplanemode_active_24 ActionId.ENABLE_AIRPLANE_MODE -> R.drawable.ic_outline_airplanemode_active_24 @@ -677,6 +682,8 @@ object ActionUtils { ActionId.SELECT_WORD_AT_CURSOR, -> Build.VERSION_CODES.JELLY_BEAN_MR2 + ActionId.PERFORM_IME_ACTION -> Build.VERSION_CODES.TIRAMISU + ActionId.SHOW_POWER_MENU -> Build.VERSION_CODES.LOLLIPOP ActionId.DEVICE_CONTROLS -> Build.VERSION_CODES.S @@ -1029,6 +1036,7 @@ object ActionUtils { ActionId.TEXT_COPY -> Icons.Rounded.ContentCopy ActionId.TEXT_PASTE -> Icons.Rounded.ContentPaste ActionId.SELECT_WORD_AT_CURSOR -> KeyMapperIcons.MatchWord + ActionId.PERFORM_IME_ACTION -> Icons.AutoMirrored.Outlined.Send ActionId.SWITCH_KEYBOARD -> Icons.Outlined.Keyboard ActionId.TOGGLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive ActionId.ENABLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive 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 dc4d735993..b0b36eac3f 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 @@ -764,6 +764,16 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = inputMethodAdapter.showImePicker(fromForeground = false) } + is ActionData.PerformImeAction -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + result = service.performActionOnNode({ it.isFocused }) { + AccessibilityNodeAction(AccessibilityNodeInfo.ACTION_IME_ENTER) + } + } else { + result = SdkVersionTooLow(minSdk = Build.VERSION_CODES.TIRAMISU) + } + } + is ActionData.CutText -> { result = service.performActionOnNode({ it.isFocused }) { AccessibilityNodeAction(AccessibilityNodeInfo.ACTION_CUT) diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 9bb7dc5d2f..20f88e678c 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1074,6 +1074,8 @@ Show keyboard picker + Perform IME action + Switch keyboard Switch to %s From b79c0c428e18bba6d5faf650cc84d3a0dc2206dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 12:49:12 +0000 Subject: [PATCH 06/38] #2045 Use inputMethod.currentInputConnection.performEditorAction() Replace the AccessibilityNodeInfo.ACTION_IME_ENTER node action with inputMethod.currentInputConnection.performEditorAction(), following the same pattern as injectText(). Adds performImeAction() to IAccessibilityService and implements it in BaseAccessibilityService. https://claude.ai/code/session_01WYByQnTBo1Hudb7zmGkfj3 --- .../sds100/keymapper/base/actions/PerformActionsUseCase.kt | 5 ++--- .../base/system/accessibility/BaseAccessibilityService.kt | 4 ++++ .../base/system/accessibility/IAccessibilityService.kt | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) 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 b0b36eac3f..e2dd348dde 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 @@ -766,9 +766,8 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( is ActionData.PerformImeAction -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - result = service.performActionOnNode({ it.isFocused }) { - AccessibilityNodeAction(AccessibilityNodeInfo.ACTION_IME_ENTER) - } + service.performImeAction() + result = Success(Unit) } else { result = SdkVersionTooLow(minSdk = Build.VERSION_CODES.TIRAMISU) } 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 ccb7d38ff1..c1d5991a29 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 @@ -569,4 +569,8 @@ abstract class BaseAccessibilityService : null, ) } + + override fun performImeAction() { + inputMethod?.currentInputConnection?.performEditorAction(EditorInfo.IME_ACTION_UNSPECIFIED) + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt index 3eaa73ca40..7ae1761d8c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt @@ -66,4 +66,7 @@ interface IAccessibilityService : SwitchImeInterface { @RequiresApi(Build.VERSION_CODES.TIRAMISU) fun injectText(text: String) + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + fun performImeAction() } From 0c04c9f821c236187584277535d171cb110b4880 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 13:04:17 +0000 Subject: [PATCH 07/38] #2045 Use Keyboard icon for PerformImeAction (fix build) Replace Icons.AutoMirrored.Outlined.Send (which may not resolve in this icon set) with Icons.Outlined.Keyboard which is already imported and used by other keyboard actions. https://claude.ai/code/session_01WYByQnTBo1Hudb7zmGkfj3 --- .../io/github/sds100/keymapper/base/actions/ActionUtils.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt index e71653eba6..85586fe539 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt @@ -8,7 +8,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.Message import androidx.compose.material.icons.automirrored.outlined.OpenInNew -import androidx.compose.material.icons.automirrored.outlined.Send import androidx.compose.material.icons.automirrored.outlined.ShortText import androidx.compose.material.icons.automirrored.outlined.Undo import androidx.compose.material.icons.automirrored.outlined.VolumeDown @@ -1036,7 +1035,7 @@ object ActionUtils { ActionId.TEXT_COPY -> Icons.Rounded.ContentCopy ActionId.TEXT_PASTE -> Icons.Rounded.ContentPaste ActionId.SELECT_WORD_AT_CURSOR -> KeyMapperIcons.MatchWord - ActionId.PERFORM_IME_ACTION -> Icons.AutoMirrored.Outlined.Send + ActionId.PERFORM_IME_ACTION -> Icons.Outlined.Keyboard ActionId.SWITCH_KEYBOARD -> Icons.Outlined.Keyboard ActionId.TOGGLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive ActionId.ENABLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive From 6c0cc61af4a46db8a81b47ce50996d0182bb6dc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 13:22:45 +0000 Subject: [PATCH 08/38] #2045 Fix missing PERFORM_IME_ACTION case in CreateActionDelegate The exhaustive when(actionId) block in configAction() was missing a case for the new PERFORM_IME_ACTION, causing build and test failures. https://claude.ai/code/session_01WYByQnTBo1Hudb7zmGkfj3 --- .../sds100/keymapper/base/actions/CreateActionDelegate.kt | 2 ++ 1 file changed, 2 insertions(+) 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 3525167f70..e1d2124db5 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 @@ -1066,6 +1066,8 @@ class CreateActionDelegate( ActionId.SHOW_KEYBOARD_PICKER -> return ActionData.ShowKeyboardPicker + ActionId.PERFORM_IME_ACTION -> return ActionData.PerformImeAction + ActionId.TEXT_CUT -> return ActionData.CutText ActionId.TEXT_COPY -> return ActionData.CopyText From 08ea2c489a027928e1e567a81d90da8c63327ada Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 22:25:38 +0000 Subject: [PATCH 09/38] #2106 fix: show dialog when using in-app keyboard switcher to disable auto-switch When the user taps the "Choose keyboard" menu item on the home screen and the automatic keyboard switching feature is enabled, show a dialog warning that auto-switch will be disabled. If the user confirms, the feature is turned off and the IME picker is opened. This prevents KeyMapper immediately and silently switching back to its own IME after the user has deliberately selected another keyboard. https://claude.ai/code/session_01AdijnJnpbsCqPDTYobwnLa --- .../keyevent/FixKeyEventActionDelegate.kt | 5 +++++ .../base/home/KeyMapListViewModel.kt | 22 ++++++++++++++++++- base/src/main/res/values/strings.xml | 3 +++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt index c555f586f9..2336ba4dc5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt @@ -63,6 +63,10 @@ class FixKeyEventActionDelegateImpl @Inject constructor( preferenceRepository.get(Keys.keyEventActionsUseSystemBridge) .map { it ?: PreferenceDefaults.KEY_EVENT_ACTIONS_USE_SYSTEM_BRIDGE } + override val isAutoSwitchImeEnabled: Flow = + preferenceRepository.get(Keys.changeImeOnInputFocus) + .map { it ?: PreferenceDefaults.CHANGE_IME_ON_INPUT_FOCUS } + private val showBottomSheet: MutableStateFlow = MutableStateFlow(false) @OptIn(ExperimentalCoroutinesApi::class) @@ -166,6 +170,7 @@ class FixKeyEventActionDelegateImpl @Inject constructor( interface FixKeyEventActionDelegate { val fixKeyEventActionState: StateFlow + val isAutoSwitchImeEnabled: Flow fun showFixKeyEventActionBottomSheet() fun dismissFixKeyEventActionBottomSheet() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt index 414dfc2ac2..e7086c2439 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt @@ -907,7 +907,27 @@ class KeyMapListViewModel( } fun showInputMethodPicker() { - showInputMethodPickerUseCase.show(fromForeground = true) + coroutineScope.launch { + val autoSwitchEnabled = isAutoSwitchImeEnabled.first() + + if (autoSwitchEnabled) { + val response = showDialog( + "disable_auto_switch_ime_dialog", + DialogModel.Alert( + title = getString(R.string.dialog_title_disable_auto_switch_ime), + message = getString(R.string.dialog_message_disable_auto_switch_ime), + positiveButtonText = getString(R.string.pos_ok), + negativeButtonText = getString(R.string.neg_cancel), + ), + ) + + if (response != DialogResponse.POSITIVE) return@launch + + onAutoSwitchImeCheckedChange(false) + } + + showInputMethodPickerUseCase.show(fromForeground = true) + } } private suspend fun onAutomaticBackupResult(result: KMResult<*>) { diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 9bb7dc5d2f..8f820b1af2 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1901,4 +1901,7 @@ Open Discord Email us + Disable auto-switch keyboard? + Choosing a keyboard here will disable the automatic keyboard switching feature. You can re-enable it in Settings. + From bfb0d8a2fe1b8d288eeff1f6f570bb504348b026 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 08:21:45 +0000 Subject: [PATCH 10/38] #2106 refactor: move auto-switch IME logic to ShowInputMethodPickerUseCase Move isAutoSwitchImeEnabled flow and disableAutoSwitch() out of FixKeyEventActionDelegate (where they don't belong) and into ShowInputMethodPickerUseCase which owns all IME picker concerns. KeyMapListViewModel now reads/writes through the use case. https://claude.ai/code/session_01AdijnJnpbsCqPDTYobwnLa --- .../keyevent/FixKeyEventActionDelegate.kt | 5 ----- .../keymapper/base/home/KeyMapListViewModel.kt | 4 ++-- .../inputmethod/ShowInputMethodPickerUseCase.kt | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt index 2336ba4dc5..c555f586f9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt @@ -63,10 +63,6 @@ class FixKeyEventActionDelegateImpl @Inject constructor( preferenceRepository.get(Keys.keyEventActionsUseSystemBridge) .map { it ?: PreferenceDefaults.KEY_EVENT_ACTIONS_USE_SYSTEM_BRIDGE } - override val isAutoSwitchImeEnabled: Flow = - preferenceRepository.get(Keys.changeImeOnInputFocus) - .map { it ?: PreferenceDefaults.CHANGE_IME_ON_INPUT_FOCUS } - private val showBottomSheet: MutableStateFlow = MutableStateFlow(false) @OptIn(ExperimentalCoroutinesApi::class) @@ -170,7 +166,6 @@ class FixKeyEventActionDelegateImpl @Inject constructor( interface FixKeyEventActionDelegate { val fixKeyEventActionState: StateFlow - val isAutoSwitchImeEnabled: Flow fun showFixKeyEventActionBottomSheet() fun dismissFixKeyEventActionBottomSheet() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt index e7086c2439..6a2100b5a0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt @@ -908,7 +908,7 @@ class KeyMapListViewModel( fun showInputMethodPicker() { coroutineScope.launch { - val autoSwitchEnabled = isAutoSwitchImeEnabled.first() + val autoSwitchEnabled = showInputMethodPickerUseCase.isAutoSwitchImeEnabled.first() if (autoSwitchEnabled) { val response = showDialog( @@ -923,7 +923,7 @@ class KeyMapListViewModel( if (response != DialogResponse.POSITIVE) return@launch - onAutoSwitchImeCheckedChange(false) + showInputMethodPickerUseCase.disableAutoSwitch() } showInputMethodPickerUseCase.show(fromForeground = true) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ShowInputMethodPickerUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ShowInputMethodPickerUseCase.kt index 38caa71748..d2f2e6c846 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ShowInputMethodPickerUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ShowInputMethodPickerUseCase.kt @@ -1,16 +1,33 @@ package io.github.sds100.keymapper.base.system.inputmethod +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.PreferenceDefaults +import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map class ShowInputMethodPickerUseCaseImpl @Inject constructor( private val inputMethodAdapter: InputMethodAdapter, + private val preferenceRepository: PreferenceRepository, ) : ShowInputMethodPickerUseCase { + override val isAutoSwitchImeEnabled: Flow = + preferenceRepository.get(Keys.changeImeOnInputFocus) + .map { it ?: PreferenceDefaults.CHANGE_IME_ON_INPUT_FOCUS } + + override fun disableAutoSwitch() { + preferenceRepository.set(Keys.changeImeOnInputFocus, false) + } + override fun show(fromForeground: Boolean) { inputMethodAdapter.showImePicker(fromForeground = fromForeground) } } interface ShowInputMethodPickerUseCase { + val isAutoSwitchImeEnabled: Flow + + fun disableAutoSwitch() fun show(fromForeground: Boolean) } From 28873387d8880019fda84d0b05ce3024426c6087 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 13 Apr 2026 19:04:34 +0200 Subject: [PATCH 11/38] fix: title in AdvancedTriggersScreen would not wrap correctly --- base/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 9bb7dc5d2f..d70ca23896 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1341,7 +1341,7 @@ - Support Key Mapper ❤️ + Support Key\u00A0Mapper\u00A0❤️ Choose add-ons to upgrade your experience Your support keeps Key Mapper alive! "Floating button feature is a game changer" From 96e50339b131c66292b2fd3d78b5b519c683bc06 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 13 Apr 2026 19:04:40 +0200 Subject: [PATCH 12/38] delete modules.xml --- evdev/.idea/modules.xml | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 evdev/.idea/modules.xml diff --git a/evdev/.idea/modules.xml b/evdev/.idea/modules.xml deleted file mode 100644 index 56152584b9..0000000000 --- a/evdev/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file From 5b48de9add5eb6347b349bc09b9c7eb7f139634e Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 13 Apr 2026 22:16:33 +0200 Subject: [PATCH 13/38] #2045 rename action --- base/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 20f88e678c..e0f3dcac5e 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1074,7 +1074,7 @@ Show keyboard picker - Perform IME action + Keyboard enter/send Switch keyboard Switch to %s From 22f9892c4a5629c06a5ba846525897ebfb7e437d Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 13 Apr 2026 22:21:39 +0200 Subject: [PATCH 14/38] chore: update changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e66c03232b..d9a3ec583b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [4.1.0](https://github.com/sds100/KeyMapper/releases/tag/v4.1.0) + +#### TO BE RELEASED + +## Added + +- #2045 add action to input on-screen keyboard enter/send button. +- #2106 disable the keyboard auto-switching setting when manually switching the keyboard in the Key Mapper homescreen menu. + ## [4.0.5](https://github.com/sds100/KeyMapper/releases/tag/v4.0.5) #### 26 February 2026 From 88dedb36bfa2e5a6952e47e13c56ad41505872f2 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 14 Apr 2026 07:55:23 +0000 Subject: [PATCH 15/38] New Crowdin translations by GitHub Action --- fastlane/metadata/android/tr_TR/full_description.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fastlane/metadata/android/tr_TR/full_description.txt b/fastlane/metadata/android/tr_TR/full_description.txt index b5be52ef5e..8afc1ca778 100644 --- a/fastlane/metadata/android/tr_TR/full_description.txt +++ b/fastlane/metadata/android/tr_TR/full_description.txt @@ -46,10 +46,10 @@ Herhangi bir kullanıcı verisi toplamayacak veya herhangi bir veriyi göndermek Erişilebilirlik hizmetimiz, yalnızca kullanıcı cihazındaki fiziksel bir tuşa bastığında tetiklenir. Kullanıcı, sistem erişilebilirlik ayarlarından bu hizmeti istediği zaman kapatabilir. Discord topluluğumuza gelip merhaba deyin! -www.keymapper.club +keymapper.app/discord Kodu kendiniz görün! (Açık kaynak) -code.keymapper.club +github.com/keymapperorg/KeyMapper Belgeleri okuyun: -docs.keymapper.club \ No newline at end of file +keymapper.app From 297c8ef6c9c9dad110813ee234d60b0d7e2ab014 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 08:24:27 +0000 Subject: [PATCH 16/38] Fix ktlint violations in GetEventViewModel and GetEventScreen - Move ExecuteShellCommandUseCase import before base.utils.* (alphabetical order) - Remove unused `width` import from GetEventScreen https://claude.ai/code/session_01QG84he6gs9tqgY9QrFhtKb --- .../io/github/sds100/keymapper/base/debug/GetEventScreen.kt | 1 - .../io/github/sds100/keymapper/base/debug/GetEventViewModel.kt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt index fc675cd7c0..f4d18632be 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt index 89acde9931..42ca58ae17 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.base.actions.ExecuteShellCommandUseCase import io.github.sds100.keymapper.base.utils.ExpertModeStatus import io.github.sds100.keymapper.base.utils.ShareUtils import io.github.sds100.keymapper.base.utils.getFullMessage @@ -16,7 +17,6 @@ import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.ResourceProvider -import io.github.sds100.keymapper.base.actions.ExecuteShellCommandUseCase import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.handle From ddb95d447322978f984785ccddd11bfe20281afc Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 14 Apr 2026 10:32:50 +0200 Subject: [PATCH 17/38] #2081 fix padding and text style --- .../keymapper/base/debug/GetEventScreen.kt | 72 +++++++++++++++---- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt index fc675cd7c0..d904202a6d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.debug +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -10,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll @@ -44,6 +44,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme @@ -111,12 +112,16 @@ private fun GetEventScreen( if (state.isRecording) { Icon( imageVector = Icons.Rounded.Stop, - contentDescription = stringResource(R.string.debug_getevent_stop_recording), + contentDescription = stringResource( + R.string.debug_getevent_stop_recording, + ), ) } else { Icon( imageVector = Icons.Rounded.FiberManualRecord, - contentDescription = stringResource(R.string.debug_getevent_start_recording), + contentDescription = stringResource( + R.string.debug_getevent_start_recording, + ), ) } } @@ -198,14 +203,13 @@ private fun Content( } } - Column( - modifier = modifier - .verticalScroll(scrollState) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + Column(modifier = modifier) { if (state.expertModeStatus != ExpertModeStatus.ENABLED) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { + ElevatedCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), @@ -225,20 +229,36 @@ private fun Content( } if (state.isLoadingDeviceInfo) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) } if (state.isRecording) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) } if (state.output.isNotEmpty()) { - SelectionContainer { + SelectionContainer( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .verticalScroll(rememberScrollState()), + ) { Text( + modifier = Modifier.padding(horizontal = 16.dp), text = state.output, + softWrap = false, style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.sp, fontFamily = FontFamily.Monospace, ), + lineHeight = 13.sp, ) } } @@ -253,7 +273,31 @@ private fun PreviewWithOutput() { KeyMapperTheme { GetEventScreen( state = GetEventViewModel.State( - output = "add device 1: /dev/input/event0\n name: \"gpio-keys\"\n\nadd device 2: /dev/input/event1\n name: \"sec_touchscreen\"", + output = """add device 1: /dev/input/event0 + bus: 0019 + vendor 0001 + product 0001 + version 0100 + name: "gpio-keys" + location: "gpio-keys/input0" + id: "" + version: 1.0.1 + events: + KEY (0001): KEY_POWER + input props: + +add device 2: /dev/input/event1 + bus: 0006 + vendor 0000 + product 0000 + version 0000 + name: "virtio_input_multi_touch_1" + location: "virtio10/input0" + id: "" + version: 1.0.1 + events: + KEY (0001): BTN_TOOL_RUBBER BTN_STYLUS + ABS (0003): ABS_X : value 0, min 0, max 32767, fuzz 0, flat 0, resolution 0"""", expertModeStatus = ExpertModeStatus.ENABLED, ), ) From 567150a7fd4e78e35528edaf245b9a01aab46334 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 16:48:44 +0000 Subject: [PATCH 18/38] #2091 fix: show error for device assistant action when no assistant is installed https://claude.ai/code/session_0116QYB2BM6u4VAhCRTcxZid --- CHANGELOG.md | 4 +++ .../base/actions/ActionErrorSnapshot.kt | 9 +++++ .../base/actions/GetActionErrorUseCaseTest.kt | 34 ++++++++++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9a3ec583b..50cc80581a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - #2045 add action to input on-screen keyboard enter/send button. - #2106 disable the keyboard auto-switching setting when manually switching the keyboard in the Key Mapper homescreen menu. +## Fixed + +- #2091 show an error on the "open device assistant" action when no device assistant is installed. + ## [4.0.5](https://github.com/sds100/KeyMapper/releases/tag/v4.0.5) #### 26 February 2026 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 a79591be43..31b9e596c7 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 @@ -53,6 +53,9 @@ class LazyActionErrorSnapshot( private val isCompatibleImeEnabled by lazy { keyMapperImeHelper.isCompatibleImeEnabled() } private val isCompatibleImeChosen by lazy { keyMapperImeHelper.isCompatibleImeChosen() } private val isVoiceAssistantInstalled by lazy { packageManager.isVoiceAssistantInstalled() } + private val isDeviceAssistantInstalled by lazy { + packageManager.getDeviceAssistantPackage().isSuccess + } private val grantedPermissions: MutableMap = mutableMapOf() private val flashLenses by lazy { buildSet { @@ -190,6 +193,12 @@ class LazyActionErrorSnapshot( } } + is ActionData.DeviceAssistant -> { + if (!isDeviceAssistantInstalled) { + return KMError.NoDeviceAssistant + } + } + is ActionData.Flashlight -> if (!flashLenses.contains(action.lens)) { return when (action.lens) { diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/GetActionErrorUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/GetActionErrorUseCaseTest.kt index f3f233625b..f1a978b026 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/GetActionErrorUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/GetActionErrorUseCaseTest.kt @@ -5,10 +5,12 @@ import io.github.sds100.keymapper.base.repositories.FakePreferenceRepository import io.github.sds100.keymapper.base.system.inputmethod.FakeInputMethodAdapter import io.github.sds100.keymapper.base.utils.TestBuildConfigProvider import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState import io.github.sds100.keymapper.sysbridge.utils.SystemBridgeError +import io.github.sds100.keymapper.system.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter @@ -59,6 +61,7 @@ class GetActionErrorUseCaseTest { private lateinit var mockPermissionAdapter: PermissionAdapter private lateinit var fakePreferenceRepository: FakePreferenceRepository private lateinit var mockSystemBridgeConnectionManager: SystemBridgeConnectionManager + private lateinit var mockPackageManagerAdapter: PackageManagerAdapter @Before fun init() { @@ -66,9 +69,10 @@ class GetActionErrorUseCaseTest { mockPermissionAdapter = mock() fakePreferenceRepository = FakePreferenceRepository() mockSystemBridgeConnectionManager = mock() + mockPackageManagerAdapter = mock() useCase = GetActionErrorUseCaseImpl( - packageManagerAdapter = mock(), + packageManagerAdapter = mockPackageManagerAdapter, inputMethodAdapter = fakeInputMethodAdapter, switchImeInterface = mock(), permissionAdapter = mockPermissionAdapter, @@ -300,4 +304,32 @@ class GetActionErrorUseCaseTest { assertThat(errors[0], `is`(KMError.KeyEventActionError(SystemBridgeError.Disconnected))) } + + @Test + fun `show an error for device assistant action when no device assistant is installed`() = + testScope.runTest { + whenever( + mockPackageManagerAdapter.getDeviceAssistantPackage(), + ).thenReturn(KMError.NoDeviceAssistant) + + val action = ActionData.DeviceAssistant + + val errors = useCase.actionErrorSnapshot.first().getErrors(listOf(action)) + + assertThat(errors[action], `is`(KMError.NoDeviceAssistant)) + } + + @Test + fun `do not show an error for device assistant action when a device assistant is installed`() = + testScope.runTest { + whenever( + mockPackageManagerAdapter.getDeviceAssistantPackage(), + ).thenReturn(Success("com.google.android.googlequicksearchbox")) + + val action = ActionData.DeviceAssistant + + val errors = useCase.actionErrorSnapshot.first().getErrors(listOf(action)) + + assertThat(errors[action], nullValue()) + } } From 498a559730da159ff49965afd7ad978ad6be1934 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 17:05:59 +0000 Subject: [PATCH 19/38] #2091 fix: add missing isSuccess import in ActionErrorSnapshot https://claude.ai/code/session_0116QYB2BM6u4VAhCRTcxZid --- .../github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt | 1 + 1 file changed, 1 insertion(+) 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 31b9e596c7..9cf7a00748 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 @@ -8,6 +8,7 @@ import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.firstBlocking +import io.github.sds100.keymapper.common.utils.isSuccess import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.onSuccess import io.github.sds100.keymapper.common.utils.valueOrNull From 4f1648dd592ae4722a822f713cf04ef2dadd79d3 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 10:25:32 +0200 Subject: [PATCH 20/38] #2081 create tabs for info and events --- .../sds100/keymapper/base/BaseMainNavHost.kt | 2 +- .../keymapper/base/debug/GetEventScreen.kt | 176 ++++++++++++------ .../keymapper/base/debug/GetEventViewModel.kt | 31 +-- base/src/main/res/values/strings.xml | 2 + 4 files changed, 143 insertions(+), 68 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt index acc0092714..1927e46269 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt @@ -25,9 +25,9 @@ import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementScreen import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementViewModel import io.github.sds100.keymapper.base.constraints.ChooseConstraintScreen import io.github.sds100.keymapper.base.constraints.ChooseConstraintViewModel +import io.github.sds100.keymapper.base.debug.GetEventScreen import io.github.sds100.keymapper.base.expertmode.ExpertModeScreen import io.github.sds100.keymapper.base.expertmode.ExpertModeSetupScreen -import io.github.sds100.keymapper.base.debug.GetEventScreen import io.github.sds100.keymapper.base.logging.LogScreen import io.github.sds100.keymapper.base.onboarding.HandleAccessibilityServiceDialogs import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegateImpl diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt index d904202a6d..bfe767f706 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt @@ -9,8 +9,9 @@ import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll @@ -32,12 +33,15 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource @@ -49,6 +53,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ExpertModeStatus +import kotlinx.coroutines.launch @Composable fun GetEventScreen( @@ -80,7 +85,9 @@ private fun GetEventScreen( onSaveToFileClick: () -> Unit = {}, onSetupExpertModeClick: () -> Unit = {}, ) { - val hasOutput = state.output.isNotEmpty() + val hasOutput = state.recordingOutput.isNotEmpty() + val pagerState = rememberPagerState(pageCount = { 2 }) + val scope = rememberCoroutineScope() Scaffold( modifier = modifier.displayCutoutPadding(), @@ -104,7 +111,12 @@ private fun GetEventScreen( MaterialTheme.colorScheme.onPrimaryContainer } FloatingActionButton( - onClick = onToggleRecordClick, + onClick = { + if (!state.isRecording) { + scope.launch { pagerState.animateScrollToPage(1) } + } + onToggleRecordClick() + }, containerColor = containerColor, contentColor = contentColor, elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), @@ -180,79 +192,132 @@ private fun GetEventScreen( end = endPadding, ), ) { - Content( - modifier = Modifier.fillMaxSize(), - state = state, - onSetupExpertModeClick = onSetupExpertModeClick, - ) + Column(modifier = Modifier.fillMaxSize()) { + if (state.expertModeStatus != ExpertModeStatus.ENABLED) { + ExpertModeSetupCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + onSetupExpertModeClick = onSetupExpertModeClick, + ) + } else { + PrimaryTabRow( + selectedTabIndex = pagerState.targetPage, + modifier = Modifier.fillMaxWidth(), + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Tab( + selected = pagerState.targetPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(stringResource(R.string.debug_getevent_tab_info)) }, + ) + Tab( + selected = pagerState.targetPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(stringResource(R.string.debug_getevent_tab_events)) }, + ) + } + + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = pagerState, + ) { pageIndex -> + when (pageIndex) { + 0 -> InfoContent( + modifier = Modifier.fillMaxSize(), + state = state, + ) + + 1 -> EventsContent( + modifier = Modifier.fillMaxSize(), + state = state, + ) + } + } + } + } } } } @Composable -private fun Content( +private fun ExpertModeSetupCard( modifier: Modifier = Modifier, - state: GetEventViewModel.State, onSetupExpertModeClick: () -> Unit, ) { - val scrollState = rememberScrollState() - - LaunchedEffect(state.output) { - if (state.output.isNotEmpty()) { - scrollState.animateScrollTo(scrollState.maxValue) + ElevatedCard(modifier = modifier) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.debug_getevent_expert_mode_required), + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedButton( + onClick = onSetupExpertModeClick, + colors = ButtonDefaults.outlinedButtonColors(), + ) { + Text(stringResource(R.string.action_shell_command_setup_expert_mode)) + } } } +} +@Composable +private fun InfoContent(modifier: Modifier = Modifier, state: GetEventViewModel.State) { Column(modifier = modifier) { - if (state.expertModeStatus != ExpertModeStatus.ENABLED) { - ElevatedCard( + if (state.isLoadingDeviceInfo) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + if (state.deviceInfoOutput.isNotEmpty()) { + SelectionContainer( modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + .weight(1f) + .horizontalScroll(rememberScrollState()) + .verticalScroll(rememberScrollState()), ) { - Column( + Text( modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(R.string.debug_getevent_expert_mode_required), - style = MaterialTheme.typography.bodyMedium, - ) - OutlinedButton( - onClick = onSetupExpertModeClick, - colors = ButtonDefaults.outlinedButtonColors(), - ) { - Text(stringResource(R.string.action_shell_command_setup_expert_mode)) - } - } + text = state.deviceInfoOutput, + softWrap = false, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + ), + lineHeight = 13.sp, + ) } } + } +} - if (state.isLoadingDeviceInfo) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) +@Composable +private fun EventsContent(modifier: Modifier = Modifier, state: GetEventViewModel.State) { + val verticalScrollState = rememberScrollState() + + LaunchedEffect(state.recordingOutput) { + if (state.recordingOutput.isNotEmpty()) { + verticalScrollState.animateScrollTo(verticalScrollState.maxValue) } + } + Column(modifier = modifier) { if (state.isRecording) { - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } - if (state.output.isNotEmpty()) { + if (state.recordingOutput.isNotEmpty()) { SelectionContainer( modifier = Modifier + .weight(1f) .horizontalScroll(rememberScrollState()) - .verticalScroll(rememberScrollState()), + .verticalScroll(verticalScrollState), ) { Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = state.output, + modifier = Modifier.padding(16.dp), + text = state.recordingOutput, softWrap = false, style = MaterialTheme.typography.bodySmall.copy( fontSize = 10.sp, @@ -262,18 +327,16 @@ private fun Content( ) } } - - Spacer(modifier = Modifier.height(8.dp)) } } @Preview @Composable -private fun PreviewWithOutput() { +private fun PreviewInfoTab() { KeyMapperTheme { GetEventScreen( state = GetEventViewModel.State( - output = """add device 1: /dev/input/event0 + deviceInfoOutput = """add device 1: /dev/input/event0 bus: 0019 vendor 0001 product 0001 @@ -283,7 +346,7 @@ private fun PreviewWithOutput() { id: "" version: 1.0.1 events: - KEY (0001): KEY_POWER + KEY (0001): KEY_POWER input props: add device 2: /dev/input/event1 @@ -296,8 +359,8 @@ add device 2: /dev/input/event1 id: "" version: 1.0.1 events: - KEY (0001): BTN_TOOL_RUBBER BTN_STYLUS - ABS (0003): ABS_X : value 0, min 0, max 32767, fuzz 0, flat 0, resolution 0"""", + KEY (0001): BTN_TOOL_RUBBER BTN_STYLUS + ABS (0003): ABS_X : value 0, min 0, max 32767, fuzz 0, flat 0, resolution 0""", expertModeStatus = ExpertModeStatus.ENABLED, ), ) @@ -310,7 +373,8 @@ private fun PreviewRecording() { KeyMapperTheme { GetEventScreen( state = GetEventViewModel.State( - output = "add device 1: /dev/input/event0\n name: \"gpio-keys\"", + recordingOutput = """/dev/input/event1: EV_KEY KEY_VOLUMEDOWN DOWN +/dev/input/event1: EV_SYN SYN_REPORT 00""", isRecording = true, expertModeStatus = ExpertModeStatus.ENABLED, ), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt index 42ca58ae17..dc5928ca0c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt @@ -20,11 +20,11 @@ import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.handle +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState import io.github.sds100.keymapper.system.clipboard.ClipboardAdapter import io.github.sds100.keymapper.system.files.FileAdapter import io.github.sds100.keymapper.system.files.FileUtils -import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager -import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.map @@ -40,10 +40,13 @@ class GetEventViewModel @Inject constructor( private val buildConfigProvider: BuildConfigProvider, @ApplicationContext private val context: Context, resourceProvider: ResourceProvider, -) : ViewModel(), NavigationProvider by navigationProvider, ResourceProvider by resourceProvider { +) : ViewModel(), + NavigationProvider by navigationProvider, + ResourceProvider by resourceProvider { data class State( - val output: String = "", + val deviceInfoOutput: String = "", + val recordingOutput: String = "", val isLoadingDeviceInfo: Boolean = false, val isRecording: Boolean = false, val expertModeStatus: ExpertModeStatus = ExpertModeStatus.DISABLED, @@ -62,7 +65,10 @@ class GetEventViewModel @Inject constructor( }.collect { status -> val wasDisabled = state.expertModeStatus != ExpertModeStatus.ENABLED state = state.copy(expertModeStatus = status) - if (status == ExpertModeStatus.ENABLED && wasDisabled && state.output.isEmpty()) { + if (status == ExpertModeStatus.ENABLED && + wasDisabled && + state.deviceInfoOutput.isEmpty() + ) { loadDeviceInfo() } } @@ -81,7 +87,7 @@ class GetEventViewModel @Inject constructor( onSuccess = { it.stdout }, onError = { "Error: ${it.getFullMessage(this@GetEventViewModel)}" }, ) - state = state.copy(output = output, isLoadingDeviceInfo = false) + state = state.copy(deviceInfoOutput = output, isLoadingDeviceInfo = false) } } @@ -107,7 +113,7 @@ class GetEventViewModel @Inject constructor( ) if (newOutput.isNotEmpty()) { state = state.copy( - output = state.output + "\n--- Recording ---\n" + newOutput, + recordingOutput = state.recordingOutput + newOutput, ) } state = state.copy(isRecording = false) @@ -125,11 +131,11 @@ class GetEventViewModel @Inject constructor( } fun onClearClick() { - state = state.copy(output = "") + state = state.copy(recordingOutput = "") } fun onCopyToClipboardClick() { - clipboardAdapter.copy("getevent output", state.output) + clipboardAdapter.copy("getevent output", state.recordingOutput) } fun onSaveToFileClick() { @@ -137,7 +143,7 @@ class GetEventViewModel @Inject constructor( val fileName = "getevent/key_mapper_getevent_${FileUtils.createFileDate()}.txt" val file = fileAdapter.getPrivateFile(fileName) file.createFile() - file.outputStream()?.bufferedWriter()?.use { it.write(state.output) } + file.outputStream()?.bufferedWriter()?.use { it.write(state.recordingOutput) } val publicUri = fileAdapter.getPublicUriForPrivateFile(file).toUri() ShareUtils.shareFile(context, publicUri, buildConfigProvider.packageName) } @@ -151,7 +157,10 @@ class GetEventViewModel @Inject constructor( fun onSetupExpertModeClick() { viewModelScope.launch { - navigationProvider.navigate("getevent_setup_expert_mode", NavDestination.ExpertModeSetup) + navigationProvider.navigate( + "getevent_setup_expert_mode", + NavDestination.ExpertModeSetup, + ) } } } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 3fa56c873e..c8d598751e 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -645,6 +645,8 @@ Loading device info\u2026 Copy to clipboard Save to file + Info + Events Report issue From 0968f4d35f851d02365e8204b8af9515a884d78c Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 12:43:24 +0200 Subject: [PATCH 21/38] #2081 show refresh button for getevent info --- .../keymapper/base/debug/GetEventScreen.kt | 200 ++++++++++++++---- .../keymapper/base/debug/GetEventViewModel.kt | 22 +- base/src/main/res/values/strings.xml | 1 + 3 files changed, 177 insertions(+), 46 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt index bfe767f706..f448f8eec3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt @@ -1,5 +1,10 @@ package io.github.sds100.keymapper.base.debug +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -18,7 +23,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.outlined.ContentCopy -import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Share import androidx.compose.material.icons.rounded.FiberManualRecord import androidx.compose.material.icons.rounded.Stop @@ -66,13 +71,29 @@ fun GetEventScreen( state = viewModel.state, onBackClick = onBackClick, onToggleRecordClick = viewModel::onToggleRecordClick, - onClearClick = viewModel::onClearClick, - onCopyToClipboardClick = viewModel::onCopyToClipboardClick, - onSaveToFileClick = viewModel::onSaveToFileClick, + onRefreshDeviceInfoClick = viewModel::onRefreshDeviceInfoClick, + onCopyToClipboardClick = { tab -> viewModel.onCopyToClipboardClick(tab.toOutputTab()) }, + onSaveToFileClick = { tab -> viewModel.onSaveToFileClick(tab.toOutputTab()) }, onSetupExpertModeClick = viewModel::onSetupExpertModeClick, ) } +private enum class GetEventTab { + INFO, + EVENTS, +} + +private enum class RefreshButtonState { + REFRESH_INFO, + START_RECORDING, + STOP, +} + +private fun GetEventTab.toOutputTab(): GetEventViewModel.OutputTab = when (this) { + GetEventTab.INFO -> GetEventViewModel.OutputTab.INFO + GetEventTab.EVENTS -> GetEventViewModel.OutputTab.EVENTS +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun GetEventScreen( @@ -80,14 +101,23 @@ private fun GetEventScreen( state: GetEventViewModel.State, onBackClick: () -> Unit = {}, onToggleRecordClick: () -> Unit = {}, - onClearClick: () -> Unit = {}, - onCopyToClipboardClick: () -> Unit = {}, - onSaveToFileClick: () -> Unit = {}, + onRefreshDeviceInfoClick: () -> Unit = {}, + onCopyToClipboardClick: (GetEventTab) -> Unit = {}, + onSaveToFileClick: (GetEventTab) -> Unit = {}, onSetupExpertModeClick: () -> Unit = {}, ) { - val hasOutput = state.recordingOutput.isNotEmpty() val pagerState = rememberPagerState(pageCount = { 2 }) val scope = rememberCoroutineScope() + val selectedTab = if (pagerState.currentPage == 0) GetEventTab.INFO else GetEventTab.EVENTS + val hasOutputForSelectedTab = when (selectedTab) { + GetEventTab.INFO -> state.deviceInfoOutput.isNotEmpty() + GetEventTab.EVENTS -> state.recordingOutput.isNotEmpty() + } + val refreshButtonState = when { + selectedTab == GetEventTab.INFO -> RefreshButtonState.REFRESH_INFO + state.isRecording -> RefreshButtonState.STOP + else -> RefreshButtonState.START_RECORDING + } Scaffold( modifier = modifier.displayCutoutPadding(), @@ -100,41 +130,67 @@ private fun GetEventScreen( BottomAppBar( floatingActionButton = { if (state.expertModeStatus == ExpertModeStatus.ENABLED) { - val containerColor = if (state.isRecording) { + val containerColor = if (refreshButtonState == RefreshButtonState.STOP) { MaterialTheme.colorScheme.errorContainer } else { MaterialTheme.colorScheme.primaryContainer } - val contentColor = if (state.isRecording) { + val contentColor = if (refreshButtonState == RefreshButtonState.STOP) { MaterialTheme.colorScheme.onErrorContainer } else { MaterialTheme.colorScheme.onPrimaryContainer } FloatingActionButton( onClick = { - if (!state.isRecording) { - scope.launch { pagerState.animateScrollToPage(1) } + if (selectedTab == GetEventTab.INFO) { + onRefreshDeviceInfoClick() + } else { + if (!state.isRecording) { + scope.launch { pagerState.animateScrollToPage(1) } + } + onToggleRecordClick() } - onToggleRecordClick() }, containerColor = containerColor, contentColor = contentColor, elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), ) { - if (state.isRecording) { - Icon( - imageVector = Icons.Rounded.Stop, - contentDescription = stringResource( - R.string.debug_getevent_stop_recording, - ), - ) - } else { - Icon( - imageVector = Icons.Rounded.FiberManualRecord, - contentDescription = stringResource( - R.string.debug_getevent_start_recording, - ), - ) + AnimatedContent( + targetState = refreshButtonState, + transitionSpec = { + fadeIn(animationSpec = tween(200)) togetherWith + fadeOut(animationSpec = tween(200)) + }, + label = "refresh_button_state", + ) { buttonState -> + when (buttonState) { + RefreshButtonState.REFRESH_INFO -> { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = stringResource( + R.string.debug_getevent_refresh, + ), + ) + } + + RefreshButtonState.START_RECORDING -> { + Icon( + imageVector = Icons.Rounded.FiberManualRecord, + contentDescription = stringResource( + R.string.debug_getevent_start_recording, + ), + ) + } + + RefreshButtonState.STOP -> { + Icon( + imageVector = Icons.Rounded.Stop, + contentDescription = stringResource( + R.string.debug_getevent_stop_recording, + ), + ) + } + } } } } @@ -148,8 +204,8 @@ private fun GetEventScreen( } Spacer(modifier = Modifier.weight(1f)) IconButton( - onClick = onCopyToClipboardClick, - enabled = hasOutput, + onClick = { onCopyToClipboardClick(selectedTab) }, + enabled = hasOutputForSelectedTab, ) { Icon( imageVector = Icons.Outlined.ContentCopy, @@ -157,23 +213,14 @@ private fun GetEventScreen( ) } IconButton( - onClick = onSaveToFileClick, - enabled = hasOutput, + onClick = { onSaveToFileClick(selectedTab) }, + enabled = hasOutputForSelectedTab, ) { Icon( imageVector = Icons.Outlined.Share, contentDescription = stringResource(R.string.debug_getevent_save), ) } - IconButton( - onClick = onClearClick, - enabled = hasOutput, - ) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(R.string.debug_getevent_clear), - ) - } }, ) }, @@ -367,6 +414,31 @@ add device 2: /dev/input/event1 } } +@Preview +@Composable +private fun PreviewInfoTabLoading() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + isLoadingDeviceInfo = true, + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewInfoTabEmptyOutput() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + @Preview @Composable private fun PreviewRecording() { @@ -382,6 +454,54 @@ private fun PreviewRecording() { } } +@Preview +@Composable +private fun PreviewEventsContentEmptyIdle() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + isRecording = false, + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewEventsContentOutputIdle() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + recordingOutput = """/dev/input/event2: EV_KEY KEY_VOLUMEUP DOWN +/dev/input/event2: EV_SYN SYN_REPORT 00""", + isRecording = false, + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewInfoContentOutputAndLoading() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + deviceInfoOutput = """add device 3: /dev/input/event2 + bus: 0019 + vendor 0001 + product 0001 + version 0100 + name: "gpio-keys-2" + location: "gpio-keys/input1\"""", + isLoadingDeviceInfo = true, + expertModeStatus = ExpertModeStatus.ENABLED, + ), + ) + } +} + @Preview @Composable private fun PreviewExpertModeDisabled() { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt index dc5928ca0c..a5dc0fe931 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt @@ -44,6 +44,11 @@ class GetEventViewModel @Inject constructor( NavigationProvider by navigationProvider, ResourceProvider by resourceProvider { + enum class OutputTab { + INFO, + EVENTS, + } + data class State( val deviceInfoOutput: String = "", val recordingOutput: String = "", @@ -69,13 +74,13 @@ class GetEventViewModel @Inject constructor( wasDisabled && state.deviceInfoOutput.isEmpty() ) { - loadDeviceInfo() + onRefreshDeviceInfoClick() } } } } - private fun loadDeviceInfo() { + fun onRefreshDeviceInfoClick() { viewModelScope.launch { state = state.copy(isLoadingDeviceInfo = true) val result = executeShellCommandUseCase.execute( @@ -134,21 +139,26 @@ class GetEventViewModel @Inject constructor( state = state.copy(recordingOutput = "") } - fun onCopyToClipboardClick() { - clipboardAdapter.copy("getevent output", state.recordingOutput) + fun onCopyToClipboardClick(tab: OutputTab) { + clipboardAdapter.copy("getevent output", getOutputForTab(tab)) } - fun onSaveToFileClick() { + fun onSaveToFileClick(tab: OutputTab) { viewModelScope.launch(Dispatchers.IO) { val fileName = "getevent/key_mapper_getevent_${FileUtils.createFileDate()}.txt" val file = fileAdapter.getPrivateFile(fileName) file.createFile() - file.outputStream()?.bufferedWriter()?.use { it.write(state.recordingOutput) } + file.outputStream()?.bufferedWriter()?.use { it.write(getOutputForTab(tab)) } val publicUri = fileAdapter.getPublicUriForPrivateFile(file).toUri() ShareUtils.shareFile(context, publicUri, buildConfigProvider.packageName) } } + private fun getOutputForTab(tab: OutputTab): String = when (tab) { + OutputTab.INFO -> state.deviceInfoOutput + OutputTab.EVENTS -> state.recordingOutput + } + fun onBackClick() { viewModelScope.launch { navigationProvider.popBackStack() diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index c8d598751e..99a6abd93d 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -642,6 +642,7 @@ Record Stop Recording Clear output + Refresh Loading device info\u2026 Copy to clipboard Save to file From dc6a3262cb6835dbee0cdbae08f7aa02a2ea6b28 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 12:51:47 +0200 Subject: [PATCH 22/38] #2081 add getevent files to provider --- base/src/main/res/xml/provider_paths.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/base/src/main/res/xml/provider_paths.xml b/base/src/main/res/xml/provider_paths.xml index 81fcea4ee0..72b9380231 100644 --- a/base/src/main/res/xml/provider_paths.xml +++ b/base/src/main/res/xml/provider_paths.xml @@ -4,6 +4,10 @@ name="backups" path="backups" /> + + From 6532c66498ee75801f5fccb3fc03bc6bb5df2214 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 13:09:20 +0200 Subject: [PATCH 23/38] #2081 save getevent output and show it even if system bridge disabled --- .../keymapper/base/BaseViewModelHiltModule.kt | 6 + .../base/debug/GetEventOutputUseCase.kt | 110 ++++++++++ .../keymapper/base/debug/GetEventScreen.kt | 204 +++++++++--------- .../keymapper/base/debug/GetEventViewModel.kt | 86 ++------ base/src/main/res/values/strings.xml | 1 + .../io/github/sds100/keymapper/data/Keys.kt | 2 + 6 files changed, 242 insertions(+), 167 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventOutputUseCase.kt diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt index 75ebbc90c7..6c5a9240bf 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt @@ -25,6 +25,8 @@ import io.github.sds100.keymapper.base.constraints.ConfigConstraintsUseCaseImpl import io.github.sds100.keymapper.base.constraints.CreateConstraintUseCase import io.github.sds100.keymapper.base.constraints.CreateConstraintUseCaseImpl import io.github.sds100.keymapper.base.constraints.DisplayConstraintUseCase +import io.github.sds100.keymapper.base.debug.GetEventOutputUseCase +import io.github.sds100.keymapper.base.debug.GetEventOutputUseCaseImpl import io.github.sds100.keymapper.base.expertmode.ExpertModeSetupDelegateImpl import io.github.sds100.keymapper.base.expertmode.SystemBridgeSetupDelegate import io.github.sds100.keymapper.base.expertmode.SystemBridgeSetupUseCase @@ -185,6 +187,10 @@ abstract class BaseViewModelHiltModule { @ViewModelScoped abstract fun bindShareLogcatUseCase(impl: ShareLogcatUseCaseImpl): ShareLogcatUseCase + @Binds + @ViewModelScoped + abstract fun bindGetEventOutputUseCase(impl: GetEventOutputUseCaseImpl): GetEventOutputUseCase + @Binds @ViewModelScoped abstract fun bindOnboardingTipDelegate(impl: OnboardingTipDelegateImpl): OnboardingTipDelegate diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventOutputUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventOutputUseCase.kt new file mode 100644 index 0000000000..86054205a9 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventOutputUseCase.kt @@ -0,0 +1,110 @@ +package io.github.sds100.keymapper.base.debug + +import android.content.Context +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.base.actions.ExecuteShellCommandUseCase +import io.github.sds100.keymapper.base.utils.ShareUtils +import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.models.ShellExecutionMode +import io.github.sds100.keymapper.common.utils.handle +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.system.clipboard.ClipboardAdapter +import io.github.sds100.keymapper.system.files.FileAdapter +import io.github.sds100.keymapper.system.files.FileUtils +import io.github.sds100.keymapper.system.files.IFile +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext + +interface GetEventOutputUseCase { + val deviceInfoOutput: Flow + val eventsOutput: Flow + + suspend fun refreshDeviceInfo() + suspend fun recordEvents() + suspend fun stopRecording() + fun copyOutput(output: String) + suspend fun shareOutput(output: String) +} + +class GetEventOutputUseCaseImpl @Inject constructor( + @ApplicationContext private val context: Context, + private val executeShellCommandUseCase: ExecuteShellCommandUseCase, + private val preferenceRepository: PreferenceRepository, + private val clipboardAdapter: ClipboardAdapter, + private val fileAdapter: FileAdapter, + private val buildConfigProvider: BuildConfigProvider, + private val resourceProvider: ResourceProvider, +) : GetEventOutputUseCase { + + companion object { + private const val MAX_COPY_OUTPUT_LENGTH = 150_000 + } + + override val deviceInfoOutput: Flow = preferenceRepository + .get(Keys.getEventDeviceInfoOutput) + .map { it.orEmpty() } + + override val eventsOutput: Flow = preferenceRepository + .get(Keys.getEventEventsOutput) + .map { it.orEmpty() } + + override suspend fun refreshDeviceInfo() { + val output = executeShellCommandUseCase.execute( + command = "getevent -il", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 30_000L, + ).handle( + onSuccess = { it.stdout }, + onError = { "Error: ${it.getFullMessage(resourceProvider)}" }, + ) + preferenceRepository.set(Keys.getEventDeviceInfoOutput, output) + } + + override suspend fun recordEvents() { + val output = executeShellCommandUseCase.execute( + command = "getevent -lt", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 300_000L, + ).handle( + onSuccess = { it.stdout }, + onError = { "" }, + ) + if (output.isNotEmpty()) { + preferenceRepository.set(Keys.getEventEventsOutput, output) + } + } + + override suspend fun stopRecording() { + executeShellCommandUseCase.execute( + command = "pkill -x getevent || true", + executionMode = ShellExecutionMode.ADB, + timeoutMillis = 5_000L, + ) + } + + override fun copyOutput(output: String) { + clipboardAdapter.copy( + "getevent output", + output.takeLast(MAX_COPY_OUTPUT_LENGTH), + ) + } + + override suspend fun shareOutput(output: String) { + withContext(Dispatchers.IO) { + val fileName = "getevent/key_mapper_getevent_${FileUtils.createFileDate()}.txt" + val file: IFile = fileAdapter.getPrivateFile(fileName) + file.createFile() + file.outputStream()?.bufferedWriter()?.use { it.write(output) } + + val publicUri = fileAdapter.getPublicUriForPrivateFile(file).toUri() + ShareUtils.shareFile(context, publicUri, buildConfigProvider.packageName) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt index f448f8eec3..3322defce5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -48,6 +49,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -108,6 +110,7 @@ private fun GetEventScreen( ) { val pagerState = rememberPagerState(pageCount = { 2 }) val scope = rememberCoroutineScope() + val isExpertModeEnabled = state.expertModeStatus == ExpertModeStatus.ENABLED val selectedTab = if (pagerState.currentPage == 0) GetEventTab.INFO else GetEventTab.EVENTS val hasOutputForSelectedTab = when (selectedTab) { GetEventTab.INFO -> state.deviceInfoOutput.isNotEmpty() @@ -129,67 +132,69 @@ private fun GetEventScreen( bottomBar = { BottomAppBar( floatingActionButton = { - if (state.expertModeStatus == ExpertModeStatus.ENABLED) { - val containerColor = if (refreshButtonState == RefreshButtonState.STOP) { - MaterialTheme.colorScheme.errorContainer - } else { - MaterialTheme.colorScheme.primaryContainer - } - val contentColor = if (refreshButtonState == RefreshButtonState.STOP) { - MaterialTheme.colorScheme.onErrorContainer - } else { - MaterialTheme.colorScheme.onPrimaryContainer - } - FloatingActionButton( - onClick = { - if (selectedTab == GetEventTab.INFO) { - onRefreshDeviceInfoClick() - } else { - if (!state.isRecording) { - scope.launch { pagerState.animateScrollToPage(1) } - } - onToggleRecordClick() + val containerColor = if (refreshButtonState == RefreshButtonState.STOP) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + } + val contentColor = if (refreshButtonState == RefreshButtonState.STOP) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onPrimaryContainer + } + FloatingActionButton( + modifier = if (isExpertModeEnabled) Modifier else Modifier.alpha(0.5f), + onClick = { + if (!isExpertModeEnabled) { + return@FloatingActionButton + } + if (selectedTab == GetEventTab.INFO) { + onRefreshDeviceInfoClick() + } else { + if (!state.isRecording) { + scope.launch { pagerState.animateScrollToPage(1) } } + onToggleRecordClick() + } + }, + containerColor = containerColor, + contentColor = contentColor, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + ) { + AnimatedContent( + targetState = refreshButtonState, + transitionSpec = { + fadeIn(animationSpec = tween(200)) togetherWith + fadeOut(animationSpec = tween(200)) }, - containerColor = containerColor, - contentColor = contentColor, - elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), - ) { - AnimatedContent( - targetState = refreshButtonState, - transitionSpec = { - fadeIn(animationSpec = tween(200)) togetherWith - fadeOut(animationSpec = tween(200)) - }, - label = "refresh_button_state", - ) { buttonState -> - when (buttonState) { - RefreshButtonState.REFRESH_INFO -> { - Icon( - imageVector = Icons.Outlined.Refresh, - contentDescription = stringResource( - R.string.debug_getevent_refresh, - ), - ) - } + label = "refresh_button_state", + ) { buttonState -> + when (buttonState) { + RefreshButtonState.REFRESH_INFO -> { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = stringResource( + R.string.debug_getevent_refresh, + ), + ) + } - RefreshButtonState.START_RECORDING -> { - Icon( - imageVector = Icons.Rounded.FiberManualRecord, - contentDescription = stringResource( - R.string.debug_getevent_start_recording, - ), - ) - } + RefreshButtonState.START_RECORDING -> { + Icon( + imageVector = Icons.Rounded.FiberManualRecord, + contentDescription = stringResource( + R.string.debug_getevent_start_recording, + ), + ) + } - RefreshButtonState.STOP -> { - Icon( - imageVector = Icons.Rounded.Stop, - contentDescription = stringResource( - R.string.debug_getevent_stop_recording, - ), - ) - } + RefreshButtonState.STOP -> { + Icon( + imageVector = Icons.Rounded.Stop, + contentDescription = stringResource( + R.string.debug_getevent_stop_recording, + ), + ) } } } @@ -247,39 +252,38 @@ private fun GetEventScreen( .padding(16.dp), onSetupExpertModeClick = onSetupExpertModeClick, ) - } else { - PrimaryTabRow( - selectedTabIndex = pagerState.targetPage, - modifier = Modifier.fillMaxWidth(), - contentColor = MaterialTheme.colorScheme.onSurface, - ) { - Tab( - selected = pagerState.targetPage == 0, - onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, - text = { Text(stringResource(R.string.debug_getevent_tab_info)) }, - ) - Tab( - selected = pagerState.targetPage == 1, - onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, - text = { Text(stringResource(R.string.debug_getevent_tab_events)) }, - ) - } + } + PrimaryTabRow( + selectedTabIndex = pagerState.targetPage, + modifier = Modifier.fillMaxWidth(), + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Tab( + selected = pagerState.targetPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(stringResource(R.string.debug_getevent_tab_info)) }, + ) + Tab( + selected = pagerState.targetPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(stringResource(R.string.debug_getevent_tab_events)) }, + ) + } - HorizontalPager( - modifier = Modifier.fillMaxSize(), - state = pagerState, - ) { pageIndex -> - when (pageIndex) { - 0 -> InfoContent( - modifier = Modifier.fillMaxSize(), - state = state, - ) + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = pagerState, + ) { pageIndex -> + when (pageIndex) { + 0 -> InfoContent( + modifier = Modifier.fillMaxSize(), + state = state, + ) - 1 -> EventsContent( - modifier = Modifier.fillMaxSize(), - state = state, - ) - } + 1 -> EventsContent( + modifier = Modifier.fillMaxSize(), + state = state, + ) } } } @@ -315,7 +319,8 @@ private fun ExpertModeSetupCard( private fun InfoContent(modifier: Modifier = Modifier, state: GetEventViewModel.State) { Column(modifier = modifier) { if (state.isLoadingDeviceInfo) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(16.dp)) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) } if (state.deviceInfoOutput.isNotEmpty()) { @@ -352,7 +357,13 @@ private fun EventsContent(modifier: Modifier = Modifier, state: GetEventViewMode Column(modifier = modifier) { if (state.isRecording) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(16.dp)) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(R.string.debug_getevent_events_output_after_recording), + style = MaterialTheme.typography.bodySmall, + ) } if (state.recordingOutput.isNotEmpty()) { @@ -454,19 +465,6 @@ private fun PreviewRecording() { } } -@Preview -@Composable -private fun PreviewEventsContentEmptyIdle() { - KeyMapperTheme { - GetEventScreen( - state = GetEventViewModel.State( - isRecording = false, - expertModeStatus = ExpertModeStatus.ENABLED, - ), - ) - } -} - @Preview @Composable private fun PreviewEventsContentOutputIdle() { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt index a5dc0fe931..aa4846544b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt @@ -1,48 +1,28 @@ package io.github.sds100.keymapper.base.debug -import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import io.github.sds100.keymapper.base.actions.ExecuteShellCommandUseCase import io.github.sds100.keymapper.base.utils.ExpertModeStatus -import io.github.sds100.keymapper.base.utils.ShareUtils -import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.navigate -import io.github.sds100.keymapper.base.utils.ui.ResourceProvider -import io.github.sds100.keymapper.common.BuildConfigProvider -import io.github.sds100.keymapper.common.models.ShellExecutionMode -import io.github.sds100.keymapper.common.utils.handle import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState -import io.github.sds100.keymapper.system.clipboard.ClipboardAdapter -import io.github.sds100.keymapper.system.files.FileAdapter -import io.github.sds100.keymapper.system.files.FileUtils import javax.inject.Inject -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @HiltViewModel class GetEventViewModel @Inject constructor( - private val executeShellCommandUseCase: ExecuteShellCommandUseCase, + private val outputUseCase: GetEventOutputUseCase, private val navigationProvider: NavigationProvider, private val systemBridgeConnectionManager: SystemBridgeConnectionManager, - private val clipboardAdapter: ClipboardAdapter, - private val fileAdapter: FileAdapter, - private val buildConfigProvider: BuildConfigProvider, - @ApplicationContext private val context: Context, - resourceProvider: ResourceProvider, ) : ViewModel(), - NavigationProvider by navigationProvider, - ResourceProvider by resourceProvider { + NavigationProvider by navigationProvider { enum class OutputTab { INFO, @@ -61,6 +41,18 @@ class GetEventViewModel @Inject constructor( private set init { + viewModelScope.launch { + outputUseCase.deviceInfoOutput.collect { output -> + state = state.copy(deviceInfoOutput = output) + } + } + + viewModelScope.launch { + outputUseCase.eventsOutput.collect { output -> + state = state.copy(recordingOutput = output) + } + } + viewModelScope.launch { systemBridgeConnectionManager.connectionState.map { connectionState -> when (connectionState) { @@ -83,16 +75,8 @@ class GetEventViewModel @Inject constructor( fun onRefreshDeviceInfoClick() { viewModelScope.launch { state = state.copy(isLoadingDeviceInfo = true) - val result = executeShellCommandUseCase.execute( - command = "getevent -il", - executionMode = ShellExecutionMode.ADB, - timeoutMillis = 30_000L, - ) - val output = result.handle( - onSuccess = { it.stdout }, - onError = { "Error: ${it.getFullMessage(this@GetEventViewModel)}" }, - ) - state = state.copy(deviceInfoOutput = output, isLoadingDeviceInfo = false) + outputUseCase.refreshDeviceInfo() + state = state.copy(isLoadingDeviceInfo = false) } } @@ -105,52 +89,26 @@ class GetEventViewModel @Inject constructor( } private fun startRecording() { - state = state.copy(isRecording = true) viewModelScope.launch { - val result = executeShellCommandUseCase.execute( - command = "getevent -lt", - executionMode = ShellExecutionMode.ADB, - timeoutMillis = 300_000L, - ) - val newOutput = result.handle( - onSuccess = { it.stdout }, - onError = { "" }, - ) - if (newOutput.isNotEmpty()) { - state = state.copy( - recordingOutput = state.recordingOutput + newOutput, - ) - } + state = state.copy(isRecording = true) + outputUseCase.recordEvents() state = state.copy(isRecording = false) } } private fun stopRecording() { viewModelScope.launch { - executeShellCommandUseCase.execute( - command = "pkill -x getevent || true", - executionMode = ShellExecutionMode.ADB, - timeoutMillis = 5_000L, - ) + outputUseCase.stopRecording() } } - fun onClearClick() { - state = state.copy(recordingOutput = "") - } - fun onCopyToClipboardClick(tab: OutputTab) { - clipboardAdapter.copy("getevent output", getOutputForTab(tab)) + outputUseCase.copyOutput(getOutputForTab(tab)) } fun onSaveToFileClick(tab: OutputTab) { - viewModelScope.launch(Dispatchers.IO) { - val fileName = "getevent/key_mapper_getevent_${FileUtils.createFileDate()}.txt" - val file = fileAdapter.getPrivateFile(fileName) - file.createFile() - file.outputStream()?.bufferedWriter()?.use { it.write(getOutputForTab(tab)) } - val publicUri = fileAdapter.getPublicUriForPrivateFile(file).toUri() - ShareUtils.shareFile(context, publicUri, buildConfigProvider.packageName) + viewModelScope.launch { + outputUseCase.shareOutput(getOutputForTab(tab)) } } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 99a6abd93d..9d6bb9862b 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -648,6 +648,7 @@ Save to file Info Events + Output appears after recording stops. Report issue diff --git a/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt b/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt index 65f9615f5f..59e88eb0a4 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/Keys.kt @@ -153,6 +153,8 @@ object Keys { booleanPreferencesKey("key_key_event_actions_use_system_bridge") val shellCommandScriptText = stringPreferencesKey("key_shell_command_script_text") + val getEventDeviceInfoOutput = stringPreferencesKey("key_getevent_device_info_output") + val getEventEventsOutput = stringPreferencesKey("key_getevent_events_output") /** * This is stored as true when PRO Mode has been auto started after updating From 48694b5d6c3e0f1acd99e5e4acd2dfaaecb81533 Mon Sep 17 00:00:00 2001 From: Seth Schroeder Date: Tue, 5 May 2026 13:43:24 +0200 Subject: [PATCH 24/38] Update CHANGELOG with new entries --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50cc80581a..3c23e06649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - #2045 add action to input on-screen keyboard enter/send button. - #2106 disable the keyboard auto-switching setting when manually switching the keyboard in the Key Mapper homescreen menu. +- #2081 add getevent debug screen. ## Fixed From b39cc9da31481a0831bf3e8a6028d6ccbbb90593 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 13:46:50 +0200 Subject: [PATCH 25/38] #1956 fix emergency kill timing source --- .../jni/src/evdev_jni_observer.rs | 99 +++++++++++++++++-- 1 file changed, 91 insertions(+), 8 deletions(-) diff --git a/evdev/src/main/rust/evdev_manager/jni/src/evdev_jni_observer.rs b/evdev/src/main/rust/evdev_manager/jni/src/evdev_jni_observer.rs index 738f115bc0..acfcd735f6 100644 --- a/evdev/src/main/rust/evdev_manager/jni/src/evdev_jni_observer.rs +++ b/evdev/src/main/rust/evdev_manager/jni/src/evdev_jni_observer.rs @@ -8,12 +8,25 @@ use jni::objects::{GlobalRef, JValue}; use jni::JavaVM; use std::process; use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +const EMERGENCY_KILL_HOLD_DURATION: Duration = Duration::from_secs(10); + +fn should_emergency_kill( + down_time: Option, + release_time: Instant, + threshold: Duration, +) -> bool { + down_time + .and_then(|pressed_at| release_time.checked_duration_since(pressed_at)) + .is_some_and(|hold_duration| hold_duration >= threshold) +} pub struct EvdevJniObserver { jvm: Arc, system_bridge: GlobalRef, key_layout_map_manager: Arc, - power_button_down_time: Mutex, + power_button_down_time: Mutex>, } impl std::fmt::Debug for EvdevJniObserver { @@ -38,7 +51,7 @@ impl EvdevJniObserver { jvm, system_bridge, key_layout_map_manager, - power_button_down_time: Mutex::new(0), + power_button_down_time: Mutex::new(None), } } @@ -48,17 +61,19 @@ impl EvdevJniObserver { ev_code: u32, android_code: u32, value: i32, - time_sec: libc::time_t, ) { let mut time_guard = self.power_button_down_time.lock().unwrap(); // KEY_POWER scan code = 116 if ev_code == 116 || android_code == android_codes::AKEYCODE_POWER { if value == 1 { - *time_guard = time_sec; + *time_guard = Some(Instant::now()); } else if value == 0 { // Button up - check if held for 10+ seconds - let down_time = *time_guard; - if down_time > 0 && time_sec - down_time >= 10 { + if should_emergency_kill( + *time_guard, + Instant::now(), + EMERGENCY_KILL_HOLD_DURATION, + ) { // Must send log to Key Mapper for diagnostic purposes. warn!("Emergency killing system bridge!"); // Call BaseSystemBridge.onEmergencyKillSystemBridge() via JNI @@ -72,7 +87,7 @@ impl EvdevJniObserver { } process::exit(0); } - *time_guard = 0 + *time_guard = None } } } @@ -173,7 +188,7 @@ impl EvdevJniObserver { }; // Handle power button emergency kill - self.handle_power_button(ev_code, android_code, event.value, event.time.tv_sec); + self.handle_power_button(ev_code, android_code, event.value); // Call BaseSystemBridge.onEvdevEvent() via JNI @@ -280,3 +295,71 @@ impl EvdevJniObserver { } } } + +#[cfg(test)] +mod tests { + use super::{should_emergency_kill, EMERGENCY_KILL_HOLD_DURATION}; + use std::time::{Duration, Instant}; + + #[test] + fn no_down_event_never_triggers_emergency_kill() { + let release_time = Instant::now(); + assert!(!should_emergency_kill( + None, + release_time, + EMERGENCY_KILL_HOLD_DURATION + )); + } + + #[test] + fn hold_duration_below_threshold_does_not_trigger_emergency_kill() { + let pressed_at = Instant::now(); + let release_time = pressed_at + Duration::from_secs(9); + assert!(!should_emergency_kill( + Some(pressed_at), + release_time, + EMERGENCY_KILL_HOLD_DURATION + )); + } + + #[test] + fn hold_duration_at_threshold_triggers_emergency_kill() { + let pressed_at = Instant::now(); + let release_time = pressed_at + EMERGENCY_KILL_HOLD_DURATION; + assert!(should_emergency_kill( + Some(pressed_at), + release_time, + EMERGENCY_KILL_HOLD_DURATION + )); + } + + #[test] + fn backward_time_jump_never_triggers_emergency_kill() { + let pressed_at = Instant::now() + Duration::from_secs(5); + let release_time = Instant::now(); + assert!(!should_emergency_kill( + Some(pressed_at), + release_time, + EMERGENCY_KILL_HOLD_DURATION + )); + } + + #[test] + fn ignores_event_timestamp_drift_and_uses_monotonic_elapsed_time() { + // Simulate issue #1956: evdev event timestamps can drift/jump and suggest + // a very long hold, even when real elapsed time is short. + let fake_event_down_ts_sec = 1_000_i64; + let fake_event_up_ts_sec = fake_event_down_ts_sec + 60; + let event_timestamp_delta = fake_event_up_ts_sec - fake_event_down_ts_sec; + assert!(event_timestamp_delta >= 10); + + // Real elapsed time is still short (< 10s), so emergency kill must not trigger. + let pressed_at = Instant::now(); + let release_time = pressed_at + Duration::from_secs(2); + assert!(!should_emergency_kill( + Some(pressed_at), + release_time, + EMERGENCY_KILL_HOLD_DURATION + )); + } +} From 166f751269a0457f2caf08d53a0cbdf75090a14b Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 13:48:26 +0200 Subject: [PATCH 26/38] #1029 feat: add action to show a toast message --- CHANGELOG.md | 1 + .../keymapper/base/actions/ActionData.kt | 16 ++ .../base/actions/ActionDataEntityMapper.kt | 152 ++++++++++++- .../base/actions/ActionErrorSnapshot.kt | 8 + .../sds100/keymapper/base/actions/ActionId.kt | 1 + .../keymapper/base/actions/ActionUiHelper.kt | 11 + .../keymapper/base/actions/ActionUtils.kt | 5 + .../base/actions/ChooseActionScreen.kt | 1 + .../base/actions/CreateActionDelegate.kt | 30 +++ .../base/actions/PerformActionsUseCase.kt | 8 + .../base/actions/ToastActionBottomSheet.kt | 208 ++++++++++++++++++ base/src/main/res/values/strings.xml | 8 + .../keymapper/data/entities/ActionEntity.kt | 2 + .../system/popup/AndroidToastAdapter.kt | 5 +- .../keymapper/system/popup/ToastAdapter.kt | 2 +- 15 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ToastActionBottomSheet.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 50cc80581a..f597f3e8f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - #2045 add action to input on-screen keyboard enter/send button. - #2106 disable the keyboard auto-switching setting when manually switching the keyboard in the Key Mapper homescreen menu. +- #1029 add action to show a toast message. ## Fixed diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index e3903bd094..4c3bb89e50 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -924,6 +924,22 @@ sealed class ActionData : Comparable { } } + @Serializable + data class Toast(val message: String, val duration: Duration) : ActionData() { + override val id: ActionId = ActionId.TOAST + + @Serializable + enum class Duration { + SHORT, + LONG, + } + + override fun compareTo(other: ActionData) = when (other) { + is Toast -> compareValuesBy(this, other, { it.message }, { it.duration }) + else -> super.compareTo(other) + } + } + @Serializable data object AnswerCall : ActionData() { override val id: ActionId = ActionId.ANSWER_PHONE_CALL diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index a8fc7a24d0..75c7d48fa6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -33,26 +33,43 @@ object ActionDataEntityMapper { fun fromEntity(entity: ActionEntity): ActionData? { val actionId = when (entity.type) { ActionEntity.Type.APP -> ActionId.APP + ActionEntity.Type.APP_SHORTCUT -> ActionId.APP_SHORTCUT + ActionEntity.Type.KEY_EVENT -> ActionId.KEY_EVENT + ActionEntity.Type.TEXT_BLOCK -> ActionId.TEXT + ActionEntity.Type.URL -> ActionId.URL + ActionEntity.Type.TAP_COORDINATE -> ActionId.TAP_SCREEN + ActionEntity.Type.SWIPE_COORDINATE -> ActionId.SWIPE_SCREEN + ActionEntity.Type.PINCH_COORDINATE -> ActionId.PINCH_SCREEN + ActionEntity.Type.INTENT -> ActionId.INTENT + ActionEntity.Type.PHONE_CALL -> ActionId.PHONE_CALL + ActionEntity.Type.SEND_SMS -> ActionId.SEND_SMS + ActionEntity.Type.COMPOSE_SMS -> ActionId.COMPOSE_SMS + ActionEntity.Type.SOUND -> ActionId.SOUND + ActionEntity.Type.SYSTEM_ACTION -> { SYSTEM_ACTION_ID_MAP.getKey(entity.data) ?: return null } ActionEntity.Type.INTERACT_UI_ELEMENT -> ActionId.INTERACT_UI_ELEMENT + ActionEntity.Type.SHELL_COMMAND -> ActionId.SHELL_COMMAND + ActionEntity.Type.MODIFY_SETTING -> ActionId.MODIFY_SETTING + ActionEntity.Type.CREATE_NOTIFICATION -> ActionId.CREATE_NOTIFICATION + ActionEntity.Type.TOAST -> ActionId.TOAST } return when (actionId) { @@ -246,10 +263,12 @@ object ActionDataEntityMapper { number = entity.data, message = message, ) + ActionId.COMPOSE_SMS -> ActionData.ComposeSms( number = entity.data, message = message, ) + else -> return null } } @@ -320,12 +339,15 @@ object ActionDataEntityMapper { when (actionId) { ActionId.VOLUME_UP -> ActionData.Volume.Up(showVolumeUi, volumeStream) + ActionId.VOLUME_DOWN -> ActionData.Volume.Down(showVolumeUi, volumeStream) + ActionId.VOLUME_TOGGLE_MUTE -> ActionData.Volume.ToggleMute( showVolumeUi, ) ActionId.VOLUME_UNMUTE -> ActionData.Volume.UnMute(showVolumeUi) + ActionId.VOLUME_MUTE -> ActionData.Volume.Mute(showVolumeUi) else -> throw Exception("don't know how to create system action for $actionId") @@ -333,7 +355,9 @@ object ActionDataEntityMapper { } ActionId.MUTE_MICROPHONE -> ActionData.Microphone.Mute + ActionId.UNMUTE_MICROPHONE -> ActionData.Microphone.Unmute + ActionId.TOGGLE_MUTE_MICROPHONE -> ActionData.Microphone.Toggle ActionId.TOGGLE_FLASHLIGHT, @@ -350,6 +374,7 @@ object ActionDataEntityMapper { when (actionId) { ActionId.TOGGLE_FLASHLIGHT -> ActionData.Flashlight.Toggle(lens, flashStrength) + ActionId.ENABLE_FLASHLIGHT -> ActionData.Flashlight.Enable(lens, flashStrength) ActionId.CHANGE_FLASHLIGHT_STRENGTH -> { @@ -477,97 +502,159 @@ object ActionDataEntityMapper { } ActionId.TOGGLE_WIFI -> ActionData.Wifi.Toggle + ActionId.ENABLE_WIFI -> ActionData.Wifi.Enable + ActionId.DISABLE_WIFI -> ActionData.Wifi.Disable ActionId.TOGGLE_BLUETOOTH -> ActionData.Bluetooth.Toggle + ActionId.ENABLE_BLUETOOTH -> ActionData.Bluetooth.Enable + ActionId.DISABLE_BLUETOOTH -> ActionData.Bluetooth.Disable ActionId.TOGGLE_MOBILE_DATA -> ActionData.MobileData.Toggle + ActionId.ENABLE_MOBILE_DATA -> ActionData.MobileData.Enable + ActionId.DISABLE_MOBILE_DATA -> ActionData.MobileData.Disable ActionId.TOGGLE_HOTSPOT -> ActionData.Hotspot.Toggle + ActionId.ENABLE_HOTSPOT -> ActionData.Hotspot.Enable + ActionId.DISABLE_HOTSPOT -> ActionData.Hotspot.Disable ActionId.TOGGLE_AUTO_BRIGHTNESS -> ActionData.Brightness.ToggleAuto + ActionId.DISABLE_AUTO_BRIGHTNESS -> ActionData.Brightness.DisableAuto + ActionId.ENABLE_AUTO_BRIGHTNESS -> ActionData.Brightness.EnableAuto + ActionId.INCREASE_BRIGHTNESS -> ActionData.Brightness.Increase + ActionId.DECREASE_BRIGHTNESS -> ActionData.Brightness.Decrease ActionId.TOGGLE_NIGHT_SHIFT -> ActionData.NightShift.Toggle + ActionId.ENABLE_NIGHT_SHIFT -> ActionData.NightShift.Enable + ActionId.DISABLE_NIGHT_SHIFT -> ActionData.NightShift.Disable ActionId.TOGGLE_AUTO_ROTATE -> ActionData.Rotation.ToggleAuto + ActionId.ENABLE_AUTO_ROTATE -> ActionData.Rotation.EnableAuto + ActionId.DISABLE_AUTO_ROTATE -> ActionData.Rotation.DisableAuto + ActionId.PORTRAIT_MODE -> ActionData.Rotation.Portrait + ActionId.LANDSCAPE_MODE -> ActionData.Rotation.Landscape + ActionId.SWITCH_ORIENTATION -> ActionData.Rotation.SwitchOrientation ActionId.VOLUME_SHOW_DIALOG -> ActionData.Volume.ShowDialog + ActionId.CYCLE_RINGER_MODE -> ActionData.Volume.CycleRingerMode + ActionId.CYCLE_VIBRATE_RING -> ActionData.Volume.CycleVibrateRing ActionId.EXPAND_NOTIFICATION_DRAWER -> ActionData.StatusBar.ExpandNotifications + ActionId.TOGGLE_NOTIFICATION_DRAWER -> ActionData.StatusBar.ToggleNotifications + ActionId.EXPAND_QUICK_SETTINGS -> ActionData.StatusBar.ExpandQuickSettings + ActionId.TOGGLE_QUICK_SETTINGS -> ActionData.StatusBar.ToggleQuickSettings + ActionId.COLLAPSE_STATUS_BAR -> ActionData.StatusBar.Collapse ActionId.PAUSE_MEDIA -> ActionData.ControlMedia.Pause + ActionId.PLAY_MEDIA -> ActionData.ControlMedia.Play + ActionId.PLAY_PAUSE_MEDIA -> ActionData.ControlMedia.PlayPause + ActionId.NEXT_TRACK -> ActionData.ControlMedia.NextTrack + ActionId.PREVIOUS_TRACK -> ActionData.ControlMedia.PreviousTrack + ActionId.FAST_FORWARD -> ActionData.ControlMedia.FastForward + ActionId.REWIND -> ActionData.ControlMedia.Rewind + ActionId.STOP_MEDIA -> ActionData.ControlMedia.Stop + ActionId.STEP_FORWARD -> ActionData.ControlMedia.StepForward + ActionId.STEP_BACKWARD -> ActionData.ControlMedia.StepBackward ActionId.GO_BACK -> ActionData.GoBack + ActionId.GO_HOME -> ActionData.GoHome + ActionId.OPEN_RECENTS -> ActionData.OpenRecents + ActionId.TOGGLE_SPLIT_SCREEN -> ActionData.ToggleSplitScreen + ActionId.GO_LAST_APP -> ActionData.GoLastApp + ActionId.OPEN_MENU -> ActionData.OpenMenu ActionId.ENABLE_NFC -> ActionData.Nfc.Enable + ActionId.DISABLE_NFC -> ActionData.Nfc.Disable + ActionId.TOGGLE_NFC -> ActionData.Nfc.Toggle ActionId.TOGGLE_KEYBOARD -> ActionData.ToggleKeyboard + ActionId.SHOW_KEYBOARD -> ActionData.ShowKeyboard + ActionId.HIDE_KEYBOARD -> ActionData.HideKeyboard + ActionId.SHOW_KEYBOARD_PICKER -> ActionData.ShowKeyboardPicker + ActionId.PERFORM_IME_ACTION -> ActionData.PerformImeAction + ActionId.TEXT_CUT -> ActionData.CutText + ActionId.TEXT_COPY -> ActionData.CopyText + ActionId.TEXT_PASTE -> ActionData.PasteText + ActionId.SELECT_WORD_AT_CURSOR -> ActionData.SelectWordAtCursor ActionId.TOGGLE_AIRPLANE_MODE -> ActionData.AirplaneMode.Toggle + ActionId.ENABLE_AIRPLANE_MODE -> ActionData.AirplaneMode.Enable + ActionId.DISABLE_AIRPLANE_MODE -> ActionData.AirplaneMode.Disable ActionId.SCREENSHOT -> ActionData.Screenshot + ActionId.OPEN_VOICE_ASSISTANT -> ActionData.VoiceAssistant + ActionId.OPEN_DEVICE_ASSISTANT -> ActionData.DeviceAssistant ActionId.OPEN_CAMERA -> ActionData.OpenCamera + ActionId.LOCK_DEVICE -> ActionData.LockDevice + ActionId.POWER_ON_OFF_DEVICE -> ActionData.ScreenOnOff + ActionId.SECURE_LOCK_DEVICE -> ActionData.SecureLock + ActionId.CONSUME_KEY_EVENT -> ActionData.ConsumeKeyEvent + ActionId.OPEN_SETTINGS -> ActionData.OpenSettings + ActionId.SHOW_POWER_MENU -> ActionData.ShowPowerMenu + ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> ActionData.DismissLastNotification + ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionData.DismissAllNotifications + ActionId.CREATE_NOTIFICATION -> { val title = entity.extras.getData(ActionEntity.EXTRA_NOTIFICATION_TITLE).valueOrNull() @@ -587,9 +674,31 @@ object ActionDataEntityMapper { timeoutMs = timeoutMs, ) } + + ActionId.TOAST -> { + val message = entity.data.takeIf { it.isNotBlank() } ?: return null + + val durationString = entity.extras.getData(ActionEntity.EXTRA_TOAST_DURATION) + .valueOrNull() ?: return null + + val duration = try { + ActionData.Toast.Duration.valueOf(durationString) + } catch (_: IllegalArgumentException) { + return null + } + + ActionData.Toast( + message = message, + duration = duration, + ) + } + ActionId.ANSWER_PHONE_CALL -> ActionData.AnswerCall + ActionId.END_PHONE_CALL -> ActionData.EndCall + ActionId.DEVICE_CONTROLS -> ActionData.DeviceControls + ActionId.HTTP_REQUEST -> { val method = entity.extras.getData(ActionEntity.EXTRA_HTTP_METHOD).then { HTTP_METHOD_MAP.getKey(it)!!.success() @@ -684,18 +793,23 @@ object ActionDataEntityMapper { ActionEntity.CURSOR_TYPE_CHAR -> Success( ActionData.MoveCursor.Type.CHAR, ) + ActionEntity.CURSOR_TYPE_WORD -> Success( ActionData.MoveCursor.Type.WORD, ) + ActionEntity.CURSOR_TYPE_LINE -> Success( ActionData.MoveCursor.Type.LINE, ) + ActionEntity.CURSOR_TYPE_PARAGRAPH -> Success( ActionData.MoveCursor.Type.PARAGRAPH, ) + ActionEntity.CURSOR_TYPE_PAGE -> Success( ActionData.MoveCursor.Type.PAGE, ) + else -> KMError.Exception( IllegalArgumentException("Unknown move cursor type: $value"), ) @@ -708,9 +822,11 @@ object ActionDataEntityMapper { ActionEntity.CURSOR_DIRECTION_START -> Success( ActionData.MoveCursor.Direction.START, ) + ActionEntity.CURSOR_DIRECTION_END -> Success( ActionData.MoveCursor.Direction.END, ) + else -> KMError.Exception( IllegalArgumentException("Unknown move cursor direction: $value"), ) @@ -753,6 +869,7 @@ object ActionDataEntityMapper { } ActionId.FORCE_STOP_APP -> ActionData.ForceStopApp + ActionId.CLEAR_RECENT_APP -> ActionData.ClearRecentApp ActionId.MODIFY_SETTING -> { @@ -802,6 +919,7 @@ object ActionDataEntityMapper { is ActionData.ShellCommand -> ActionEntity.Type.SHELL_COMMAND is ActionData.ModifySetting -> ActionEntity.Type.MODIFY_SETTING is ActionData.CreateNotification -> ActionEntity.Type.CREATE_NOTIFICATION + is ActionData.Toast -> ActionEntity.Type.TOAST else -> ActionEntity.Type.SYSTEM_ACTION } @@ -851,35 +969,60 @@ object ActionDataEntityMapper { @Suppress("ktlint:standard:max-line-length") private fun getDataString(data: ActionData): String = when (data) { is ActionData.Intent -> data.uri + is ActionData.InputKeyEvent -> data.keyCode.toString() + is ActionData.App -> data.packageName + is ActionData.AppShortcut -> data.uri + is ActionData.PhoneCall -> data.number + is ActionData.SendSms -> data.number + is ActionData.ComposeSms -> data.number + is ActionData.TapScreen -> "${data.x},${data.y}" + is ActionData.SwipeScreen -> "${data.xStart},${data.yStart},${data.xEnd},${data.yEnd},${data.fingerCount},${data.duration}" + is ActionData.PinchScreen -> "${data.x},${data.y},${data.distance},${data.pinchType},${data.fingerCount},${data.duration}" + is ActionData.Text -> data.text + is ActionData.Url -> data.url + is ActionData.Sound -> when (data) { is ActionData.Sound.Ringtone -> data.uri is ActionData.Sound.SoundFile -> data.soundUid } is ActionData.InteractUiElement -> data.description + is ActionData.ShellCommand -> Base64.encodeToString( data.command.toByteArray(), Base64.DEFAULT, - ).trim() // Trim to remove trailing newline added by Base64.DEFAULT + ).trim() + + // Trim to remove trailing newline added by Base64.DEFAULT is ActionData.CreateNotification -> data.text + + is ActionData.Toast -> data.message + is ActionData.HttpRequest -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.ControlMediaForApp.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.ControlMediaForApp.Stop -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.ControlMedia.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.ControlMedia.Stop -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.GoBack -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.ModifySetting -> data.settingKey + else -> SYSTEM_ACTION_ID_MAP[data.id]!! } @@ -916,6 +1059,7 @@ object ActionDataEntityMapper { }.toList() is ActionData.App -> emptyList() + is ActionData.AppShortcut -> sequence { yield(EntityExtra(ActionEntity.EXTRA_SHORTCUT_TITLE, data.shortcutTitle)) data.packageName?.let { yield(EntityExtra(ActionEntity.EXTRA_PACKAGE_NAME, it)) } @@ -987,6 +1131,7 @@ object ActionDataEntityMapper { } is ActionData.Flashlight.Disable -> listOf(lensExtra) + is ActionData.Flashlight.ChangeStrength -> buildList { add(lensExtra) add( @@ -1054,6 +1199,7 @@ object ActionDataEntityMapper { }.toList() is ActionData.Text -> emptyList() + is ActionData.Url -> emptyList() is ActionData.Sound.SoundFile -> listOf( @@ -1172,6 +1318,10 @@ object ActionDataEntityMapper { } } + is ActionData.Toast -> listOf( + EntityExtra(ActionEntity.EXTRA_TOAST_DURATION, data.duration.name), + ) + else -> emptyList() } 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 9cf7a00748..6c3e5c78ed 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 @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.actions import android.annotation.SuppressLint +import android.os.Build import io.github.sds100.keymapper.base.actions.sound.SoundsManager import io.github.sds100.keymapper.base.system.inputmethod.KeyMapperImeHelper import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface @@ -165,6 +166,13 @@ class LazyActionErrorSnapshot( } } + if (action is ActionData.Toast && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + !isPermissionGranted(Permission.POST_NOTIFICATIONS) + ) { + return SystemError.PermissionDenied(Permission.POST_NOTIFICATIONS) + } + when (action) { is ActionData.App -> { return getAppError(action.packageName) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index 2ce2fd0eb3..d42bb721ed 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt @@ -148,6 +148,7 @@ enum class ActionId { DISMISS_MOST_RECENT_NOTIFICATION, DISMISS_ALL_NOTIFICATIONS, CREATE_NOTIFICATION, + TOAST, ANSWER_PHONE_CALL, END_PHONE_CALL, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index b97425adf2..9d1f277883 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -674,6 +674,17 @@ class ActionUiHelper( action.title, ) } + + is ActionData.Toast -> { + when (action.duration) { + ActionData.Toast.Duration.SHORT -> { + getString(R.string.action_toast_description_short, action.message) + } + ActionData.Toast.Duration.LONG -> { + getString(R.string.action_toast_description_long, action.message) + } + } + } } fun getIcon(action: ActionData): ComposeIconInfo = when (action) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt index 85586fe539..846e2e3aba 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt @@ -239,6 +239,7 @@ object ActionUtils { ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> ActionCategory.NOTIFICATIONS ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionCategory.NOTIFICATIONS ActionId.CREATE_NOTIFICATION -> ActionCategory.NOTIFICATIONS + ActionId.TOAST -> ActionCategory.NOTIFICATIONS ActionId.DEVICE_CONTROLS -> ActionCategory.APPS ActionId.INTERACT_UI_ELEMENT -> ActionCategory.APPS ActionId.FORCE_STOP_APP -> ActionCategory.APPS @@ -485,6 +486,7 @@ object ActionUtils { ActionId.DISMISS_ALL_NOTIFICATIONS -> R.string.action_dismiss_all_notifications ActionId.CREATE_NOTIFICATION -> R.string.action_create_notification + ActionId.TOAST -> R.string.action_toast ActionId.ANSWER_PHONE_CALL -> R.string.action_answer_call @@ -632,6 +634,7 @@ object ActionUtils { ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> R.drawable.ic_baseline_clear_all_24 ActionId.DISMISS_ALL_NOTIFICATIONS -> R.drawable.ic_baseline_clear_all_24 ActionId.CREATE_NOTIFICATION -> R.drawable.ic_notification_play + ActionId.TOAST -> R.drawable.ic_outline_message_24 ActionId.ANSWER_PHONE_CALL -> R.drawable.ic_outline_call_24 ActionId.END_PHONE_CALL -> R.drawable.ic_outline_call_end_24 ActionId.SEND_SMS -> R.drawable.ic_outline_message_24 @@ -1067,6 +1070,7 @@ object ActionUtils { ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> Icons.Outlined.ClearAll ActionId.DISMISS_ALL_NOTIFICATIONS -> Icons.Outlined.ClearAll ActionId.CREATE_NOTIFICATION -> Icons.AutoMirrored.Outlined.Message + ActionId.TOAST -> Icons.AutoMirrored.Outlined.Message ActionId.ANSWER_PHONE_CALL -> Icons.Outlined.Call ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice @@ -1125,6 +1129,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.HttpRequest, is ActionData.ShellCommand, is ActionData.CreateNotification, + is ActionData.Toast, is ActionData.InteractUiElement, is ActionData.MoveCursor, is ActionData.ModifySetting, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt index 7d895e7fcf..3ad37eb413 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt @@ -58,6 +58,7 @@ fun HandleActionBottomSheets(delegate: CreateActionDelegate) { VolumeActionBottomSheet(delegate) ModifySettingActionBottomSheet(delegate) CreateNotificationActionBottomSheet(delegate) + ToastActionBottomSheet(delegate) } @Composable 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 e1d2124db5..76fa4b1068 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 @@ -65,6 +65,7 @@ class CreateActionDelegate( by mutableStateOf(null) var createNotificationActionBottomSheetState: CreateNotificationActionBottomSheetState? by mutableStateOf(null) + var toastActionBottomSheetState: ToastActionBottomSheetState? by mutableStateOf(null) init { coroutineScope.launch { @@ -357,6 +358,26 @@ class CreateActionDelegate( actionResult.update { action } } + fun onToastMessageChange(message: String) { + toastActionBottomSheetState = + toastActionBottomSheetState?.copy(message = message) + } + + fun onToastDurationChange(duration: ActionData.Toast.Duration) { + toastActionBottomSheetState = + toastActionBottomSheetState?.copy(duration = duration) + } + + fun onDoneToastClick() { + val state = toastActionBottomSheetState ?: return + val action = ActionData.Toast( + message = state.message, + duration = state.duration, + ) + toastActionBottomSheetState = null + actionResult.update { action } + } + fun onRequestNotificationPermissionClick() { useCase.requestPermission(Permission.POST_NOTIFICATIONS) } @@ -1121,6 +1142,15 @@ class CreateActionDelegate( return null } + ActionId.TOAST -> { + val oldAction = oldData as? ActionData.Toast + toastActionBottomSheetState = ToastActionBottomSheetState( + message = oldAction?.message ?: "", + duration = oldAction?.duration ?: ActionData.Toast.Duration.SHORT, + ) + return null + } + ActionId.ANSWER_PHONE_CALL -> return ActionData.AnswerCall ActionId.END_PHONE_CALL -> return ActionData.EndCall 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 e2dd348dde..dccaeee822 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 @@ -967,6 +967,14 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = success() } + is ActionData.Toast -> { + toastAdapter.show( + message = action.message, + isLong = action.duration == ActionData.Toast.Duration.LONG, + ) + result = success() + } + ActionData.AnswerCall -> { phoneAdapter.answerCall() result = success() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ToastActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ToastActionBottomSheet.kt new file mode 100644 index 0000000000..2a0d9a9c56 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ToastActionBottomSheet.kt @@ -0,0 +1,208 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow +import kotlinx.coroutines.launch + +data class ToastActionBottomSheetState( + val message: String = "", + val duration: ActionData.Toast.Duration = ActionData.Toast.Duration.SHORT, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ToastActionBottomSheet(delegate: CreateActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (delegate.toastActionBottomSheetState != null) { + ToastActionBottomSheet( + sheetState = sheetState, + state = delegate.toastActionBottomSheetState!!, + onDismissRequest = { delegate.toastActionBottomSheetState = null }, + onMessageChange = delegate::onToastMessageChange, + onDurationChange = delegate::onToastDurationChange, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneToastClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ToastActionBottomSheet( + sheetState: SheetState, + state: ToastActionBottomSheetState, + onDismissRequest: () -> Unit = {}, + onMessageChange: (String) -> Unit = {}, + onDurationChange: (ActionData.Toast.Duration) -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val messageEmptyError = stringResource(R.string.action_toast_message_error) + var messageError: String? by rememberSaveable { mutableStateOf(null) } + + LaunchedEffect(state.message) { + if (state.message.isNotBlank()) { + messageError = null + } + } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + textAlign = TextAlign.Center, + text = stringResource(R.string.action_toast), + style = MaterialTheme.typography.headlineMedium, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.message, + onValueChange = onMessageChange, + label = { Text(stringResource(R.string.action_toast_message_label)) }, + placeholder = { Text(stringResource(R.string.action_toast_message_hint)) }, + singleLine = true, + isError = messageError != null, + supportingText = { + if (messageError != null) { + Text(text = messageError!!, color = MaterialTheme.colorScheme.error) + } + }, + ) + + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + buttonStates = listOf( + ActionData.Toast.Duration.SHORT to + stringResource(R.string.action_toast_duration_short), + ActionData.Toast.Duration.LONG to + stringResource(R.string.action_toast_duration_long), + ), + selectedState = state.duration, + onStateSelected = onDurationChange, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + modifier = Modifier.weight(1f), + onClick = { + if (state.message.isBlank()) { + messageError = messageEmptyError + } else { + onDoneClick() + } + }, + ) { + Text(stringResource(R.string.pos_done)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun ToastActionBottomSheetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + ToastActionBottomSheet( + sheetState = sheetState, + state = ToastActionBottomSheetState( + message = "Hello world", + duration = ActionData.Toast.Duration.SHORT, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun ToastActionBottomSheetEmptyPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + ToastActionBottomSheet( + sheetState = sheetState, + state = ToastActionBottomSheetState(), + ) + } +} diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index d90aef6edc..49693f9bd2 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1136,6 +1136,14 @@ Test Testing… Notification shown successfully + Show toast + Show short toast: %1$s + Show long toast: %1$s + Toast message + Enter toast message + Message cannot be empty + Short + Long Device controls screen HTTP request HTTP Method diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index b233308d29..65f644da15 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -91,6 +91,7 @@ data class ActionEntity( const val EXTRA_SHELL_COMMAND_TIMEOUT = "extra_shell_command_timeout" const val EXTRA_NOTIFICATION_TITLE = "extra_notification_title" const val EXTRA_NOTIFICATION_TIMEOUT = "extra_notification_timeout" + const val EXTRA_TOAST_DURATION = "extra_toast_duration" // Accessibility node extras const val EXTRA_ACCESSIBILITY_PACKAGE_NAME = "extra_accessibility_package_name" @@ -199,6 +200,7 @@ data class ActionEntity( SHELL_COMMAND, MODIFY_SETTING, CREATE_NOTIFICATION, + TOAST, } constructor( diff --git a/system/src/main/java/io/github/sds100/keymapper/system/popup/AndroidToastAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/popup/AndroidToastAdapter.kt index a9086a8810..b12b853293 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/popup/AndroidToastAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/popup/AndroidToastAdapter.kt @@ -9,7 +9,8 @@ import javax.inject.Singleton @Singleton class AndroidToastAdapter @Inject constructor(@ApplicationContext private val ctx: Context) : ToastAdapter { - override fun show(message: String) { - Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() + override fun show(message: String, isLong: Boolean) { + val duration = if (isLong) Toast.LENGTH_LONG else Toast.LENGTH_SHORT + Toast.makeText(ctx, message, duration).show() } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/popup/ToastAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/popup/ToastAdapter.kt index 5db5b1fdcc..6d6d52282f 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/popup/ToastAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/popup/ToastAdapter.kt @@ -1,5 +1,5 @@ package io.github.sds100.keymapper.system.popup interface ToastAdapter { - fun show(message: String) + fun show(message: String, isLong: Boolean = false) } From 6fc880128d0806c98b94b5ee5cd7ceab6604ce2f Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 13:55:00 +0200 Subject: [PATCH 27/38] tell AI to update CHANGELOG.md when making a PR --- CLAUDE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 2518fdbe69..9bb77efc7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,13 @@ Types: `feat`, `fix`, `chore`, `refactor`, `style` Example: `#2025 feat: add button to report bug on home screen` +## Pull Request Requirement + +- Any user-facing behavior change, fix, feature, or notable internal change in a PR must include an + update to `CHANGELOG.md`. +- If there is truly no changelog impact (for example, docs-only or CI-only changes), explicitly state + that in the PR description. + ## Compose Guidelines - `Modifier` is always the first parameter after required params From f4addba4f3740746e21dd1d581cdf538237bda8c Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 14:02:32 +0200 Subject: [PATCH 28/38] #1956 reformat rust --- .../evdev_manager/jni/src/evdev_jni_observer.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/evdev/src/main/rust/evdev_manager/jni/src/evdev_jni_observer.rs b/evdev/src/main/rust/evdev_manager/jni/src/evdev_jni_observer.rs index acfcd735f6..a3d8e7c124 100644 --- a/evdev/src/main/rust/evdev_manager/jni/src/evdev_jni_observer.rs +++ b/evdev/src/main/rust/evdev_manager/jni/src/evdev_jni_observer.rs @@ -56,12 +56,7 @@ impl EvdevJniObserver { } /// Handle power button emergency kill. - fn handle_power_button( - &self, - ev_code: u32, - android_code: u32, - value: i32, - ) { + fn handle_power_button(&self, ev_code: u32, android_code: u32, value: i32) { let mut time_guard = self.power_button_down_time.lock().unwrap(); // KEY_POWER scan code = 116 if ev_code == 116 || android_code == android_codes::AKEYCODE_POWER { @@ -69,11 +64,8 @@ impl EvdevJniObserver { *time_guard = Some(Instant::now()); } else if value == 0 { // Button up - check if held for 10+ seconds - if should_emergency_kill( - *time_guard, - Instant::now(), - EMERGENCY_KILL_HOLD_DURATION, - ) { + if should_emergency_kill(*time_guard, Instant::now(), EMERGENCY_KILL_HOLD_DURATION) + { // Must send log to Key Mapper for diagnostic purposes. warn!("Emergency killing system bridge!"); // Call BaseSystemBridge.onEmergencyKillSystemBridge() via JNI From 39ee0b1e7560bc2f11c15399859fac349d3548b7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 14:06:44 +0200 Subject: [PATCH 29/38] #1029 fix tests --- .../sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt index 3f7941a56d..7654fbeed8 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt @@ -14,7 +14,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -100,6 +99,6 @@ class PerformActionsUseCaseTest { useCase.perform(action) // THEN - verify(mockToastAdapter, never()).show(anyOrNull()) + verify(mockToastAdapter, never()).show(any(), any()) } } From 0c6161a1b8ddbba1ecdb086edf9a9a0381954932 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 14:17:15 +0200 Subject: [PATCH 30/38] #2087 fix: small segmented button text is not readable in dark mode --- CHANGELOG.md | 1 + .../base/trigger/BaseTriggerScreen.kt | 38 +++++++++++++++++++ .../ui/compose/KeyMapperSegmentedButtonRow.kt | 4 ++ 3 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c23e06649..c6719db3aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - #2045 add action to input on-screen keyboard enter/send button. - #2106 disable the keyboard auto-switching setting when manually switching the keyboard in the Key Mapper homescreen menu. - #2081 add getevent debug screen. +- #2087 small segmented button text is not readable in dark mode. ## Fixed diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt index 01b19e820a..735ae05da8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt @@ -640,6 +640,25 @@ private fun VerticalPreview() { } } +@Preview(device = Devices.PIXEL) +@Composable +private fun VerticalPreviewDark() { + KeyMapperTheme(darkTheme = true) { + TriggerScreenVertical( + configState = previewState, + recordTriggerState = RecordTriggerState.Idle, + expertModeSwitchState = ExpertModeRecordSwitchState( + isVisible = true, + isChecked = false, + isEnabled = true, + ), + discoverScreenContent = { + TriggerDiscoverScreen() + }, + ) + } +} + @Preview(heightDp = 300, widthDp = 300) @Composable private fun VerticalPreviewTiny() { @@ -659,6 +678,25 @@ private fun VerticalPreviewTiny() { } } +@Preview(heightDp = 300, widthDp = 300) +@Composable +private fun VerticalPreviewTinyDark() { + KeyMapperTheme(darkTheme = true) { + TriggerScreenVertical( + configState = previewState, + recordTriggerState = RecordTriggerState.Idle, + expertModeSwitchState = ExpertModeRecordSwitchState( + isVisible = true, + isChecked = true, + isEnabled = true, + ), + discoverScreenContent = { + TriggerDiscoverScreen() + }, + ) + } +} + // This preview is slightly rectangular @Preview(heightDp = 500, widthDp = 530) @Composable diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt index f250d0aa14..8dc10f0f0b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedButton @@ -70,6 +71,8 @@ fun KeyMapperSegmentedButtonRow( ), colors = colors, ) { + val contentColor = LocalContentColor.current + BasicText( modifier = if (isUnselectedDisabled) Modifier.alpha(0.5f) else Modifier, text = label, @@ -79,6 +82,7 @@ fun KeyMapperSegmentedButtonRow( maxFontSize = LocalTextStyle.current.fontSize, minFontSize = 10.sp, ), + color = { contentColor }, ) } } else { From a8dd95bbd51995626ffb9ea8ab40ecc08eca9dac Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 14:20:14 +0200 Subject: [PATCH 31/38] #2077 feat: rename "This device" and "any device" to "This Android device" and "Any input device" to prevent confusion. --- CHANGELOG.md | 1 + base/src/main/res/values/strings.xml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6719db3aa..62f465745f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - #2106 disable the keyboard auto-switching setting when manually switching the keyboard in the Key Mapper homescreen menu. - #2081 add getevent debug screen. - #2087 small segmented button text is not readable in dark mode. +- #2077 rename "This device" and "any device" to "This Android device" and "Any input device" to prevent confusion. ## Fixed diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 9d6bb9862b..a86e7c6c24 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -16,8 +16,8 @@ On Off Follow system - This device - Any device + This Android device + Any input device Default Enable accessibility service Restart accessibility service From 19a1b64e2e46e35ab3e03a35070725bb577ac136 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 14:23:51 +0200 Subject: [PATCH 32/38] #2107 clarify accessibility restart dialog actions and copy --- .../SetupAccessibilityServiceDialog.kt | 25 +++---------------- base/src/main/res/values/strings.xml | 4 +-- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/SetupAccessibilityServiceDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/SetupAccessibilityServiceDialog.kt index 272d514f0f..a893facaeb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/SetupAccessibilityServiceDialog.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/SetupAccessibilityServiceDialog.kt @@ -1,10 +1,8 @@ package io.github.sds100.keymapper.base.onboarding import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -41,15 +39,10 @@ fun HandleAccessibilityServiceDialogs(delegate: SetupAccessibilityServiceDelegat } is AccessibilityServiceDialog.RestartService -> { - val dontKillMyAppUrl = stringResource(R.string.url_dont_kill_my_app) RestartAccessibilityServiceDialog( modifier = Modifier, onDismissRequest = delegate::onCancelClick, onRestartClick = delegate::onRestartServiceClick, - onDontKillMyAppClick = { - uriHandler.openUriSafe(context, dontKillMyAppUrl) - }, - onIgnoreClick = delegate::onIgnoreCrashedClick, ) } @@ -113,8 +106,6 @@ private fun RestartAccessibilityServiceDialog( modifier: Modifier = Modifier, onDismissRequest: () -> Unit, onRestartClick: () -> Unit, - onDontKillMyAppClick: () -> Unit, - onIgnoreClick: () -> Unit, ) { AlertDialog( modifier = modifier, @@ -129,19 +120,13 @@ private fun RestartAccessibilityServiceDialog( ) }, confirmButton = { - Row { - TextButton(onClick = onRestartClick) { - Text(stringResource(R.string.pos_restart)) - } - Spacer(modifier = Modifier.width(8.dp)) - TextButton(onClick = onDontKillMyAppClick) { - Text(stringResource(R.string.dialog_button_read_dont_kill_my_app_yes)) - } + TextButton(onClick = onRestartClick) { + Text(stringResource(R.string.pos_restart)) } }, dismissButton = { - TextButton(onClick = onIgnoreClick) { - Text(stringResource(R.string.dialog_button_read_dont_kill_my_app_no)) + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.neg_cancel)) } }, ) @@ -238,8 +223,6 @@ private fun RestartAccessibilityServiceDialogPreview() { RestartAccessibilityServiceDialog( onDismissRequest = {}, onRestartClick = {}, - onDontKillMyAppClick = {}, - onIgnoreClick = {}, ) } } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 9d6bb9862b..2781a4164d 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -460,8 +460,8 @@ Restart the accessibility service by turning it off and on. - Key Mapper was interrupted - Key Mapper tried to run in the background but was stopped by the system.\nThis can happen if you have battery or memory optimization turned on.\n\nTo fix this, you can try following an online guide. You should also restart the service when you\'re done. + Accessibility service needs restarting + Key Mapper\'s accessibility service was stopped by the system.\n\nTo fix this, restart it by turning the accessibility service off and then on again. Proceed Ignore From 88e80da48b03d8172b8543a6d6e079a4dea40cf8 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 14:26:01 +0200 Subject: [PATCH 33/38] #2107 update changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c23e06649..6cddfd30dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ## Fixed - #2091 show an error on the "open device assistant" action when no device assistant is installed. +- #2107 clarify the crashed accessibility service dialog text and keep only Cancel/Restart actions. ## [4.0.5](https://github.com/sds100/KeyMapper/releases/tag/v4.0.5) From 28c437bd1bc6478963b2378f70691e5ea8384913 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 14:26:45 +0200 Subject: [PATCH 34/38] update CLAUDE.md and cursor rules --- .../rules/keymapper-add-action-checklist.mdc | 24 +++++++++++++ .cursor/rules/keymapper-architecture.mdc | 15 ++++++++ .cursor/rules/keymapper-build-and-tooling.mdc | 16 +++++++++ .cursor/rules/keymapper-commit-messages.mdc | 34 +++++++++++++++++++ .../rules/keymapper-compose-guidelines.mdc | 16 +++++++++ CLAUDE.md | 5 +++ 6 files changed, 110 insertions(+) create mode 100644 .cursor/rules/keymapper-add-action-checklist.mdc create mode 100644 .cursor/rules/keymapper-architecture.mdc create mode 100644 .cursor/rules/keymapper-build-and-tooling.mdc create mode 100644 .cursor/rules/keymapper-commit-messages.mdc create mode 100644 .cursor/rules/keymapper-compose-guidelines.mdc diff --git a/.cursor/rules/keymapper-add-action-checklist.mdc b/.cursor/rules/keymapper-add-action-checklist.mdc new file mode 100644 index 0000000000..2399275cd9 --- /dev/null +++ b/.cursor/rules/keymapper-add-action-checklist.mdc @@ -0,0 +1,24 @@ +--- +description: Required checklist when adding a new Action +alwaysApply: true +--- + +# Adding A New Action + +Before implementing, confirm whether the action is editable. +- Branch names must follow `feature/123-branch-name` or `fix/123-branch-name`. + +Then follow this sequence: + +1. Add an ID in `ActionId`. +2. Add a new `ActionData` sealed class variant. +3. Map entity conversions in `ActionDataEntityMapper`. +4. Assign category in `ActionUtils`. +5. If editable, add it to `ActionUtils.isEditable`. +6. Add title string in `strings.xml`. +7. Add title and Compose icon in `ActionUtils` (ignore drawables). +8. Add title in `ActionUiHelper`. +9. Add execution handling in `PerformActionsUseCase`. +10. Handle creation in `CreateActionDelegate`. + +Do not delete existing action code; follow existing names and place additions near similar actions. diff --git a/.cursor/rules/keymapper-architecture.mdc b/.cursor/rules/keymapper-architecture.mdc new file mode 100644 index 0000000000..f3d64dd6ec --- /dev/null +++ b/.cursor/rules/keymapper-architecture.mdc @@ -0,0 +1,15 @@ +--- +description: Module boundaries and architectural conventions for KeyMapper +alwaysApply: true +--- + +# KeyMapper Architecture + +- Branch names must follow `feature/123-branch-name` or `fix/123-branch-name`. +- Treat the codebase as Clean Architecture + MVVM with unidirectional data flow. +- Keep dependency direction as `:app` -> `:base` -> `:common`/`:data`/`:system`/`:sysbridge`. +- Keep domain models in `:common` and persistence entities in `:data` with explicit mappers. +- Put business logic in use cases and inject them into ViewModels. +- Expose UI state via `Flow`/`StateFlow`; collect from UI and send events back to ViewModels. +- Use Hilt for DI and preserve app-specific binding overrides in app modules. +- Use coroutines for async work and `viewModelScope` for ViewModel-owned jobs. diff --git a/.cursor/rules/keymapper-build-and-tooling.mdc b/.cursor/rules/keymapper-build-and-tooling.mdc new file mode 100644 index 0000000000..da00f5e648 --- /dev/null +++ b/.cursor/rules/keymapper-build-and-tooling.mdc @@ -0,0 +1,16 @@ +--- +description: Build, test, and tooling commands for KeyMapper +alwaysApply: true +--- + +# KeyMapper Build And Tooling + +- Branch names must follow `feature/123-branch-name` or `fix/123-branch-name`. +- Use these common commands: + - `./gradlew assembleDebug` + - `./gradlew test` + - `./gradlew connectedAndroidTest` + - `./gradlew ktlintCheck` or `./gradlew ktlintFormat` +- Release builds require `KEYSTORE_PASSWORD` and `KEY_PASSWORD` environment variables. +- Target platform constraints: min SDK 26, target SDK 36, Java 11. +- Dependency versions are managed in `gradle/libs.versions.toml`. diff --git a/.cursor/rules/keymapper-commit-messages.mdc b/.cursor/rules/keymapper-commit-messages.mdc new file mode 100644 index 0000000000..720273f268 --- /dev/null +++ b/.cursor/rules/keymapper-commit-messages.mdc @@ -0,0 +1,34 @@ +--- +description: Commit message format convention for KeyMapper +alwaysApply: true +--- + +# KeyMapper Commit Messages + +Use this format for commits: + +`# : ` + +Allowed types: + +- `feat` +- `fix` +- `chore` +- `refactor` +- `style` + +Example: + +`#2025 feat: add button to report bug on home screen` + +# Branch Naming Convention + +- Use `feature/123-branch-name` for new features. +- Use `fix/123-branch-name` for bug fixes. + +# Pull Request Changelog Requirement + +- When preparing a PR, update `CHANGELOG.md` for any user-facing fix/feature/change or notable + internal change. +- If no changelog entry is needed (for example, docs-only or CI-only), explicitly note that in the PR + description. diff --git a/.cursor/rules/keymapper-compose-guidelines.mdc b/.cursor/rules/keymapper-compose-guidelines.mdc new file mode 100644 index 0000000000..9671c3b958 --- /dev/null +++ b/.cursor/rules/keymapper-compose-guidelines.mdc @@ -0,0 +1,16 @@ +--- +description: Compose coding conventions used in KeyMapper +globs: "**/*.kt" +alwaysApply: false +--- + +# KeyMapper Compose Guidelines + +Apply this when editing Compose code: + +- Branch names must follow `feature/123-branch-name` or `fix/123-branch-name`. +- Put `Modifier` as the first optional parameter after required parameters. +- For non-standard colors, use `LocalCustomColorsPalette` instead of `MaterialTheme.colorScheme`. +- Check `KeyMapperIcons` before introducing icons from other sources. +- Use `LocalUriHandler.openUriSafe` for URL launching and do not hoist URL launching logic. +- Prefer imports; avoid fully qualified names in Compose call sites. diff --git a/CLAUDE.md b/CLAUDE.md index 9bb77efc7f..27616596c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,11 @@ Types: `feat`, `fix`, `chore`, `refactor`, `style` Example: `#2025 feat: add button to report bug on home screen` +## Branch Naming Convention + +- Use `feature/123-branch-name` for new features. +- Use `fix/123-branch-name` for bug fixes. + ## Pull Request Requirement - Any user-facing behavior change, fix, feature, or notable internal change in a PR must include an From b04ea7c681f86c8e44e285cb83a18d88b743a0d1 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 14:42:20 +0200 Subject: [PATCH 35/38] #2067 feat: add select all text action --- CHANGELOG.md | 1 + .../sds100/keymapper/base/actions/ActionData.kt | 5 +++++ .../base/actions/ActionDataEntityMapper.kt | 3 +++ .../sds100/keymapper/base/actions/ActionId.kt | 1 + .../keymapper/base/actions/ActionUiHelper.kt | 1 + .../keymapper/base/actions/ActionUtils.kt | 7 +++++++ .../base/actions/CreateActionDelegate.kt | 2 ++ .../base/actions/PerformActionsUseCase.kt | 17 +++++++++++++++++ base/src/main/res/values/strings.xml | 1 + 9 files changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd453adc9f..10120dbdaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Added +- #2067 add action to select all text in the focused field. - #2045 add action to input on-screen keyboard enter/send button. - #2106 disable the keyboard auto-switching setting when manually switching the keyboard in the Key Mapper homescreen menu. - #1029 add action to show a toast message. diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 4c3bb89e50..3c608e7277 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -858,6 +858,11 @@ sealed class ActionData : Comparable { override val id = ActionId.SELECT_WORD_AT_CURSOR } + @Serializable + data object SelectAllText : ActionData() { + override val id = ActionId.SELECT_ALL_TEXT + } + @Serializable data object VoiceAssistant : ActionData() { override val id = ActionId.OPEN_VOICE_ASSISTANT diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index 75c7d48fa6..b8caede87b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -625,6 +625,8 @@ object ActionDataEntityMapper { ActionId.SELECT_WORD_AT_CURSOR -> ActionData.SelectWordAtCursor + ActionId.SELECT_ALL_TEXT -> ActionData.SelectAllText + ActionId.TOGGLE_AIRPLANE_MODE -> ActionData.AirplaneMode.Toggle ActionId.ENABLE_AIRPLANE_MODE -> ActionData.AirplaneMode.Enable @@ -1478,6 +1480,7 @@ object ActionDataEntityMapper { ActionId.TEXT_COPY to "text_copy", ActionId.TEXT_PASTE to "text_paste", ActionId.SELECT_WORD_AT_CURSOR to "select_word_at_cursor", + ActionId.SELECT_ALL_TEXT to "select_all_text", ActionId.SWITCH_KEYBOARD to "switch_keyboard", diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index d42bb721ed..63f4e62f07 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt @@ -121,6 +121,7 @@ enum class ActionId { TEXT_PASTE, MOVE_CURSOR, SELECT_WORD_AT_CURSOR, + SELECT_ALL_TEXT, TOGGLE_KEYBOARD, SHOW_KEYBOARD, HIDE_KEYBOARD, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index 9d1f277883..fe8323f74d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -593,6 +593,7 @@ class ActionUiHelper( ActionData.Screenshot -> getString(R.string.action_screenshot) ActionData.SecureLock -> getString(R.string.action_secure_lock_device) ActionData.SelectWordAtCursor -> getString(R.string.action_select_word_at_cursor) + ActionData.SelectAllText -> getString(R.string.action_select_all_text) ActionData.ShowKeyboard -> getString(R.string.action_show_keyboard) ActionData.ShowKeyboardPicker -> getString(R.string.action_show_keyboard_picker) ActionData.PerformImeAction -> getString(R.string.action_perform_ime_action) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt index 846e2e3aba..8707fa2c9a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt @@ -54,6 +54,7 @@ import androidx.compose.material.icons.outlined.PowerSettingsNew import androidx.compose.material.icons.outlined.Replay30 import androidx.compose.material.icons.outlined.ScreenLockRotation import androidx.compose.material.icons.outlined.ScreenRotation +import androidx.compose.material.icons.outlined.SelectAll import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SignalCellular4Bar import androidx.compose.material.icons.outlined.SignalCellularOff @@ -225,6 +226,7 @@ object ActionUtils { ActionId.HIDE_KEYBOARD -> ActionCategory.KEYBOARD ActionId.SHOW_KEYBOARD_PICKER -> ActionCategory.KEYBOARD ActionId.SELECT_WORD_AT_CURSOR -> ActionCategory.KEYBOARD + ActionId.SELECT_ALL_TEXT -> ActionCategory.KEYBOARD ActionId.PERFORM_IME_ACTION -> ActionCategory.KEYBOARD ActionId.SWITCH_KEYBOARD -> ActionCategory.KEYBOARD ActionId.LOCK_DEVICE -> ActionCategory.INTERFACE @@ -426,6 +428,8 @@ object ActionUtils { ActionId.SELECT_WORD_AT_CURSOR -> R.string.action_select_word_at_cursor + ActionId.SELECT_ALL_TEXT -> R.string.action_select_all_text + ActionId.PERFORM_IME_ACTION -> R.string.action_perform_ime_action ActionId.SWITCH_KEYBOARD -> R.string.action_switch_keyboard @@ -604,6 +608,7 @@ object ActionUtils { ActionId.TEXT_COPY -> R.drawable.ic_content_copy ActionId.TEXT_PASTE -> R.drawable.ic_content_paste ActionId.SELECT_WORD_AT_CURSOR -> null + ActionId.SELECT_ALL_TEXT -> null ActionId.PERFORM_IME_ACTION -> null ActionId.SWITCH_KEYBOARD -> R.drawable.ic_outline_keyboard_24 ActionId.TOGGLE_AIRPLANE_MODE -> R.drawable.ic_outline_airplanemode_active_24 @@ -682,6 +687,7 @@ object ActionUtils { ActionId.TEXT_COPY, ActionId.TEXT_PASTE, ActionId.SELECT_WORD_AT_CURSOR, + ActionId.SELECT_ALL_TEXT, -> Build.VERSION_CODES.JELLY_BEAN_MR2 ActionId.PERFORM_IME_ACTION -> Build.VERSION_CODES.TIRAMISU @@ -1038,6 +1044,7 @@ object ActionUtils { ActionId.TEXT_COPY -> Icons.Rounded.ContentCopy ActionId.TEXT_PASTE -> Icons.Rounded.ContentPaste ActionId.SELECT_WORD_AT_CURSOR -> KeyMapperIcons.MatchWord + ActionId.SELECT_ALL_TEXT -> Icons.Outlined.SelectAll ActionId.PERFORM_IME_ACTION -> Icons.Outlined.Keyboard ActionId.SWITCH_KEYBOARD -> Icons.Outlined.Keyboard ActionId.TOGGLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive 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 76fa4b1068..0f125641dc 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 @@ -1097,6 +1097,8 @@ class CreateActionDelegate( ActionId.SELECT_WORD_AT_CURSOR -> return ActionData.SelectWordAtCursor + ActionId.SELECT_ALL_TEXT -> return ActionData.SelectAllText + ActionId.TOGGLE_AIRPLANE_MODE -> return ActionData.AirplaneMode.Toggle ActionId.ENABLE_AIRPLANE_MODE -> return ActionData.AirplaneMode.Enable 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 dccaeee822..d193ee28fe 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 @@ -820,6 +820,23 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } } + is ActionData.SelectAllText -> { + result = service.performActionOnNode({ it.isFocused }) { node -> + val text = node.text?.toString().orEmpty() + if (text.isEmpty()) return@performActionOnNode null + + val extras = mapOf( + AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT to 0, + AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT to text.length, + ) + + AccessibilityNodeAction( + AccessibilityNodeInfo.ACTION_SET_SELECTION, + extras, + ) + } + } + is ActionData.AirplaneMode.Toggle -> { result = if (airplaneModeAdapter.isEnabled()) { airplaneModeAdapter.disable() diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index adc1656137..c8d339dc0e 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1098,6 +1098,7 @@ Copy Paste Select word at cursor + Select all text Open settings Show power menu From 5665145099ddab721bb60808a8364e8b4f143c79 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 14:51:26 +0200 Subject: [PATCH 36/38] bump version to 4.1.0 --- app/version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/version.properties b/app/version.properties index c8c849f38d..8712175313 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=4.0.5 -VERSION_CODE=247 +VERSION_NAME=4.1.0 +VERSION_CODE=248 From 1e19635cab615ebb77cf01fab309e4a32de12716 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 15:00:58 +0200 Subject: [PATCH 37/38] update changelog release date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4bbc78d1..3c8bc07841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [4.1.0](https://github.com/sds100/KeyMapper/releases/tag/v4.1.0) -#### TO BE RELEASED +#### 05 May 2026 ## Added From a4b36accb925f68bded8fa1ab2c7ddb1ce4e8d13 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 5 May 2026 17:37:56 +0200 Subject: [PATCH 38/38] move getevent debug screen to expert mode section and hide events output when recording --- .../keymapper/base/debug/GetEventScreen.kt | 25 +++++++---------- .../keymapper/base/debug/GetEventViewModel.kt | 27 ++++++++++--------- .../base/expertmode/ExpertModeScreen.kt | 25 +++++++++++++++++ .../base/expertmode/ExpertModeViewModel.kt | 6 +++++ .../keymapper/base/settings/SettingsScreen.kt | 9 ------- 5 files changed, 55 insertions(+), 37 deletions(-) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt index 3322defce5..253f2d3b18 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt @@ -74,8 +74,8 @@ fun GetEventScreen( onBackClick = onBackClick, onToggleRecordClick = viewModel::onToggleRecordClick, onRefreshDeviceInfoClick = viewModel::onRefreshDeviceInfoClick, - onCopyToClipboardClick = { tab -> viewModel.onCopyToClipboardClick(tab.toOutputTab()) }, - onSaveToFileClick = { tab -> viewModel.onSaveToFileClick(tab.toOutputTab()) }, + onCopyToClipboardClick = viewModel::onCopyToClipboardClick, + onSaveToFileClick = viewModel::onSaveToFileClick, onSetupExpertModeClick = viewModel::onSetupExpertModeClick, ) } @@ -91,11 +91,6 @@ private enum class RefreshButtonState { STOP, } -private fun GetEventTab.toOutputTab(): GetEventViewModel.OutputTab = when (this) { - GetEventTab.INFO -> GetEventViewModel.OutputTab.INFO - GetEventTab.EVENTS -> GetEventViewModel.OutputTab.EVENTS -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun GetEventScreen( @@ -104,18 +99,16 @@ private fun GetEventScreen( onBackClick: () -> Unit = {}, onToggleRecordClick: () -> Unit = {}, onRefreshDeviceInfoClick: () -> Unit = {}, - onCopyToClipboardClick: (GetEventTab) -> Unit = {}, - onSaveToFileClick: (GetEventTab) -> Unit = {}, + onCopyToClipboardClick: () -> Unit = {}, + onSaveToFileClick: () -> Unit = {}, onSetupExpertModeClick: () -> Unit = {}, ) { val pagerState = rememberPagerState(pageCount = { 2 }) val scope = rememberCoroutineScope() val isExpertModeEnabled = state.expertModeStatus == ExpertModeStatus.ENABLED val selectedTab = if (pagerState.currentPage == 0) GetEventTab.INFO else GetEventTab.EVENTS - val hasOutputForSelectedTab = when (selectedTab) { - GetEventTab.INFO -> state.deviceInfoOutput.isNotEmpty() - GetEventTab.EVENTS -> state.recordingOutput.isNotEmpty() - } + val hasOutputForSelectedTab = + state.deviceInfoOutput.isNotEmpty() || state.recordingOutput.isNotEmpty() val refreshButtonState = when { selectedTab == GetEventTab.INFO -> RefreshButtonState.REFRESH_INFO state.isRecording -> RefreshButtonState.STOP @@ -209,7 +202,7 @@ private fun GetEventScreen( } Spacer(modifier = Modifier.weight(1f)) IconButton( - onClick = { onCopyToClipboardClick(selectedTab) }, + onClick = onCopyToClipboardClick, enabled = hasOutputForSelectedTab, ) { Icon( @@ -218,7 +211,7 @@ private fun GetEventScreen( ) } IconButton( - onClick = { onSaveToFileClick(selectedTab) }, + onClick = onSaveToFileClick, enabled = hasOutputForSelectedTab, ) { Icon( @@ -366,7 +359,7 @@ private fun EventsContent(modifier: Modifier = Modifier, state: GetEventViewMode ) } - if (state.recordingOutput.isNotEmpty()) { + if (!state.isRecording && state.recordingOutput.isNotEmpty()) { SelectionContainer( modifier = Modifier .weight(1f) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt index aa4846544b..b3b6719afa 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt @@ -24,11 +24,6 @@ class GetEventViewModel @Inject constructor( ) : ViewModel(), NavigationProvider by navigationProvider { - enum class OutputTab { - INFO, - EVENTS, - } - data class State( val deviceInfoOutput: String = "", val recordingOutput: String = "", @@ -102,19 +97,27 @@ class GetEventViewModel @Inject constructor( } } - fun onCopyToClipboardClick(tab: OutputTab) { - outputUseCase.copyOutput(getOutputForTab(tab)) + fun onCopyToClipboardClick() { + outputUseCase.copyOutput(getCombinedOutput()) } - fun onSaveToFileClick(tab: OutputTab) { + fun onSaveToFileClick() { viewModelScope.launch { - outputUseCase.shareOutput(getOutputForTab(tab)) + outputUseCase.shareOutput(getCombinedOutput()) } } - private fun getOutputForTab(tab: OutputTab): String = when (tab) { - OutputTab.INFO -> state.deviceInfoOutput - OutputTab.EVENTS -> state.recordingOutput + private fun getCombinedOutput(): String = buildString { + if (state.deviceInfoOutput.isNotBlank()) { + append(state.deviceInfoOutput) + } + + if (state.recordingOutput.isNotBlank()) { + if (isNotEmpty()) { + append("\n\n") + } + append(state.recordingOutput) + } } fun onBackClick() { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt index 42fe615f04..eb1e4c1e8b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.HelpOutline +import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Close @@ -74,6 +75,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.base.utils.ui.compose.OptionPageButton import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.SwitchPreferenceCompose import io.github.sds100.keymapper.base.utils.ui.compose.icons.FakeShizuku @@ -114,6 +116,7 @@ fun ExpertModeScreen(modifier: Modifier = Modifier, viewModel: ExpertModeViewMod onAutoStartAtBootToggled = { viewModel.onAutoStartBootToggled() }, onLaunchDeveloperOptionsClick = viewModel::onLaunchDeveloperOptionsClick, onGetShellStartCommandClick = viewModel::onGetShellStartCommandClick, + onGetEventClick = viewModel::onGetEventClick, ) } } @@ -196,6 +199,7 @@ private fun Content( onAutoStartAtBootToggled: () -> Unit = {}, onLaunchDeveloperOptionsClick: () -> Unit = {}, onGetShellStartCommandClick: () -> Unit = {}, + onGetEventClick: () -> Unit = {}, ) { Column(modifier = modifier.verticalScroll(rememberScrollState())) { AnimatedVisibility( @@ -255,6 +259,27 @@ private fun Content( textAlign = TextAlign.Center, ) } + + Spacer(modifier = Modifier.height(16.dp)) + + OptionsHeaderRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + icon = Icons.Outlined.BugReport, + text = stringResource(R.string.settings_section_debugging_title), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OptionPageButton( + modifier = Modifier.padding(horizontal = 8.dp), + title = stringResource(R.string.title_pref_get_event_debug), + text = stringResource(R.string.summary_pref_get_event_debug), + icon = Icons.Outlined.BugReport, + onClick = onGetEventClick, + ) + Spacer(modifier = Modifier.height(8.dp)) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeViewModel.kt index 720296c005..7130911276 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeViewModel.kt @@ -188,6 +188,12 @@ class ExpertModeViewModel @Inject constructor( } } + fun onGetEventClick() { + viewModelScope.launch { + navigate("get_event_debug", NavDestination.GetEvent) + } + } + private fun stoppedStateFlow(): Flow = combine( useCase.isRootGranted, useCase.shizukuSetupState, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt index b4435f13e2..5d79cd6838 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt @@ -152,7 +152,6 @@ fun SettingsScreen(modifier: Modifier = Modifier, viewModel: SettingsViewModel) onForceVibrateToggled = viewModel::onForceVibrateToggled, onLoggingToggled = viewModel::onLoggingToggled, onViewLogClick = viewModel::onViewLogClick, - onGetEventClick = viewModel::onGetEventClick, onHideHomeScreenAlertsToggled = viewModel::onHideHomeScreenAlertsToggled, onShowDeviceDescriptorsToggled = viewModel::onShowDeviceDescriptorsToggled, onAutomaticBackupClick = { @@ -236,7 +235,6 @@ private fun Content( onForceVibrateToggled: (Boolean) -> Unit = { }, onLoggingToggled: (Boolean) -> Unit = { }, onViewLogClick: () -> Unit = { }, - onGetEventClick: () -> Unit = { }, onShareLogcatClick: () -> Unit = { }, onHideHomeScreenAlertsToggled: (Boolean) -> Unit = { }, onShowDeviceDescriptorsToggled: (Boolean) -> Unit = { }, @@ -400,13 +398,6 @@ private fun Content( onClick = onShareLogcatClick, ) - OptionPageButton( - title = stringResource(R.string.title_pref_get_event_debug), - text = stringResource(R.string.summary_pref_get_event_debug), - icon = Icons.Rounded.Keyboard, - onClick = onGetEventClick, - ) - Spacer(modifier = Modifier.height(8.dp)) } }