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/CHANGELOG.md b/CHANGELOG.md index e66c03232b..c6ec7b0e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## [4.1.0](https://github.com/sds100/KeyMapper/releases/tag/v4.1.0) + +#### 05 May 2026 + +## 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. +- #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 + +- #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) #### 26 February 2026 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..27616596c8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,109 @@ +# 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` + +## 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 + 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 +- 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 +- Write `@Preview` composables for every screen + +## 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. 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 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..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,6 +25,7 @@ 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.logging.LogScreen @@ -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/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/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index eb24dd579a..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 @@ -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 @@ -853,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 @@ -919,6 +929,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 a4485f9813..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 @@ -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,96 +502,161 @@ 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.SELECT_ALL_TEXT -> ActionData.SelectAllText + 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() @@ -586,9 +676,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() @@ -683,18 +795,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"), ) @@ -707,9 +824,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"), ) @@ -752,6 +871,7 @@ object ActionDataEntityMapper { } ActionId.FORCE_STOP_APP -> ActionData.ForceStopApp + ActionId.CLEAR_RECENT_APP -> ActionData.ClearRecentApp ActionId.MODIFY_SETTING -> { @@ -801,6 +921,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 } @@ -850,35 +971,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]!! } @@ -915,6 +1061,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)) } @@ -986,6 +1133,7 @@ object ActionDataEntityMapper { } is ActionData.Flashlight.Disable -> listOf(lensExtra) + is ActionData.Flashlight.ChangeStrength -> buildList { add(lensExtra) add( @@ -1053,6 +1201,7 @@ object ActionDataEntityMapper { }.toList() is ActionData.Text -> emptyList() + is ActionData.Url -> emptyList() is ActionData.Sound.SoundFile -> listOf( @@ -1171,6 +1320,10 @@ object ActionDataEntityMapper { } } + is ActionData.Toast -> listOf( + EntityExtra(ActionEntity.EXTRA_TOAST_DURATION, data.duration.name), + ) + else -> emptyList() } @@ -1322,10 +1475,12 @@ 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", 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/ActionErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt index a79591be43..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 @@ -8,6 +9,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 @@ -53,6 +55,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 { @@ -161,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) @@ -190,6 +202,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/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..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,10 +121,12 @@ enum class ActionId { TEXT_PASTE, MOVE_CURSOR, SELECT_WORD_AT_CURSOR, + SELECT_ALL_TEXT, TOGGLE_KEYBOARD, SHOW_KEYBOARD, HIDE_KEYBOARD, SHOW_KEYBOARD_PICKER, + PERFORM_IME_ACTION, SWITCH_KEYBOARD, @@ -147,6 +149,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 98b53064b4..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,8 +593,10 @@ 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) ActionData.ShowPowerMenu -> getString(R.string.action_show_power_menu) ActionData.StatusBar.Collapse -> getString(R.string.action_collapse_status_bar) @@ -673,6 +675,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 a5bfaa6454..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,8 @@ 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 ActionId.POWER_ON_OFF_DEVICE -> ActionCategory.INTERFACE @@ -238,6 +241,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 @@ -424,6 +428,10 @@ 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 ActionId.TOGGLE_AIRPLANE_MODE -> R.string.action_toggle_airplane_mode @@ -482,6 +490,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 @@ -599,6 +608,8 @@ 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 ActionId.ENABLE_AIRPLANE_MODE -> R.drawable.ic_outline_airplanemode_active_24 @@ -628,6 +639,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 @@ -675,8 +687,11 @@ 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 + ActionId.SHOW_POWER_MENU -> Build.VERSION_CODES.LOLLIPOP ActionId.DEVICE_CONTROLS -> Build.VERSION_CODES.S @@ -1029,6 +1044,8 @@ 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 ActionId.ENABLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive @@ -1060,6 +1077,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 @@ -1118,6 +1136,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 3525167f70..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 @@ -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) } @@ -1066,6 +1087,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 @@ -1074,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 @@ -1119,6 +1144,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 dc4d735993..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 @@ -764,6 +764,15 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = inputMethodAdapter.showImePicker(fromForeground = false) } + is ActionData.PerformImeAction -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + service.performImeAction() + result = Success(Unit) + } else { + result = SdkVersionTooLow(minSdk = Build.VERSION_CODES.TIRAMISU) + } + } + is ActionData.CutText -> { result = service.performActionOnNode({ it.isFocused }) { AccessibilityNodeAction(AccessibilityNodeInfo.ACTION_CUT) @@ -811,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() @@ -958,6 +984,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/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 new file mode 100644 index 0000000000..253f2d3b18 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventScreen.kt @@ -0,0 +1,506 @@ +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 +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.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 +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.Refresh +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.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.draw.alpha +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.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 +import io.github.sds100.keymapper.base.utils.ExpertModeStatus +import kotlinx.coroutines.launch + +@Composable +fun GetEventScreen( + modifier: Modifier = Modifier, + viewModel: GetEventViewModel = hiltViewModel(), + onBackClick: () -> Unit, +) { + GetEventScreen( + modifier = modifier, + state = viewModel.state, + onBackClick = onBackClick, + onToggleRecordClick = viewModel::onToggleRecordClick, + onRefreshDeviceInfoClick = viewModel::onRefreshDeviceInfoClick, + onCopyToClipboardClick = viewModel::onCopyToClipboardClick, + onSaveToFileClick = viewModel::onSaveToFileClick, + onSetupExpertModeClick = viewModel::onSetupExpertModeClick, + ) +} + +private enum class GetEventTab { + INFO, + EVENTS, +} + +private enum class RefreshButtonState { + REFRESH_INFO, + START_RECORDING, + STOP, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GetEventScreen( + modifier: Modifier = Modifier, + state: GetEventViewModel.State, + onBackClick: () -> Unit = {}, + onToggleRecordClick: () -> Unit = {}, + onRefreshDeviceInfoClick: () -> 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 = + state.deviceInfoOutput.isNotEmpty() || state.recordingOutput.isNotEmpty() + val refreshButtonState = when { + selectedTab == GetEventTab.INFO -> RefreshButtonState.REFRESH_INFO + state.isRecording -> RefreshButtonState.STOP + else -> RefreshButtonState.START_RECORDING + } + + Scaffold( + modifier = modifier.displayCutoutPadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_debug_getevent)) }, + ) + }, + bottomBar = { + BottomAppBar( + floatingActionButton = { + 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)) + }, + 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, + ), + ) + } + } + } + } + }, + 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 = hasOutputForSelectedTab, + ) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = stringResource(R.string.debug_getevent_copy), + ) + } + IconButton( + onClick = onSaveToFileClick, + enabled = hasOutputForSelectedTab, + ) { + Icon( + imageVector = Icons.Outlined.Share, + contentDescription = stringResource(R.string.debug_getevent_save), + ) + } + }, + ) + }, + ) { 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, + ), + ) { + Column(modifier = Modifier.fillMaxSize()) { + if (state.expertModeStatus != ExpertModeStatus.ENABLED) { + ExpertModeSetupCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + onSetupExpertModeClick = onSetupExpertModeClick, + ) + } + 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 ExpertModeSetupCard( + modifier: Modifier = Modifier, + onSetupExpertModeClick: () -> Unit, +) { + 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.isLoadingDeviceInfo) { + Spacer(Modifier.height(16.dp)) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) + } + + if (state.deviceInfoOutput.isNotEmpty()) { + SelectionContainer( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()) + .verticalScroll(rememberScrollState()), + ) { + Text( + modifier = Modifier.padding(16.dp), + text = state.deviceInfoOutput, + softWrap = false, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + ), + lineHeight = 13.sp, + ) + } + } + } +} + +@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) { + 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.isRecording && state.recordingOutput.isNotEmpty()) { + SelectionContainer( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()) + .verticalScroll(verticalScrollState), + ) { + Text( + modifier = Modifier.padding(16.dp), + text = state.recordingOutput, + softWrap = false, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + ), + lineHeight = 13.sp, + ) + } + } + } +} + +@Preview +@Composable +private fun PreviewInfoTab() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + deviceInfoOutput = """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, + ), + ) + } +} + +@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() { + KeyMapperTheme { + GetEventScreen( + state = GetEventViewModel.State( + recordingOutput = """/dev/input/event1: EV_KEY KEY_VOLUMEDOWN DOWN +/dev/input/event1: EV_SYN SYN_REPORT 00""", + isRecording = true, + 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() { + 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..b3b6719afa --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/debug/GetEventViewModel.kt @@ -0,0 +1,137 @@ +package io.github.sds100.keymapper.base.debug + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.utils.ExpertModeStatus +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.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import javax.inject.Inject +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +@HiltViewModel +class GetEventViewModel @Inject constructor( + private val outputUseCase: GetEventOutputUseCase, + private val navigationProvider: NavigationProvider, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, +) : ViewModel(), + NavigationProvider by navigationProvider { + + data class State( + val deviceInfoOutput: String = "", + val recordingOutput: 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 { + 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) { + 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.deviceInfoOutput.isEmpty() + ) { + onRefreshDeviceInfoClick() + } + } + } + } + + fun onRefreshDeviceInfoClick() { + viewModelScope.launch { + state = state.copy(isLoadingDeviceInfo = true) + outputUseCase.refreshDeviceInfo() + state = state.copy(isLoadingDeviceInfo = false) + } + } + + fun onToggleRecordClick() { + if (state.isRecording) { + stopRecording() + } else { + startRecording() + } + } + + private fun startRecording() { + viewModelScope.launch { + state = state.copy(isRecording = true) + outputUseCase.recordEvents() + state = state.copy(isRecording = false) + } + } + + private fun stopRecording() { + viewModelScope.launch { + outputUseCase.stopRecording() + } + } + + fun onCopyToClipboardClick() { + outputUseCase.copyOutput(getCombinedOutput()) + } + + fun onSaveToFileClick() { + viewModelScope.launch { + outputUseCase.shareOutput(getCombinedOutput()) + } + } + + 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() { + 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/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/home/KeyMapListViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt index 414dfc2ac2..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 @@ -907,7 +907,27 @@ class KeyMapListViewModel( } fun showInputMethodPicker() { - showInputMethodPickerUseCase.show(fromForeground = true) + coroutineScope.launch { + val autoSwitchEnabled = showInputMethodPickerUseCase.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 + + showInputMethodPickerUseCase.disableAutoSwitch() + } + + showInputMethodPickerUseCase.show(fromForeground = true) + } } private suspend fun onAutomaticBackupResult(result: KMResult<*>) { 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/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/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() } 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) } 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/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, ) } 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/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 { diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 9bb7dc5d2f..b607624d26 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 @@ -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 @@ -635,6 +635,21 @@ 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 + Refresh + Loading device info\u2026 + Copy to clipboard + Save to file + Info + Events + Output appears after recording stops. + Report issue Delete sound files @@ -1074,6 +1089,8 @@ Show keyboard picker + Keyboard enter/send + Switch keyboard Switch to %s @@ -1081,6 +1098,7 @@ Copy Paste Select word at cursor + Select all text Open settings Show power menu @@ -1134,6 +1152,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 @@ -1341,7 +1367,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" @@ -1901,4 +1927,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. + 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" /> + + 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()) + } } 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()) } } 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 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/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 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..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 @@ -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,27 +51,21 @@ impl EvdevJniObserver { jvm, system_bridge, key_layout_map_manager, - power_button_down_time: Mutex::new(0), + power_button_down_time: Mutex::new(None), } } /// Handle power button emergency kill. - fn handle_power_button( - &self, - ev_code: u32, - android_code: u32, - value: i32, - time_sec: libc::time_t, - ) { + 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 { 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 +79,7 @@ impl EvdevJniObserver { } process::exit(0); } - *time_guard = 0 + *time_guard = None } } } @@ -173,7 +180,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 +287,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 + )); + } +} 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 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) }