diff --git a/.cursor/rules/create-action.mdc b/.cursor/rules/create-action.mdc deleted file mode 100644 index e8b352f9db..0000000000 --- a/.cursor/rules/create-action.mdc +++ /dev/null @@ -1,27 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- -[ActionEntity.kt](mdc:app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt) [ActionDataEntityMapper.kt](mdc:app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt) [ActionData.kt](mdc:app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt) [ActionId.kt](mdc:app/src/main/java/io/github/sds100/keymapper/actions/ActionId.kt) [PerformActionsUseCase.kt](mdc:app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt) [ActionUtils.kt](mdc:app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt) [strings.xml](mdc:app/src/main/res/values/strings.xml) [ActionUiHelper.kt](mdc:app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt) [CreateActionDelegate.kt](mdc:app/src/main/java/io/github/sds100/keymapper/actions/CreateActionDelegate.kt) - - -When you create an action you must follow these steps: - -0. Ask me whether the action is editable. -1. Create a new id in ActionId -2. Create a new ActionData -3. Map the data to and from an entity in ActionDataEntityMapper -4. Give the action a category in ActionUtils -5. If the action is editable then add it to the isEditable function in ActionUtils -6. Create a title for the action in strings.xml -7. Give the action a title and icon in ActionUtils. Only create a compose Icon. Ignore the drawable one. -8. Give the action a title in ActionUiHelper -9. Stub out the action in PerformActionsUseCase -10. Handle creating the action in CreateActionDelegate - -Important things to remember: - -- Do not delete any existing code for other actions. -- Follow the naming of existing code and strings and do not change them. -- Add code near existing code for similar actions. \ No newline at end of file diff --git a/.cursor/rules/jetpack-compose.mdc b/.cursor/rules/jetpack-compose.mdc deleted file mode 100644 index ef3b89eb3a..0000000000 --- a/.cursor/rules/jetpack-compose.mdc +++ /dev/null @@ -1,71 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- - -# Android Native (Jetpack Compose) - -# Android Jetpack Compose .cursorrules - -## Flexibility Notice -**Note:** This is a recommended project structure, but be flexible and adapt to existing project structures. Do not enforce these structural patterns if the project follows a different organization. Focus on maintaining consistency with the existing project architecture while applying Jetpack Compose best practices. - -## Project Architecture and Best Practices -```kotlin -val androidJetpackComposeBestPractices = listOf( - "Adapt to existing project architecture while maintaining clean code principles", - "Follow Material Design 3 guidelines and components", - "Implement clean architecture with domain, data, and presentation layers", - "Use Kotlin coroutines and Flow for asynchronous operations", - "Implement dependency injection using Hilt", - "Follow unidirectional data flow with ViewModel and UI State", - "Use Compose navigation for screen management", - "Implement proper state hoisting and composition" -) -``` - -## Folder Structure -**Note:** This is a reference structure. Adapt to the project's existing organization. -```plaintext -app/ - src/ - main/ - java/io/github/sds100/keymapper - actions/ - constraints/ - groups/ - home/ - mappings/ - keymaps/ - utils/ - res/ - values/ - drawable/ - mipmap/ - test/ - androidTest/ -``` - -## Compose UI Guidelines -1. Use `remember` and `derivedStateOf` appropriately -2. Implement proper recomposition optimization -3. Use proper Compose modifiers ordering -4. Follow composable function naming conventions -5. Implement proper preview annotations -6. Use proper state management with `MutableState` -7. Implement proper error handling and loading states -8. Use proper theming with `MaterialTheme` -9. Follow accessibility guidelines -10. Implement proper animation patterns - -## Performance Guidelines -1. Minimize recomposition using proper keys -2. Use proper lazy loading with `LazyColumn` and `LazyRow` -3. Implement efficient image loading -4. Use proper state management to prevent unnecessary updates -5. Follow proper lifecycle awareness -6. Implement proper memory management -7. Use proper background processing - - diff --git a/.editorconfig b/.editorconfig index a55befd7a9..4bf7890d85 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,7 +2,10 @@ ktlint_standard_function-expression-body = disabled ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_ignore_back_ticked_identifier = true -ktlint_code_style = intellij_idea # Use IntelliJ style because it has trailing commas +ktlint_code_style = android_studio +ij_kotlin_indent_before_arrow_on_new_line = true +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true [base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/*.{kt,kts}] ktlint_standard_property-naming = disabled diff --git a/.github/workflows/crowdin-actions.yml b/.github/workflows/crowdin-actions.yml deleted file mode 100644 index e0f46f3c7f..0000000000 --- a/.github/workflows/crowdin-actions.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Crowdin Action - -on: - push: -# paths: [ 'app/src/main/res/values**', 'fastlane/metadata' ] - branches: - - 'develop' - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: true - -jobs: - synchronize-with-crowdin: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: crowdin action - uses: crowdin/github-action@v2 - if: github.event.repository.fork == false - with: - upload_sources: true - upload_translations: false - download_translations: true - localization_branch_name: l10n/develop - create_pull_request: true - pull_request_title: 'New Crowdin Translations' - pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)' - pull_request_base_branch_name: 'develop' - env: - # A classic GitHub Personal Access Token with the 'repo' scope selected (the user should have write access to the repository). - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # A numeric ID, found at https://crowdin.com/project//tools/api - CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} - - # Visit https://crowdin.com/settings#api-key to create this token - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index b86ad1b439..18188155de 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -135,4 +135,33 @@ jobs: if: github.event.repository.fork == false && failure() with: title: "Build apk" - webhook: ${{ secrets.DISCORD_BUILD_STATUS_WEBHOOK }} \ No newline at end of file + webhook: ${{ secrets.DISCORD_BUILD_STATUS_WEBHOOK }} + + synchronize-with-crowdin: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: crowdin action + uses: crowdin/github-action@v2 + if: github.event.repository.fork == false + with: + upload_sources: true + upload_translations: false + download_translations: true + localization_branch_name: l10n/develop + create_pull_request: true + pull_request_title: 'New Crowdin Translations' + pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)' + pull_request_base_branch_name: 'develop' + env: + # A classic GitHub Personal Access Token with the 'repo' scope selected (the user should have write access to the repository). + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # A numeric ID, found at https://crowdin.com/project//tools/api + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + + # Visit https://crowdin.com/settings#api-key to create this token + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1494219777..0376953500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,53 @@ +## [4.0.0 Beta 1](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.01) + +#### TO BE RELEASED + +## Added + +- #761 Detect keys with scancodes. Key Mapper will do this automatically if the key code is unknown + or you record different physical keys from the same device with the same key code. +- Redesign the Settings screen. +- Shortcuts on the trigger screen that guide you how to set up different types of buttons. +- #1788 dismiss lockscreen when launching app action from lockscreen +- Show tips for parallel and sequence triggers, and constraints in the trigger screen +- #397 enable/disable all key maps in a group +- #1773 Option to show floating buttons on top of keyboard or notification panel. +- #1335 Intent API to enable/disable/toggle a key map. +- #114 action to force stop app, and an action to clear an app from recents +- #727 Actions to send SMS messages: "Send SMS" and "Compose SMS" +- #1819 Explain how to enable the accessibility service restricted setting +- #661 Action to execute shell commands. +- #991 Consolidated volume and stream actions. +- #1066 Action to mute/unmute microphone. +- #985 Constraints for foldable hinge being open/closed. + +## Removed + +- The key event relay service is now also used on all Android versions below Android 14. The + broadcast receiver method is no longer used. +- Minimum supported Android version is now 8.0. Less than 1% of users are on older versions than + this and dropping support simplifies the codebase and maintenance. +- Dropped support for showing a keyboard picker notification and automatically showing it when a + device connects. This is only supported on Android 8.1 and is extra work to maintain it. +- Dropped support for rerouting key events on Android 11. This was a workaround for a specific bug + in Android 11 which fewer than 10% of users are using and less are probably using that feature. + +## Fixed + +- Restoring subgroups works and does not freeze Key Mapper. +- Do not show duplicate constraint shortcuts. +- Make WiFi connected constraints more reliable +- #1818 auto switching of the Key Mapper keyboard when typing is more reliable and quicker on + Android 13+ +- #1818 auto switching of the Key Mapper keyboard now requires Android 11+. On older versions it was + only possible with WRITE_SECURE_SETTINGS but very few users are on these old Android versions so + it is not worth the extra maintenance effort. +- #1818 the Key Mapper GUI Keyboard is no longer mentioned in the app. It still works but PRO mode + and the auto switching feature are the preferred way to work around the limitations of the Key + Mapper keyboard. +- Allow selecting notification and alarm sound and not just ringtones for Sound action. +- #1064 wait for switch keyboard action to complete before doing next action. + ## [3.2.1](https://github.com/sds100/KeyMapper/releases/tag/v3.2.1) #### 03 Sept 2025 @@ -1332,26 +1382,26 @@ This summarises the changes since 2.0.2. - Dark mode! 🕶 - A keymap can have multiple actions. - Triggers - - 2 modes. The keys can all be pressed at the same time or one after another in a sequence. - - Keys can be limited to a specific external device, any device or the device the app is - installed on. - - Double press support. + - 2 modes. The keys can all be pressed at the same time or one after another in a sequence. + - Keys can be limited to a specific external device, any device or the device the app is + installed on. + - Double press support. - Constraints. Keymaps can be restricted to only work in certain situations. Constraints can be mixed in OR mode or AND mode. - - App in foreground - - App not in foreground - - Bluetooth device connected - - Bluetooth device not connected - - Screen on/off (ROOT only). + - App in foreground + - App not in foreground + - Bluetooth device connected + - Bluetooth device not connected + - Screen on/off (ROOT only). - Actions - - Toggle/enable/disable a Do Not Disturb mode (Android 6.0+). - - Toggle/enable/disable airplane mode (ROOT only). - - Switch between vibrate and ring. - - Launch the device assistant rather than the voice assistant. - - Take screenshots on rooted devices older than Pie. - - Can now have unique repeat options and any action is allowed to be repeated now. - - Show the keycode number when picking a Keycode action. + - Toggle/enable/disable a Do Not Disturb mode (Android 6.0+). + - Toggle/enable/disable airplane mode (ROOT only). + - Switch between vibrate and ring. + - Launch the device assistant rather than the voice assistant. + - Take screenshots on rooted devices older than Pie. + - Can now have unique repeat options and any action is allowed to be repeated now. + - Show the keycode number when picking a Keycode action. - Renamed "Repeat Delay" to "Repeat Rate". - Renamed "Hold Down Delay" to "Repeat Delay" @@ -1482,16 +1532,16 @@ Significantly improved the input latency. - A keymap can have multiple actions. - Triggers - - 2 modes. The keys can all be pressed at the same time or one after another in a sequence. - - Keys can be limited to a specific external device, any device or the device the app is - installed on. - - Double press support. + - 2 modes. The keys can all be pressed at the same time or one after another in a sequence. + - Keys can be limited to a specific external device, any device or the device the app is + installed on. + - Double press support. - Constraints. Keymaps can be restricted to only work in certain situations. Constraints can be mixed in OR mode or AND mode. - - App in foreground - - App not in foreground - - Bluetooth device connected - - Bluetooth device not connected + - App in foreground + - App not in foreground + - Bluetooth device connected + - Bluetooth device not connected - The option to show the "performing action" toast has been moved to a toggle in each keymap. - The long press delay, double press timeout, sequence trigger timeout, action repeat delay, hold-down delay until actions are repeated and vibrate delay can be changed per keymap. diff --git a/CREDITS.md b/CREDITS.md index 8d7b22f638..5cf41fe458 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -2,19 +2,33 @@ Many thanks to... - - @[dslul](https://github.com/dslul) for their [OpenBoard](https://github.com/dslul/openboard) fork of the AOSP keyboard! This made adding a GUI keyboard for Key Mapper _much_ easier. 🎉 - - @[salomonbrys] for his [Kotson] library so I can easily use Gson in Kotlin. - - The [Material Design Icons] community for various icons that are not provided by Google. - - [App Mockup] for their screenshot utility. - - Airbnb for their [Epoxy](https://github.com/airbnb/epoxy) RecyclerView. They made having multiple itemview types and dragging and dropping super easy! - - @[Jake Wharton](https://github.com/JakeWharton) for his [Timber](https://github.com/JakeWharton/timber) logging library. - - @[srikanth-lingala](https://github.com/srikanth-lingala) for their Java zip file [library](https://github.com/srikanth-lingala/zip4j). - - @[anggrayudi](https://github.com/anggrayudi) for their [Simple Storage](https://github.com/anggrayudi/SimpleStorage) library that makes working with Scoped Storage and the Storage Access Framework on Android much easier. - - @[MFlisar](https://github.com/MFlisar) for their [drag and select](https://github.com/MFlisar/DragSelectRecyclerView) library. - - @[RikkaApps](https://github.com/RikkaApps) for Shizuku! It is amazing. - - @[canopas](https://github.com/canopas) for their Jetpack Compose Tap Target library https://github.com/canopas/compose-intro-showcase. +- @[dslul](https://github.com/dslul) for their [OpenBoard](https://github.com/dslul/openboard) fork + of the AOSP keyboard! This made adding a GUI keyboard for Key Mapper _much_ easier. 🎉 +- @[salomonbrys] for his [Kotson] library so I can easily use Gson in Kotlin. +- The [Material Design Icons] community for various icons that are not provided by Google. +- [App Mockup] for their screenshot utility. +- Airbnb for their [Epoxy](https://github.com/airbnb/epoxy) RecyclerView. They made having multiple + itemview types and dragging and dropping super easy! +- @[Jake Wharton](https://github.com/JakeWharton) for + his [Timber](https://github.com/JakeWharton/timber) logging library. +- @[srikanth-lingala](https://github.com/srikanth-lingala) for their Java zip + file [library](https://github.com/srikanth-lingala/zip4j). +- @[anggrayudi](https://github.com/anggrayudi) for + their [Simple Storage](https://github.com/anggrayudi/SimpleStorage) library that makes working + with Scoped Storage and the Storage Access Framework on Android much easier. +- @[MFlisar](https://github.com/MFlisar) for + their [drag and select](https://github.com/MFlisar/DragSelectRecyclerView) library. +- @[RikkaApps](https://github.com/RikkaApps) for Shizuku! It is amazing and was used as the base for + PRO mode (system bridge). +- @[canopas](https://github.com/canopas) for their Jetpack Compose Tap Target + library https://github.com/canopas/compose-intro-showcase. +- @[topjohnwu](https://github.com/topjohnwu) for Magisk + and [libsu](https://github.com/topjohnwu/libsu). [salomonbrys]: https://github.com/salomonbrys + [Kotson]: https://github.com/salomonbrys/Kotson + [Material Design Icons]: https://materialdesignicons.com/ + [App Mockup]: https://app-mockup.com/ diff --git a/api/src/main/AndroidManifest.xml b/api/src/main/AndroidManifest.xml index b3e73b7e65..6965340ec4 100644 --- a/api/src/main/AndroidManifest.xml +++ b/api/src/main/AndroidManifest.xml @@ -36,6 +36,16 @@ + + + + + + + + diff --git a/api/src/main/java/io/github/sds100/keymapper/api/Api.kt b/api/src/main/java/io/github/sds100/keymapper/api/Api.kt index 3c51a8126f..fdc69649e8 100644 --- a/api/src/main/java/io/github/sds100/keymapper/api/Api.kt +++ b/api/src/main/java/io/github/sds100/keymapper/api/Api.kt @@ -4,9 +4,13 @@ object Api { // Do not use the package name for debug/ci builds const val ACTION_TRIGGER_KEYMAP_BY_UID = "io.github.sds100.keymapper.ACTION_TRIGGER_KEYMAP_BY_UID" - const val EXTRA_KEYMAP_UID = "io.github.sds100.keymapper.EXTRA_KEYMAP_UID" + const val EXTRA_KEYMAP_ID = "io.github.sds100.keymapper.EXTRA_KEYMAP_UID" const val ACTION_PAUSE_MAPPINGS = "io.github.sds100.keymapper.ACTION_PAUSE_MAPPINGS" const val ACTION_RESUME_MAPPINGS = "io.github.sds100.keymapper.ACTION_RESUME_MAPPINGS" const val ACTION_TOGGLE_MAPPINGS = "io.github.sds100.keymapper.ACTION_TOGGLE_MAPPINGS" + + const val ACTION_ENABLE_KEY_MAP = "io.github.sds100.keymapper.ACTION_ENABLE_KEY_MAP" + const val ACTION_DISABLE_KEY_MAP = "io.github.sds100.keymapper.ACTION_DISABLE_KEY_MAP" + const val ACTION_TOGGLE_KEY_MAP = "io.github.sds100.keymapper.ACTION_TOGGLE_KEY_MAP" } diff --git a/api/src/main/java/io/github/sds100/keymapper/api/EnableKeyMapsBroadcastReceiver.kt b/api/src/main/java/io/github/sds100/keymapper/api/EnableKeyMapsBroadcastReceiver.kt new file mode 100644 index 0000000000..4d7b304e63 --- /dev/null +++ b/api/src/main/java/io/github/sds100/keymapper/api/EnableKeyMapsBroadcastReceiver.kt @@ -0,0 +1,36 @@ +package io.github.sds100.keymapper.api + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import io.github.sds100.keymapper.base.keymaps.EnableKeyMapsUseCase +import javax.inject.Inject + +// DON'T MOVE THIS CLASS TO A DIFFERENT PACKAGE OR RENAME BECAUSE IT BREAKS THE API +@AndroidEntryPoint +class EnableKeyMapsBroadcastReceiver : BroadcastReceiver() { + + @Inject + lateinit var useCase: EnableKeyMapsUseCase + + override fun onReceive(context: Context?, intent: Intent?) { + context ?: return + intent?.action ?: return + + if (intent.action != Api.ACTION_ENABLE_KEY_MAP && + intent.action != Api.ACTION_DISABLE_KEY_MAP && + intent.action != Api.ACTION_TOGGLE_KEY_MAP + ) { + return + } + + val keyMapUid = intent.getStringExtra(Api.EXTRA_KEYMAP_ID) ?: return + + when (intent.action) { + Api.ACTION_ENABLE_KEY_MAP -> useCase.enable(keyMapUid) + Api.ACTION_DISABLE_KEY_MAP -> useCase.disable(keyMapUid) + Api.ACTION_TOGGLE_KEY_MAP -> useCase.toggle(keyMapUid) + } + } +} diff --git a/api/src/main/java/io/github/sds100/keymapper/api/KeyEventRelayService.kt b/api/src/main/java/io/github/sds100/keymapper/api/KeyEventRelayService.kt index 58c49939e5..83c41f2a98 100644 --- a/api/src/main/java/io/github/sds100/keymapper/api/KeyEventRelayService.kt +++ b/api/src/main/java/io/github/sds100/keymapper/api/KeyEventRelayService.kt @@ -8,8 +8,8 @@ import android.os.IBinder import android.os.IBinder.DeathRecipient import android.view.KeyEvent import android.view.MotionEvent -import timber.log.Timber import java.util.concurrent.ConcurrentHashMap +import timber.log.Timber /** * This service is used as a relay between the accessibility service and input method service to pass @@ -21,7 +21,10 @@ import java.util.concurrent.ConcurrentHashMap * * This was originally implemented in issue #850 for the action to answer phone calls * because Android doesn't pass volume down key events to the accessibility service - * when the phone is ringing or it is in a phone call. + * when the phone is ringing or it is in a phone call. Later, in Android 14 this relay must be + * used because they also introduced a 1-second delay to context-registered broadcast receivers. + * And who knows what other restrictions will be added in the future :) + * * The accessibility service registers a callback and the input method service * sends the key events. */ @@ -114,7 +117,9 @@ class KeyEventRelayService : Service() { val sourcePackageName = getCallerPackageName() ?: return if (client == null || !permittedPackages.contains(sourcePackageName)) { - Timber.d("An unrecognized package $sourcePackageName tried to register a key event relay callback.") + Timber.d( + "An unrecognized package $sourcePackageName tried to register a key event relay callback.", + ) return } diff --git a/api/src/main/java/io/github/sds100/keymapper/api/KeyMapShortcutActivityIntentBuilderImpl.kt b/api/src/main/java/io/github/sds100/keymapper/api/KeyMapShortcutActivityIntentBuilderImpl.kt index f6d8faae4f..930f535d4c 100644 --- a/api/src/main/java/io/github/sds100/keymapper/api/KeyMapShortcutActivityIntentBuilderImpl.kt +++ b/api/src/main/java/io/github/sds100/keymapper/api/KeyMapShortcutActivityIntentBuilderImpl.kt @@ -12,11 +12,10 @@ import javax.inject.Singleton class KeyMapShortcutActivityIntentBuilderImpl @Inject constructor( @ApplicationContext private val ctx: Context, ) : KeyMapShortcutActivityIntentBuilder { - override fun build(intentAction: String, intentExtras: Bundle): Intent { - return Intent(ctx, LaunchKeyMapShortcutActivity::class.java).apply { + override fun build(intentAction: String, intentExtras: Bundle): Intent = + Intent(ctx, LaunchKeyMapShortcutActivity::class.java).apply { action = intentAction putExtras(intentExtras) } - } } diff --git a/api/src/main/java/io/github/sds100/keymapper/api/LaunchKeyMapShortcutActivity.kt b/api/src/main/java/io/github/sds100/keymapper/api/LaunchKeyMapShortcutActivity.kt index 32e269e42b..2b31f4000c 100644 --- a/api/src/main/java/io/github/sds100/keymapper/api/LaunchKeyMapShortcutActivity.kt +++ b/api/src/main/java/io/github/sds100/keymapper/api/LaunchKeyMapShortcutActivity.kt @@ -33,8 +33,8 @@ class LaunchKeyMapShortcutActivity : ComponentActivity() { Intent(Api.ACTION_TRIGGER_KEYMAP_BY_UID).apply { setPackage(packageName) - val uuid = intent.getStringExtra(Api.EXTRA_KEYMAP_UID) - putExtra(Api.EXTRA_KEYMAP_UID, uuid) + val uuid = intent.getStringExtra(Api.EXTRA_KEYMAP_ID) + putExtra(Api.EXTRA_KEYMAP_ID, uuid) sendBroadcast(this) } diff --git a/api/src/main/java/io/github/sds100/keymapper/api/PauseMappingsBroadcastReceiver.kt b/api/src/main/java/io/github/sds100/keymapper/api/PauseMappingsBroadcastReceiver.kt index 8cabcafc4c..281f91d303 100644 --- a/api/src/main/java/io/github/sds100/keymapper/api/PauseMappingsBroadcastReceiver.kt +++ b/api/src/main/java/io/github/sds100/keymapper/api/PauseMappingsBroadcastReceiver.kt @@ -8,7 +8,7 @@ import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase import io.github.sds100.keymapper.common.utils.firstBlocking import javax.inject.Inject -// DON'T MOVE THIS CLASS TO A DIFFERENT PACKAGE BECAUSE IT BREAKS THE API +// DON'T MOVE THIS CLASS TO A DIFFERENT PACKAGE OR RENAME BECAUSE IT BREAKS THE API @AndroidEntryPoint class PauseMappingsBroadcastReceiver : BroadcastReceiver() { diff --git a/api/src/main/java/io/github/sds100/keymapper/api/TriggerKeyMapsBroadcastReceiver.kt b/api/src/main/java/io/github/sds100/keymapper/api/TriggerKeyMapsBroadcastReceiver.kt index 12b0acdf8e..b1315f4c5b 100644 --- a/api/src/main/java/io/github/sds100/keymapper/api/TriggerKeyMapsBroadcastReceiver.kt +++ b/api/src/main/java/io/github/sds100/keymapper/api/TriggerKeyMapsBroadcastReceiver.kt @@ -6,9 +6,9 @@ import android.content.Intent import dagger.hilt.android.AndroidEntryPoint import io.github.sds100.keymapper.base.keymaps.TriggerKeyMapEvent import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import javax.inject.Inject // DON'T MOVE THIS CLASS TO A DIFFERENT PACKAGE BECAUSE IT BREAKS THE API @AndroidEntryPoint @@ -26,7 +26,7 @@ class TriggerKeyMapsBroadcastReceiver : BroadcastReceiver() { when (intent.action) { Api.ACTION_TRIGGER_KEYMAP_BY_UID -> { - intent.getStringExtra(Api.EXTRA_KEYMAP_UID)?.let { uid -> + intent.getStringExtra(Api.EXTRA_KEYMAP_ID)?.let { uid -> coroutineScope.launch { serviceAdapter.send(TriggerKeyMapEvent(uid)) } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5df922030..2b0504c7a9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,6 +116,15 @@ android { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } + packaging { + jniLibs { + // This replaces extractNativeLibs option in the manifest. This is needed so the + // libraries are extracted to a location on disk where the system bridge process + // can access them. Start in Android 6.0, they are no longer extracted by default. + useLegacyPackaging = true + } + } + sourceSets { getByName("androidTest") { assets.srcDirs(files("$projectDir/schemas")) @@ -141,6 +150,7 @@ dependencies { implementation(project(":base")) implementation(project(":api")) implementation(project(":data")) + implementation(project(":sysbridge")) implementation(project(":system")) compileOnly(project(":systemstubs")) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index bbf10eaad0..032e987d4b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -98,4 +98,140 @@ -keep class com.google.gson.reflect.TypeToken -keep class * extends com.google.gson.reflect.TypeToken --keep public class * implements java.lang.reflect.Type \ No newline at end of file +-keep public class * implements java.lang.reflect.Type + +-keep class io.github.sds100.keymapper.sysbridge.service.SystemBridge { + public ; + native ; + static ; + (...); +} + +# Keep all AIDL interface classes and their methods +-keep class io.github.sds100.keymapper.sysbridge.ISystemBridge** { *; } +-keep class io.github.sds100.keymapper.sysbridge.IEvdevCallback** { *; } +-keep class io.github.sds100.keymapper.sysbridge.IShizukuStarterService** { *; } + +-keepclassmembers class io.github.sds100.keymapper.sysbridge.shizuku.ShizukuStarterService { + public (...); +} + +# Keep binder provider classes +-keep class io.github.sds100.keymapper.sysbridge.provider.** { *; } + +# Keep classes accessed via reflection or from system services +-keep class io.github.sds100.keymapper.sysbridge.utils.** { *; } + +# Keep native method signatures +-keepclasseswithmembernames class * { + native ; +} + +# Keep classes that might be accessed via ContentProvider +-keep class io.github.sds100.keymapper.sysbridge.** extends android.content.ContentProvider { *; } + +# Keep parcelable classes used in AIDL +-keep class io.github.sds100.keymapper.common.models.EvdevDeviceHandle { *; } + +# Keep all rikka.hidden classes and interfaces as they contain AIDL files +-keep class rikka.hidden.** { *; } +-keep interface rikka.hidden.** { *; } + +# Keep Android system API classes and interfaces that rikka.hidden depends on +# android.app package classes +-keep class android.app.ActivityManagerNative { *; } +-keep class android.app.ActivityTaskManager$RootTaskInfo { *; } +-keep class android.app.ContentProviderHolder { *; } +-keep class android.app.IActivityManager** { *; } +-keep class android.app.IApplicationThread** { *; } +-keep class android.app.IProcessObserver** { *; } +-keep class android.app.ITaskStackListener** { *; } +-keep class android.app.IUidObserver** { *; } +-keep class android.app.ProfilerInfo { *; } + +# android.content package classes +-keep class android.content.IContentProvider** { *; } +-keep class android.content.IIntentReceiver** { *; } + +# android.content.pm package classes +-keep class android.content.pm.IPackageManager** { *; } +-keep class android.content.pm.IPackageInstaller** { *; } +-keep class android.content.pm.ILauncherApps** { *; } +-keep class android.content.pm.IOnAppsChangedListener** { *; } +-keep class android.content.pm.IPackageInstallerCallback** { *; } +-keep class android.content.pm.ParceledListSlice { *; } +-keep class android.content.pm.UserInfo { *; } + +# android.hardware package classes +-keep class android.hardware.input.IInputManager** { *; } +-keep class android.hardware.display.IDisplayManager** { *; } +-keep class android.hardware.display.IDisplayManagerCallback** { *; } + +# android.os package classes +-keep class android.os.BatteryProperty { *; } +-keep class android.os.IBatteryPropertiesRegistrar** { *; } +-keep class android.os.IDeviceIdleController { *; } +-keep class android.os.IDeviceIdleController** { *; } +-keep class android.os.IUserManager { *; } +-keep class android.os.IUserManager** { *; } +-keep class android.os.RemoteCallback** { *; } +-keep class android.os.ServiceManager { *; } + +# android.view package classes +-keep class android.view.DisplayInfo { *; } +-keep class android.view.IWindowManager** { *; } + +# android.permission package classes +-keep class android.permission.IPermissionManager** { *; } + +# android.net package classes +-keep class android.net.wifi.IWifiManager** { *; } + +# com.android.internal package classes +-keep class com.android.internal.app.IAppOpsActiveCallback** { *; } +-keep class com.android.internal.app.IAppOpsNotedCallback** { *; } +-keep class com.android.internal.app.IAppOpsService** { *; } +-keep class com.android.internal.policy.IKeyguardLockedStateListener** { *; } + +# Keep all Android AIDL interfaces (they implement IInterface) +-keep class android.** implements android.os.IInterface { *; } + +# Keep Android system service stubs and natives +-keep class android.**Native { *; } +-keep class android.**$Stub** { *; } +-keep class android.**$Proxy** { *; } + +# Keep Android hidden/internal classes that might be accessed via reflection +-dontwarn android.app.ActivityManagerNative +-dontwarn android.app.ActivityTaskManager$** +-dontwarn android.app.ContentProviderHolder +-dontwarn android.app.IActivityManager** +-dontwarn android.app.IApplicationThread** +-dontwarn android.app.IProcessObserver** +-dontwarn android.app.ITaskStackListener** +-dontwarn android.app.IUidObserver** +-dontwarn android.app.ProfilerInfo +-dontwarn android.app.AppOpsManager$** +-dontwarn android.content.IContentProvider** +-dontwarn android.content.IIntentReceiver** +-dontwarn android.content.pm.ILauncherApps** +-dontwarn android.content.pm.IOnAppsChangedListener** +-dontwarn android.content.pm.IPackageInstallerCallback** +-dontwarn android.content.pm.ParceledListSlice +-dontwarn android.content.pm.UserInfo +-dontwarn android.content.pm.PackageManagerHidden +-dontwarn android.hardware.display.IDisplayManager** +-dontwarn android.hardware.display.IDisplayManagerCallback** +-dontwarn android.os.BatteryProperty +-dontwarn android.os.IBatteryPropertiesRegistrar** +-dontwarn android.os.IDeviceIdleController +-dontwarn android.os.IDeviceIdleController** +-dontwarn android.os.IUserManager +-dontwarn android.os.IUserManager** +-dontwarn android.os.RemoteCallback** +-dontwarn android.os.ServiceManager +-dontwarn android.os.UserHandle$** +-dontwarn android.view.DisplayInfo +-dontwarn android.view.IWindowManager** +-dontwarn com.android.internal.app.** +-dontwarn com.android.internal.policy.** \ No newline at end of file diff --git a/app/src/ci/res/values/strings.xml b/app/src/ci/res/values/strings.xml index e89ac4be22..c6cb972dab 100644 --- a/app/src/ci/res/values/strings.xml +++ b/app/src/ci/res/values/strings.xml @@ -1,5 +1,5 @@ Key Mapper CI - Key Mapper CI Basic Input Method + Key Mapper CI Input Method \ No newline at end of file diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml index bc42450e26..a78a0ec875 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -1,5 +1,5 @@ Key Mapper Debug - Key Mapper Debug Basic Input Method + Key Mapper Debug Input Method \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fd3a99ee47..c4e87ba265 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,10 +5,16 @@ + + + + diff --git a/app/src/main/java/io/github/sds100/keymapper/AppHiltModule.kt b/app/src/main/java/io/github/sds100/keymapper/AppHiltModule.kt index 286adf22ad..0cf2a1f83a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/AppHiltModule.kt +++ b/app/src/main/java/io/github/sds100/keymapper/AppHiltModule.kt @@ -12,9 +12,9 @@ import io.github.sds100.keymapper.common.utils.DefaultDispatcherProvider import io.github.sds100.keymapper.common.utils.DispatcherProvider import io.github.sds100.keymapper.purchasing.PurchasingManagerImpl import io.github.sds100.keymapper.system.accessibility.MyAccessibilityService +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -31,7 +31,7 @@ class AppHiltModule { @Provides fun provideBuildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider { override val minApi: Int - get() = Build.VERSION_CODES.LOLLIPOP + get() = Build.VERSION_CODES.O override val maxApi: Int get() = 1000 override val packageName: String @@ -40,6 +40,8 @@ class AppHiltModule { get() = BuildConfig.VERSION_NAME override val versionCode: Int get() = BuildConfig.VERSION_CODE + override val sdkInt: Int + get() = Build.VERSION.SDK_INT } @Singleton diff --git a/app/src/main/java/io/github/sds100/keymapper/MainFragment.kt b/app/src/main/java/io/github/sds100/keymapper/MainFragment.kt index ccd1e61eee..0a1fec850b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/MainFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/MainFragment.kt @@ -15,29 +15,38 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.ComposeNavigator +import androidx.navigation.compose.DialogNavigator import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint import io.github.sds100.keymapper.base.BaseMainNavHost -import io.github.sds100.keymapper.base.actions.ChooseActionScreen -import io.github.sds100.keymapper.base.actions.ChooseActionViewModel +import io.github.sds100.keymapper.base.actions.ActionsScreen +import io.github.sds100.keymapper.base.actions.ConfigActionsViewModel import io.github.sds100.keymapper.base.compose.KeyMapperTheme -import io.github.sds100.keymapper.base.databinding.FragmentComposeBinding +import io.github.sds100.keymapper.base.constraints.ConfigConstraintsViewModel +import io.github.sds100.keymapper.base.constraints.ConstraintsScreen import io.github.sds100.keymapper.base.home.HomeKeyMapListScreen +import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapScreen +import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapViewModel +import io.github.sds100.keymapper.base.keymaps.KeyMapOptionsScreen +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegateImpl import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProviderImpl import io.github.sds100.keymapper.base.utils.navigation.SetupNavigation import io.github.sds100.keymapper.base.utils.navigation.handleRouteArgs import io.github.sds100.keymapper.base.utils.navigation.setupFragmentNavigation -import io.github.sds100.keymapper.base.utils.ui.DialogProviderImpl import io.github.sds100.keymapper.home.HomeViewModel -import io.github.sds100.keymapper.keymaps.ConfigKeyMapScreen -import io.github.sds100.keymapper.keymaps.ConfigKeyMapViewModel +import io.github.sds100.keymapper.trigger.AdvancedTriggersScreenFoss +import io.github.sds100.keymapper.trigger.ConfigTriggerViewModel +import io.github.sds100.keymapper.trigger.TriggerScreen import javax.inject.Inject @AndroidEntryPoint @@ -47,7 +56,11 @@ class MainFragment : Fragment() { lateinit var navigationProvider: NavigationProviderImpl @Inject - lateinit var dialogProvider: DialogProviderImpl + lateinit var setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegateImpl + + private lateinit var composeView: ComposeView + + private lateinit var navController: NavHostController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -60,37 +73,73 @@ class MainFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle?, ): View { - FragmentComposeBinding.inflate(inflater, container, false).apply { - composeView.apply { - // Dispose of the Composition when the view's LifecycleOwner - // is destroyed - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val navController = rememberNavController() - SetupNavigation(navigationProvider, navController) - - KeyMapperTheme { - BaseMainNavHost( - modifier = Modifier - .windowInsetsPadding( - WindowInsets.systemBars.only(sides = WindowInsetsSides.Horizontal) - .add(WindowInsets.displayCutout.only(sides = WindowInsetsSides.Horizontal)), - ), - navController = navController, - composableDestinations = { - composableDestinations() - }, - ) - } + return ComposeView(requireContext()).also { + composeView = it + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + navController = NavHostController(requireContext()).apply { + navigatorProvider.addNavigator(ComposeNavigator()) + navigatorProvider.addNavigator(DialogNavigator()) + + if (savedInstanceState == null) { + restoreState(navigationProvider.savedState) + navigationProvider.savedState = null + } else { + restoreState(savedInstanceState) + } + } + + composeView.apply { + // Dispose of the Composition when the view's LifecycleOwner + // is destroyed + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + SetupNavigation(navigationProvider, navController) + + KeyMapperTheme { + BaseMainNavHost( + modifier = Modifier + .windowInsetsPadding( + WindowInsets.systemBars.only(sides = WindowInsetsSides.Horizontal) + .add( + WindowInsets.displayCutout.only( + sides = WindowInsetsSides.Horizontal, + ), + ), + ), + navController = navController, + setupAccessibilityServiceDelegate = setupAccessibilityServiceDelegate, + composableDestinations = { + composableDestinations(navController) + }, + ) } } - return this.root } } - private fun NavGraphBuilder.composableDestinations() { + override fun onSaveInstanceState(outState: Bundle) { + navController.saveState()?.let(outState::putAll) + + super.onSaveInstanceState(outState) + } + + override fun onDestroyView() { + // onSaveInstanceState is only called when the activity's onSaveInstanceState method + // is called so use our own place to save the navigation state + navigationProvider.savedState = navController.saveState() + + super.onDestroyView() + } + + private fun NavGraphBuilder.composableDestinations(navController: NavController) { composable { val snackbarState = remember { SnackbarHostState() } + val viewModel: HomeViewModel = hiltViewModel() HomeKeyMapListScreen( @@ -107,45 +156,83 @@ class MainFragment : Fragment() { } composable { backStackEntry -> - val viewModel: ConfigKeyMapViewModel = hiltViewModel() + val keyMapViewModel: ConfigKeyMapViewModel = hiltViewModel() + val triggerViewModel: ConfigTriggerViewModel = hiltViewModel() + val actionsViewModel: ConfigActionsViewModel = hiltViewModel() + val constraintsViewModel: ConfigConstraintsViewModel = hiltViewModel() + val snackbarHostState = remember { SnackbarHostState() } backStackEntry.handleRouteArgs { args -> - viewModel.loadNewKeyMap(groupUid = args.groupUid) - - if (args.showAdvancedTriggers) { - viewModel.configTriggerViewModel.showAdvancedTriggersBottomSheet = true - } + keyMapViewModel.loadNewKeyMap(groupUid = args.groupUid) + args.triggerSetupShortcut?.let { triggerViewModel.showTriggerSetup(it) } } ConfigKeyMapScreen( modifier = Modifier.fillMaxSize(), - viewModel = viewModel, + snackbarHostState = snackbarHostState, + keyMapViewModel = keyMapViewModel, + triggerScreen = { + TriggerScreen(Modifier.fillMaxSize(), triggerViewModel) + }, + actionsScreen = { + ActionsScreen(Modifier.fillMaxSize(), actionsViewModel) + }, + constraintsScreen = { + ConstraintsScreen( + Modifier.fillMaxSize(), + constraintsViewModel, + snackbarHostState, + ) + }, + optionsScreen = { + KeyMapOptionsScreen( + Modifier.fillMaxSize(), + triggerViewModel.optionsViewModel, + ) + }, ) } + composable { + AdvancedTriggersScreenFoss( + modifier = Modifier.fillMaxSize(), + onBack = navController::popBackStack, + ) + } composable { backStackEntry -> - val viewModel: ConfigKeyMapViewModel = hiltViewModel() + val keyMapViewModel: ConfigKeyMapViewModel = hiltViewModel() + val triggerViewModel: ConfigTriggerViewModel = hiltViewModel() + val actionsViewModel: ConfigActionsViewModel = hiltViewModel() + val constraintsViewModel: ConfigConstraintsViewModel = hiltViewModel() + val snackbarHostState = remember { SnackbarHostState() } backStackEntry.handleRouteArgs { args -> - viewModel.loadKeyMap(uid = args.keyMapUid) - - if (args.showAdvancedTriggers) { - viewModel.configTriggerViewModel.showAdvancedTriggersBottomSheet = true - } + keyMapViewModel.loadKeyMap(uid = args.keyMapUid) } ConfigKeyMapScreen( modifier = Modifier.fillMaxSize(), - viewModel = viewModel, - ) - } - - composable { - val viewModel: ChooseActionViewModel = hiltViewModel() - - ChooseActionScreen( - modifier = Modifier.fillMaxSize(), - viewModel = viewModel, + snackbarHostState = snackbarHostState, + keyMapViewModel = keyMapViewModel, + triggerScreen = { + TriggerScreen(Modifier.fillMaxSize(), triggerViewModel) + }, + actionsScreen = { + ActionsScreen(Modifier.fillMaxSize(), actionsViewModel) + }, + constraintsScreen = { + ConstraintsScreen( + Modifier.fillMaxSize(), + constraintsViewModel, + snackbarHostState, + ) + }, + optionsScreen = { + KeyMapOptionsScreen( + Modifier.fillMaxSize(), + triggerViewModel.optionsViewModel, + ) + }, ) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt index 0c2f3dc906..ddd332d14b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt @@ -1,15 +1,16 @@ package io.github.sds100.keymapper.home import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.actions.keyevent.FixKeyEventActionDelegate import io.github.sds100.keymapper.base.backup.BackupRestoreMappingsUseCase import io.github.sds100.keymapper.base.home.BaseHomeViewModel +import io.github.sds100.keymapper.base.home.ListKeyMapsUseCase import io.github.sds100.keymapper.base.home.ShowHomeScreenAlertsUseCase -import io.github.sds100.keymapper.base.keymaps.ListKeyMapsUseCase import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate import io.github.sds100.keymapper.base.sorting.SortKeyMapsUseCase import io.github.sds100.keymapper.base.system.inputmethod.ShowInputMethodPickerUseCase -import io.github.sds100.keymapper.base.trigger.SetupGuiKeyboardUseCase import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider @@ -23,9 +24,10 @@ class HomeViewModel @Inject constructor( private val showAlertsUseCase: ShowHomeScreenAlertsUseCase, private val onboarding: OnboardingUseCase, resourceProvider: ResourceProvider, - private val setupGuiKeyboard: SetupGuiKeyboardUseCase, private val sortKeyMaps: SortKeyMapsUseCase, private val showInputMethodPickerUseCase: ShowInputMethodPickerUseCase, + val setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, + fixKeyEventActionDelegate: FixKeyEventActionDelegate, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, ) : BaseHomeViewModel( @@ -35,9 +37,10 @@ class HomeViewModel @Inject constructor( showAlertsUseCase, onboarding, resourceProvider, - setupGuiKeyboard, sortKeyMaps, showInputMethodPickerUseCase, + setupAccessibilityServiceDelegate, + fixKeyEventActionDelegate, navigationProvider, dialogProvider, ) diff --git a/app/src/main/java/io/github/sds100/keymapper/keymaps/ConfigKeyMapScreen.kt b/app/src/main/java/io/github/sds100/keymapper/keymaps/ConfigKeyMapScreen.kt deleted file mode 100644 index 61e36a3624..0000000000 --- a/app/src/main/java/io/github/sds100/keymapper/keymaps/ConfigKeyMapScreen.kt +++ /dev/null @@ -1,81 +0,0 @@ -package io.github.sds100.keymapper.keymaps - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import io.github.sds100.keymapper.base.actions.ActionsScreen -import io.github.sds100.keymapper.base.constraints.ConstraintsScreen -import io.github.sds100.keymapper.base.keymaps.BaseConfigKeyMapScreen -import io.github.sds100.keymapper.base.keymaps.KeyMapOptionsScreen -import io.github.sds100.keymapper.base.utils.ui.UnsavedChangesDialog -import io.github.sds100.keymapper.trigger.TriggerScreen - -@Composable -fun ConfigKeyMapScreen( - modifier: Modifier = Modifier, - viewModel: ConfigKeyMapViewModel, -) { - val isKeyMapEnabled by viewModel.isEnabled.collectAsStateWithLifecycle() - val showActionTapTarget by viewModel.showActionsTapTarget.collectAsStateWithLifecycle() - val showConstraintTapTarget by viewModel.showConstraintsTapTarget.collectAsStateWithLifecycle() - - val snackbarHostState = remember { SnackbarHostState() } - - var showBackDialog by rememberSaveable { mutableStateOf(false) } - - if (showBackDialog) { - UnsavedChangesDialog( - onDismiss = { showBackDialog = false }, - onDiscardClick = { - showBackDialog = false - viewModel.onBackClick() - }, - ) - } - - BaseConfigKeyMapScreen( - modifier = modifier, - isKeyMapEnabled = isKeyMapEnabled, - onKeyMapEnabledChange = viewModel::onEnabledChanged, - triggerScreen = { - TriggerScreen(Modifier.fillMaxSize(), viewModel.configTriggerViewModel) - }, - actionScreen = { - ActionsScreen(Modifier.fillMaxSize(), viewModel.configActionsViewModel) - }, - constraintsScreen = { - ConstraintsScreen( - Modifier.fillMaxSize(), - viewModel.configConstraintsViewModel, - snackbarHostState, - ) - }, - optionsScreen = { - KeyMapOptionsScreen( - Modifier.fillMaxSize(), - viewModel.configTriggerViewModel.optionsViewModel, - ) - }, - onBackClick = { - if (viewModel.isKeyMapEdited) { - showBackDialog = true - } else { - viewModel.onBackClick() - } - }, - onDoneClick = viewModel::onDoneClick, - snackbarHostState = snackbarHostState, - showActionTapTarget = showActionTapTarget, - onActionTapTargetCompleted = viewModel::onActionTapTargetCompleted, - showConstraintTapTarget = showConstraintTapTarget, - onConstraintTapTargetCompleted = viewModel::onConstraintTapTargetCompleted, - onSkipTutorialClick = viewModel::onSkipTutorialClick, - ) -} diff --git a/app/src/main/java/io/github/sds100/keymapper/keymaps/ConfigKeyMapViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/keymaps/ConfigKeyMapViewModel.kt deleted file mode 100644 index 046f5cee1c..0000000000 --- a/app/src/main/java/io/github/sds100/keymapper/keymaps/ConfigKeyMapViewModel.kt +++ /dev/null @@ -1,81 +0,0 @@ -package io.github.sds100.keymapper.keymaps - -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import io.github.sds100.keymapper.base.actions.ConfigActionsViewModel -import io.github.sds100.keymapper.base.actions.CreateActionUseCase -import io.github.sds100.keymapper.base.actions.TestActionUseCase -import io.github.sds100.keymapper.base.constraints.ConfigConstraintsViewModel -import io.github.sds100.keymapper.base.keymaps.BaseConfigKeyMapViewModel -import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapUseCase -import io.github.sds100.keymapper.base.keymaps.CreateKeyMapShortcutUseCase -import io.github.sds100.keymapper.base.keymaps.DisplayKeyMapUseCase -import io.github.sds100.keymapper.base.keymaps.FingerprintGesturesSupportedUseCase -import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase -import io.github.sds100.keymapper.base.purchasing.PurchasingManager -import io.github.sds100.keymapper.base.trigger.RecordTriggerUseCase -import io.github.sds100.keymapper.base.trigger.SetupGuiKeyboardUseCase -import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider -import io.github.sds100.keymapper.base.utils.ui.DialogProvider -import io.github.sds100.keymapper.base.utils.ui.ResourceProvider -import io.github.sds100.keymapper.trigger.ConfigTriggerViewModel -import javax.inject.Inject - -@HiltViewModel -class ConfigKeyMapViewModel @Inject constructor( - display: DisplayKeyMapUseCase, - config: ConfigKeyMapUseCase, - onboarding: OnboardingUseCase, - createActionUseCase: CreateActionUseCase, - testActionUseCase: TestActionUseCase, - recordTriggerUseCase: RecordTriggerUseCase, - createKeyMapShortcutUseCase: CreateKeyMapShortcutUseCase, - purchasingManager: PurchasingManager, - setupGuiKeyboardUseCase: SetupGuiKeyboardUseCase, - fingerprintGesturesSupportedUseCase: FingerprintGesturesSupportedUseCase, - resourceProvider: ResourceProvider, - navigationProvider: NavigationProvider, - dialogProvider: DialogProvider, -) : BaseConfigKeyMapViewModel( - config = config, - onboarding = onboarding, - navigationProvider = navigationProvider, - dialogProvider = dialogProvider, -) { - override val configActionsViewModel: ConfigActionsViewModel = ConfigActionsViewModel( - coroutineScope = viewModelScope, - displayAction = display, - createAction = createActionUseCase, - testAction = testActionUseCase, - config = config, - onboarding = onboarding, - resourceProvider = resourceProvider, - navigationProvider = navigationProvider, - dialogProvider = dialogProvider, - ) - - override val configTriggerViewModel: ConfigTriggerViewModel = ConfigTriggerViewModel( - coroutineScope = viewModelScope, - onboarding = onboarding, - config = config, - recordTrigger = recordTriggerUseCase, - createKeyMapShortcut = createKeyMapShortcutUseCase, - displayKeyMap = display, - purchasingManager = purchasingManager, - setupGuiKeyboard = setupGuiKeyboardUseCase, - fingerprintGesturesSupported = fingerprintGesturesSupportedUseCase, - resourceProvider = resourceProvider, - navigationProvider = navigationProvider, - dialogProvider = dialogProvider, - ) - - override val configConstraintsViewModel: ConfigConstraintsViewModel = - ConfigConstraintsViewModel( - coroutineScope = viewModelScope, - config = config, - displayConstraint = display, - resourceProvider = resourceProvider, - navigationProvider = navigationProvider, - dialogProvider = dialogProvider, - ) -} diff --git a/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt b/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt index 0011982ba9..3d7374e61e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt +++ b/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt @@ -26,5 +26,9 @@ class PurchasingManagerImpl : PurchasingManager { return PurchasingError.PurchasingNotImplemented } + override suspend fun getMetadata(): KMResult> { + return PurchasingError.PurchasingNotImplemented + } + override fun refresh() {} } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt index 6bc72f0bbe..98d59f8ba1 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt @@ -5,41 +5,47 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.github.sds100.keymapper.base.actions.PerformActionsUseCaseImpl import io.github.sds100.keymapper.base.constraints.DetectConstraintsUseCaseImpl +import io.github.sds100.keymapper.base.detection.DetectKeyMapsUseCaseImpl +import io.github.sds100.keymapper.base.input.InputEventHub import io.github.sds100.keymapper.base.keymaps.FingerprintGesturesSupportedUseCase import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase -import io.github.sds100.keymapper.base.keymaps.detection.DetectKeyMapsUseCaseImpl -import io.github.sds100.keymapper.base.reroutekeyevents.RerouteKeyEventsController +import io.github.sds100.keymapper.base.promode.SystemBridgeSetupAssistantController import io.github.sds100.keymapper.base.system.accessibility.AccessibilityNodeRecorder import io.github.sds100.keymapper.base.system.accessibility.BaseAccessibilityServiceController +import io.github.sds100.keymapper.base.system.inputmethod.AutoSwitchImeController +import io.github.sds100.keymapper.base.trigger.RecordTriggerController import io.github.sds100.keymapper.data.repositories.PreferenceRepository -import io.github.sds100.keymapper.system.devices.DevicesAdapter -import io.github.sds100.keymapper.system.root.SuAdapter +import io.github.sds100.keymapper.system.inputmethod.KeyEventRelayServiceWrapper class AccessibilityServiceController @AssistedInject constructor( @Assisted private val service: MyAccessibilityService, - rerouteKeyEventsControllerFactory: RerouteKeyEventsController.Factory, accessibilityNodeRecorderFactory: AccessibilityNodeRecorder.Factory, performActionsUseCaseFactory: PerformActionsUseCaseImpl.Factory, detectKeyMapsUseCaseFactory: DetectKeyMapsUseCaseImpl.Factory, detectConstraintsUseCaseFactory: DetectConstraintsUseCaseImpl.Factory, fingerprintGesturesSupported: FingerprintGesturesSupportedUseCase, pauseKeyMapsUseCase: PauseKeyMapsUseCase, - devicesAdapter: DevicesAdapter, - suAdapter: SuAdapter, settingsRepository: PreferenceRepository, + keyEventRelayServiceWrapper: KeyEventRelayServiceWrapper, + inputEventHub: InputEventHub, + recordTriggerController: RecordTriggerController, + setupAssistantControllerFactory: SystemBridgeSetupAssistantController.Factory, + autoSwitchImeControllerFactory: AutoSwitchImeController.Factory, ) : BaseAccessibilityServiceController( service = service, - rerouteKeyEventsControllerFactory = rerouteKeyEventsControllerFactory, accessibilityNodeRecorderFactory = accessibilityNodeRecorderFactory, performActionsUseCaseFactory = performActionsUseCaseFactory, detectKeyMapsUseCaseFactory = detectKeyMapsUseCaseFactory, detectConstraintsUseCaseFactory = detectConstraintsUseCaseFactory, fingerprintGesturesSupported = fingerprintGesturesSupported, pauseKeyMapsUseCase = pauseKeyMapsUseCase, - devicesAdapter = devicesAdapter, - suAdapter = suAdapter, settingsRepository = settingsRepository, + keyEventRelayServiceWrapper = keyEventRelayServiceWrapper, + inputEventHub = inputEventHub, + recordTriggerController = recordTriggerController, + setupAssistantControllerFactory = setupAssistantControllerFactory, + autoSwitchImeControllerFactory = autoSwitchImeControllerFactory, ) { @AssistedFactory interface Factory { diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/MyAccessibilityService.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/MyAccessibilityService.kt index 6d470eb0a7..bc47591aae 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/MyAccessibilityService.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/MyAccessibilityService.kt @@ -4,8 +4,8 @@ import android.content.Intent import dagger.hilt.android.AndroidEntryPoint import io.github.sds100.keymapper.base.system.accessibility.BaseAccessibilityService import io.github.sds100.keymapper.base.system.accessibility.BaseAccessibilityServiceController -import timber.log.Timber import javax.inject.Inject +import timber.log.Timber @AndroidEntryPoint class MyAccessibilityService : BaseAccessibilityService() { diff --git a/app/src/main/java/io/github/sds100/keymapper/trigger/AdvancedTriggersBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/trigger/AdvancedTriggersBottomSheet.kt deleted file mode 100644 index cc85192142..0000000000 --- a/app/src/main/java/io/github/sds100/keymapper/trigger/AdvancedTriggersBottomSheet.kt +++ /dev/null @@ -1,155 +0,0 @@ -package io.github.sds100.keymapper.trigger - -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.height -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.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue.Expanded -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle -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 kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AdvancedTriggersBottomSheet( - modifier: Modifier = Modifier, - onDismissRequest: () -> Unit, - viewModel: ConfigTriggerViewModel, - sheetState: SheetState, -) { - AdvancedTriggersBottomSheet( - modifier, - onDismissRequest, - sheetState, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun AdvancedTriggersBottomSheet( - modifier: Modifier = Modifier, - onDismissRequest: () -> Unit, - sheetState: SheetState, -) { - val scope = rememberCoroutineScope() - - ModalBottomSheet( - modifier = modifier, - onDismissRequest = onDismissRequest, - sheetState = sheetState, - // Hide drag handle because other bottom sheets don't have it - dragHandle = {}, - ) { - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - Spacer(modifier = Modifier.height(8.dp)) - - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - text = stringResource(R.string.advanced_triggers_sheet_title), - style = MaterialTheme.typography.headlineMedium, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - text = stringResource(R.string.advanced_triggers_sheet_text), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - text = stringResource(R.string.purchasing_not_implemented_bottom_sheet_text), - fontStyle = FontStyle.Italic, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - val uriHandler = LocalUriHandler.current - val googlePlayUrl = stringResource(R.string.url_play_store_listing) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - modifier = Modifier, - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.neg_cancel)) - } - - Spacer(modifier = Modifier.width(8.dp)) - - FilledTonalButton( - modifier = Modifier, - onClick = { - scope.launch { - uriHandler.openUri(googlePlayUrl) - } - }, - ) { - Text(stringResource(R.string.purchasing_download_key_mapper_from_google_play)) - } - } - - Spacer(Modifier.height(16.dp)) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun Preview() { - KeyMapperTheme { - val sheetState = SheetState( - skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = Expanded, - ) - - AdvancedTriggersBottomSheet( - onDismissRequest = {}, - sheetState = sheetState, - ) - } -} diff --git a/app/src/main/java/io/github/sds100/keymapper/trigger/AdvancedTriggersScreenFoss.kt b/app/src/main/java/io/github/sds100/keymapper/trigger/AdvancedTriggersScreenFoss.kt new file mode 100644 index 0000000000..2994b7ace8 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/trigger/AdvancedTriggersScreenFoss.kt @@ -0,0 +1,144 @@ +package io.github.sds100.keymapper.trigger + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +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.openUriSafe + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdvancedTriggersScreenFoss(modifier: Modifier = Modifier, onBack: () -> Unit) { + Scaffold( + modifier = modifier, + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(R.string.support_key_mapper_title), + style = MaterialTheme.typography.headlineMedium, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource( + R.string.bottom_app_bar_back_content_description, + ), + ) + } + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + }, + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(contentPadding) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.support_key_mapper_subtitle), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.support_key_mapper_sub_subtitle), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.purchasing_not_implemented_bottom_sheet_text), + style = MaterialTheme.typography.bodyMedium, + fontStyle = FontStyle.Italic, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + val uriHandler = LocalUriHandler.current + val ctx = LocalContext.current + val googlePlayUrl = stringResource(R.string.url_play_store_listing) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onBack, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Button( + modifier = Modifier.weight(1f), + onClick = { + uriHandler.openUriSafe(ctx, googlePlayUrl) + }, + ) { + Text(stringResource(R.string.purchasing_download_key_mapper_from_google_play)) + } + } + + Spacer(Modifier.height(24.dp)) + } + } +} + +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + AdvancedTriggersScreenFoss( + onBack = {}, + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt index 1654023097..898a1d21b4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt @@ -1,43 +1,48 @@ package io.github.sds100.keymapper.trigger -import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapUseCase -import io.github.sds100.keymapper.base.keymaps.CreateKeyMapShortcutUseCase +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sds100.keymapper.base.keymaps.DisplayKeyMapUseCase import io.github.sds100.keymapper.base.keymaps.FingerprintGesturesSupportedUseCase +import io.github.sds100.keymapper.base.onboarding.OnboardingTipDelegate import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase -import io.github.sds100.keymapper.base.purchasing.PurchasingManager +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate +import io.github.sds100.keymapper.base.shortcuts.CreateKeyMapShortcutUseCase import io.github.sds100.keymapper.base.trigger.BaseConfigTriggerViewModel -import io.github.sds100.keymapper.base.trigger.RecordTriggerUseCase -import io.github.sds100.keymapper.base.trigger.SetupGuiKeyboardUseCase +import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCase +import io.github.sds100.keymapper.base.trigger.RecordTriggerController +import io.github.sds100.keymapper.base.trigger.TriggerSetupDelegate +import io.github.sds100.keymapper.base.trigger.TriggerSetupShortcut import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider -import kotlinx.coroutines.CoroutineScope import javax.inject.Inject +import kotlinx.coroutines.launch +@HiltViewModel class ConfigTriggerViewModel @Inject constructor( - private val coroutineScope: CoroutineScope, private val onboarding: OnboardingUseCase, - private val config: ConfigKeyMapUseCase, - private val recordTrigger: RecordTriggerUseCase, + private val config: ConfigTriggerUseCase, + private val recordTrigger: RecordTriggerController, private val createKeyMapShortcut: CreateKeyMapShortcutUseCase, private val displayKeyMap: DisplayKeyMapUseCase, - private val purchasingManager: PurchasingManager, - private val setupGuiKeyboard: SetupGuiKeyboardUseCase, private val fingerprintGesturesSupported: FingerprintGesturesSupportedUseCase, + setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, + onboardingTipDelegate: OnboardingTipDelegate, + triggerSetupDelegate: TriggerSetupDelegate, resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, ) : BaseConfigTriggerViewModel( - coroutineScope = coroutineScope, onboarding = onboarding, config = config, recordTrigger = recordTrigger, createKeyMapShortcut = createKeyMapShortcut, displayKeyMap = displayKeyMap, - purchasingManager = purchasingManager, - setupGuiKeyboard = setupGuiKeyboard, fingerprintGesturesSupported = fingerprintGesturesSupported, + setupAccessibilityServiceDelegate = setupAccessibilityServiceDelegate, + onboardingTipDelegate = onboardingTipDelegate, + triggerSetupDelegate = triggerSetupDelegate, resourceProvider = resourceProvider, navigationProvider = navigationProvider, dialogProvider = dialogProvider, @@ -45,4 +50,14 @@ class ConfigTriggerViewModel @Inject constructor( override fun onEditFloatingButtonClick() {} override fun onEditFloatingLayoutClick() {} + + override fun showTriggerSetup(shortcut: TriggerSetupShortcut, forceProMode: Boolean) { + when (shortcut) { + TriggerSetupShortcut.ASSISTANT -> viewModelScope.launch { + navigateToAdvancedTriggers("purchase_assistant_trigger") + } + + else -> super.showTriggerSetup(shortcut, forceProMode) + } + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/trigger/TriggerScreen.kt b/app/src/main/java/io/github/sds100/keymapper/trigger/TriggerScreen.kt index 6c1da6cf77..22a1ca65ad 100644 --- a/app/src/main/java/io/github/sds100/keymapper/trigger/TriggerScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/trigger/TriggerScreen.kt @@ -1,27 +1,24 @@ package io.github.sds100.keymapper.trigger -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.sds100.keymapper.base.trigger.BaseTriggerScreen +import io.github.sds100.keymapper.base.trigger.TriggerDiscoverScreen @OptIn(ExperimentalMaterial3Api::class) @Composable fun TriggerScreen(modifier: Modifier = Modifier, viewModel: ConfigTriggerViewModel) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val showFingerprintGestures: Boolean by + viewModel.showFingerprintGesturesShortcut.collectAsStateWithLifecycle() - if (viewModel.showAdvancedTriggersBottomSheet) { - AdvancedTriggersBottomSheet( - modifier = Modifier.systemBarsPadding(), - viewModel = viewModel, - onDismissRequest = { - viewModel.showAdvancedTriggersBottomSheet = false - }, - sheetState = sheetState, + BaseTriggerScreen(modifier, viewModel, discoverScreenContent = { + TriggerDiscoverScreen( + showFloatingButtons = true, + showFingerprintGestures = showFingerprintGestures, + onShortcutClick = viewModel::showTriggerSetup, ) - } - - BaseTriggerScreen(modifier, viewModel) + }) } diff --git a/app/version.properties b/app/version.properties index be3fe652ab..5f88270472 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ -VERSION_NAME=3.2.1 -VERSION_CODE=132 -VERSION_NUM=0 \ No newline at end of file +VERSION_NAME=4.0.0-beta.1 +VERSION_CODE=185 +VERSION_NUM=01 \ No newline at end of file diff --git a/base/build.gradle.kts b/base/build.gradle.kts index 86e093e95d..f9118e6def 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -73,6 +73,7 @@ android { dependencies { implementation(project(":common")) implementation(project(":data")) + implementation(project(":sysbridge")) implementation(project(":system")) implementation(project(":systemstubs")) @@ -89,11 +90,9 @@ dependencies { kapt(libs.airbnb.epoxy.processor) implementation(libs.jakewharton.timber) implementation(libs.anggrayudi.storage) - implementation(libs.github.mflisar.dragselectrecyclerview) implementation(libs.google.flexbox) implementation(libs.squareup.okhttp) coreLibraryDesugaring(libs.desugar.jdk.libs) - implementation(libs.canopas.introshowcaseview) implementation(libs.dagger.hilt.android) ksp(libs.dagger.hilt.android.compiler) implementation(libs.bundles.splitties) diff --git a/base/src/main/AndroidManifest.xml b/base/src/main/AndroidManifest.xml index b14e766002..faddfc687d 100644 --- a/base/src/main/AndroidManifest.xml +++ b/base/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/base/src/main/assets/whats-new.txt b/base/src/main/assets/whats-new.txt index 50b5221179..2e842ec8ee 100644 --- a/base/src/main/assets/whats-new.txt +++ b/base/src/main/assets/whats-new.txt @@ -1,5 +1,26 @@ -⌨️ Action to move cursor to previous/next character, word, line, paragraph, or page. +✨ Screen-off remapping +You can now remap buttons when the screen is off (including the power button) for free with PRO mode. -Many other bug fixes and optimisations. +🎯 New Actions +• Run shell commands +• Send SMS messages +• Force stop current app or clear from recents +• Mute/unmute microphone -See all the changes at http://changelog.keymapper.club. +🆕 New Features +• Redesigned Settings screen +• Constraints for foldable hinge open/closed +• Shortcuts on the trigger screen to guide setup +• Select notification and alarm sounds for Sound action + +⚙️ Enhanced Controls +• Enable or disable all key maps in a group at once +• Intent API to enable, disable, or toggle individual key maps +• Floating buttons can now appear on top of keyboard or in notification panel + +🔧 Improvements +• Auto-switching keyboard more reliable and quicker on Android 13+ +• Wi-Fi connected constraints more reliable +• Various bug fixes and performance optimizations + +📖 View the complete changelog at: http://changelog.keymapper.club diff --git a/base/src/main/java/io/github/sds100/keymapper/base/ActivityViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/ActivityViewModel.kt index a8b48bb2b9..ac3706a965 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/ActivityViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/ActivityViewModel.kt @@ -3,32 +3,35 @@ package io.github.sds100.keymapper.base import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate +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.NavigationProviderImpl +import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider -import io.github.sds100.keymapper.base.utils.ui.ViewModelHelper -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.launch @HiltViewModel class ActivityViewModel @Inject constructor( + private val setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, resourceProvider: ResourceProvider, dialogProvider: DialogProvider, + navigationProvider: NavigationProvider, ) : ViewModel(), ResourceProvider by resourceProvider, DialogProvider by dialogProvider, - NavigationProvider by NavigationProviderImpl() { + NavigationProvider by navigationProvider { - var handledActivityLaunchIntent: Boolean = false var previousNightMode: Int? = null fun onCantFindAccessibilitySettings() { + setupAccessibilityServiceDelegate.showCantFindAccessibilitySettingsDialog() + } + + fun launchProModeSetup() { viewModelScope.launch { - ViewModelHelper.handleCantFindAccessibilitySettings( - resourceProvider = this@ActivityViewModel, - dialogProvider = this@ActivityViewModel, - ) + navigate("pro_mode_setup", NavDestination.ProModeSetup) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseKeyMapperApp.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseKeyMapperApp.kt index 62d85ba466..7cea935435 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseKeyMapperApp.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseKeyMapperApp.kt @@ -1,7 +1,10 @@ package io.github.sds100.keymapper.base import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.os.Build import android.os.UserManager import android.util.Log @@ -14,30 +17,34 @@ import androidx.lifecycle.OnLifecycleEvent import androidx.lifecycle.ProcessLifecycleOwner import androidx.multidex.MultiDexApplication import io.github.sds100.keymapper.base.logging.KeyMapperLoggingTree -import io.github.sds100.keymapper.base.settings.ThemeUtils +import io.github.sds100.keymapper.base.promode.SystemBridgeAutoStarter +import io.github.sds100.keymapper.base.settings.Theme import io.github.sds100.keymapper.base.system.accessibility.AccessibilityServiceAdapterImpl -import io.github.sds100.keymapper.base.system.inputmethod.AutoSwitchImeController import io.github.sds100.keymapper.base.system.notifications.NotificationController import io.github.sds100.keymapper.base.system.permissions.AutoGrantPermissionController +import io.github.sds100.keymapper.common.utils.Constants import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.entities.LogEntryEntity import io.github.sds100.keymapper.data.repositories.LogRepository -import io.github.sds100.keymapper.data.repositories.SettingsPreferenceRepository +import io.github.sds100.keymapper.data.repositories.PreferenceRepositoryImpl +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManagerImpl +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState import io.github.sds100.keymapper.system.apps.AndroidPackageManagerAdapter import io.github.sds100.keymapper.system.devices.AndroidDevicesAdapter +import io.github.sds100.keymapper.system.inputmethod.KeyEventRelayServiceWrapperImpl import io.github.sds100.keymapper.system.permissions.AndroidPermissionAdapter import io.github.sds100.keymapper.system.permissions.Permission -import io.github.sds100.keymapper.system.root.SuAdapterImpl +import java.util.Calendar +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import timber.log.Timber -import java.util.Calendar -import javax.inject.Inject @SuppressLint("LogNotTimber") abstract class BaseKeyMapperApp : MultiDexApplication() { @@ -49,9 +56,6 @@ abstract class BaseKeyMapperApp : MultiDexApplication() { @Inject lateinit var notificationController: NotificationController - @Inject - lateinit var autoSwitchImeController: AutoSwitchImeController - @Inject lateinit var packageManagerAdapter: AndroidPackageManagerAdapter @@ -64,9 +68,6 @@ abstract class BaseKeyMapperApp : MultiDexApplication() { @Inject lateinit var accessibilityServiceAdapter: AccessibilityServiceAdapterImpl - @Inject - lateinit var suAdapter: SuAdapterImpl - @Inject lateinit var autoGrantPermissionController: AutoGrantPermissionController @@ -74,11 +75,20 @@ abstract class BaseKeyMapperApp : MultiDexApplication() { lateinit var loggingTree: KeyMapperLoggingTree @Inject - lateinit var settingsRepository: SettingsPreferenceRepository + lateinit var settingsRepository: PreferenceRepositoryImpl @Inject lateinit var logRepository: LogRepository + @Inject + lateinit var keyEventRelayServiceWrapper: KeyEventRelayServiceWrapperImpl + + @Inject + lateinit var systemBridgeAutoStarter: SystemBridgeAutoStarter + + @Inject + lateinit var systemBridgeConnectionManager: SystemBridgeConnectionManagerImpl + private val processLifecycleOwner by lazy { ProcessLifecycleOwner.get() } private val userManager: UserManager? by lazy { getSystemService() } @@ -86,6 +96,25 @@ abstract class BaseKeyMapperApp : MultiDexApplication() { private val initLock: Any = Any() private var initialized = false + private val broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + context ?: return + intent ?: return + + when (intent.action) { + Intent.ACTION_SHUTDOWN -> { + Timber.i("Clean shutdown") + settingsRepository.set(Keys.isCleanShutdown, true) + + // Block until the value is persisted. + runBlocking { + settingsRepository.get(Keys.isCleanShutdown).first { it == true } + } + } + } + } + } + override fun onCreate() { val priorExceptionHandler = Thread.getDefaultUncaughtExceptionHandler() @@ -109,7 +138,7 @@ abstract class BaseKeyMapperApp : MultiDexApplication() { super.onCreate() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && userManager?.isUserUnlocked == false) { + if (userManager?.isUserUnlocked == false) { Log.i(tag, "KeyMapperApp: Delay init because locked.") // If the device is still encrypted and locked do not initialize anything that // may potentially need the encrypted app storage like databases. @@ -123,6 +152,8 @@ abstract class BaseKeyMapperApp : MultiDexApplication() { } fun onBootUnlocked() { + Log.i(tag, "KeyMapperApp: onBootUnlocked") + synchronized(initLock) { if (!initialized) { init() @@ -134,12 +165,18 @@ abstract class BaseKeyMapperApp : MultiDexApplication() { private fun init() { Log.i(tag, "KeyMapperApp: Init") + val intentFilter = IntentFilter().apply { + addAction(Intent.ACTION_SHUTDOWN) + } + + registerReceiver(broadcastReceiver, intentFilter) + settingsRepository.get(Keys.darkTheme) .map { it?.toIntOrNull() } .map { when (it) { - ThemeUtils.DARK -> AppCompatDelegate.MODE_NIGHT_YES - ThemeUtils.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO + Theme.DARK.value -> AppCompatDelegate.MODE_NIGHT_YES + Theme.LIGHT.value -> AppCompatDelegate.MODE_NIGHT_NO else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM } } @@ -154,15 +191,16 @@ abstract class BaseKeyMapperApp : MultiDexApplication() { notificationController.init() - autoSwitchImeController.init() - processLifecycleOwner.lifecycle.addObserver(object : LifecycleObserver { + @Suppress("DEPRECATION") @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) fun onResume() { // when the user returns to the app let everything know that the permissions could have changed notificationController.onOpenApp() - if (BuildConfig.DEBUG && permissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS)) { + if (BuildConfig.DEBUG && + permissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS) + ) { accessibilityServiceAdapter.start() } } @@ -184,6 +222,27 @@ abstract class BaseKeyMapperApp : MultiDexApplication() { }.launchIn(appCoroutineScope) autoGrantPermissionController.start() + keyEventRelayServiceWrapper.bind() + + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + systemBridgeAutoStarter.init() + + appCoroutineScope.launch { + systemBridgeConnectionManager.connectionState.collect { state -> + if (state is SystemBridgeConnectionState.Connected) { + val isUsed = + settingsRepository.get(Keys.isSystemBridgeUsed).first() ?: false + + // Enable the setting to use PRO mode for key event actions the first time they use PRO mode. + if (!isUsed) { + settingsRepository.set(Keys.keyEventActionsUseSystemBridge, true) + } + + settingsRepository.set(Keys.isSystemBridgeUsed, true) + } + } + } + } } abstract fun getMainActivityClass(): Class<*> diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt index 6c6dae42eb..812ca3b3fb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt @@ -26,24 +26,29 @@ import com.anggrayudi.storage.extension.openInputStream import com.anggrayudi.storage.extension.openOutputStream import com.anggrayudi.storage.extension.toDocumentFile import io.github.sds100.keymapper.base.compose.ComposeColors +import io.github.sds100.keymapper.base.input.InputEventDetectionSource +import io.github.sds100.keymapper.base.input.InputEventHubImpl import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase import io.github.sds100.keymapper.base.system.accessibility.AccessibilityServiceAdapterImpl import io.github.sds100.keymapper.base.system.permissions.RequestPermissionDelegate -import io.github.sds100.keymapper.base.trigger.RecordTriggerController +import io.github.sds100.keymapper.base.trigger.RecordTriggerControllerImpl import io.github.sds100.keymapper.base.utils.ui.ResourceProviderImpl -import io.github.sds100.keymapper.base.utils.ui.launchRepeatOnLifecycle import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupControllerImpl +import io.github.sds100.keymapper.system.devices.AndroidDevicesAdapter import io.github.sds100.keymapper.system.files.FileUtils -import io.github.sds100.keymapper.system.inputevents.MyMotionEvent +import io.github.sds100.keymapper.system.inputevents.KMGamePadEvent +import io.github.sds100.keymapper.system.network.AndroidNetworkAdapter import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapterImpl import io.github.sds100.keymapper.system.permissions.AndroidPermissionAdapter +import io.github.sds100.keymapper.system.root.SuAdapterImpl import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber -import javax.inject.Inject abstract class BaseMainActivity : AppCompatActivity() { @@ -56,6 +61,9 @@ abstract class BaseMainActivity : AppCompatActivity() { const val ACTION_SAVE_FILE = "${BuildConfig.LIBRARY_PACKAGE_NAME}.ACTION_SAVE_FILE" const val EXTRA_FILE_URI = "${BuildConfig.LIBRARY_PACKAGE_NAME}.EXTRA_FILE_URI" + + const val ACTION_START_SYSTEM_BRIDGE = + "${BuildConfig.LIBRARY_PACKAGE_NAME}.ACTION_START_SYSTEM_BRIDGE" } @Inject @@ -71,7 +79,7 @@ abstract class BaseMainActivity : AppCompatActivity() { lateinit var onboardingUseCase: OnboardingUseCase @Inject - lateinit var recordTriggerController: RecordTriggerController + lateinit var recordTriggerController: RecordTriggerControllerImpl @Inject lateinit var notificationReceiverAdapter: NotificationReceiverAdapterImpl @@ -82,6 +90,21 @@ abstract class BaseMainActivity : AppCompatActivity() { @Inject lateinit var buildConfigProvider: BuildConfigProvider + @Inject + lateinit var systemBridgeSetupController: SystemBridgeSetupControllerImpl + + @Inject + lateinit var suAdapter: SuAdapterImpl + + @Inject + lateinit var devicesAdapter: AndroidDevicesAdapter + + @Inject + lateinit var networkAdapter: AndroidNetworkAdapter + + @Inject + lateinit var inputEventHub: InputEventHubImpl + private lateinit var requestPermissionDelegate: RequestPermissionDelegate private val currentNightMode: Int @@ -132,10 +155,6 @@ abstract class BaseMainActivity : AppCompatActivity() { ) super.onCreate(savedInstanceState) - if (viewModel.previousNightMode != currentNightMode) { - resourceProvider.onThemeChange() - } - requestPermissionDelegate = RequestPermissionDelegate( this, showDialogs = true, @@ -155,21 +174,6 @@ abstract class BaseMainActivity : AppCompatActivity() { } .launchIn(lifecycleScope) - // Must launch when the activity is resumed - // so the nav controller can be found - launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - if (viewModel.handledActivityLaunchIntent) { - return@launchRepeatOnLifecycle - } - - when (intent?.action) { - ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG -> { - viewModel.onCantFindAccessibilitySettings() - viewModel.handledActivityLaunchIntent = true - } - } - } - IntentFilter().apply { addAction(ACTION_SAVE_FILE) @@ -180,12 +184,16 @@ abstract class BaseMainActivity : AppCompatActivity() { ContextCompat.RECEIVER_EXPORTED, ) } + + handleIntent(intent) } override fun onResume() { super.onResume() - Timber.i("MainActivity: onResume. Version: ${buildConfigProvider.version} ${buildConfigProvider.versionCode}") + Timber.i( + "MainActivity: onResume. Version: ${buildConfigProvider.version} ${buildConfigProvider.versionCode}", + ) // This must be after onResume to ensure all the fragment lifecycles' have also // resumed which are observing these events. @@ -193,6 +201,9 @@ abstract class BaseMainActivity : AppCompatActivity() { // the activities have not necessarily resumed at that point. permissionAdapter.onPermissionsChanged() serviceAdapter.invalidateState() + suAdapter.invalidateIsRooted() + systemBridgeSetupController.invalidateSettings() + networkAdapter.invalidateState() } override fun onDestroy() { @@ -203,11 +214,19 @@ abstract class BaseMainActivity : AppCompatActivity() { super.onDestroy() } + /** + * Process motion events from the activity so that DPAD buttons can be recorded + * even when the Key Mapper IME is not being used. DO NOT record the key events because + * these are sent from the joy sticks. + */ override fun onGenericMotionEvent(event: MotionEvent?): Boolean { event ?: return super.onGenericMotionEvent(event) - val consume = - recordTriggerController.onActivityMotionEvent(MyMotionEvent.fromMotionEvent(event)) + val gamepadEvent = KMGamePadEvent.fromMotionEvent(event) ?: return false + val consume = inputEventHub.onInputEvent( + gamepadEvent, + detectionSource = InputEventDetectionSource.INPUT_METHOD, + ) return if (consume) { true @@ -217,6 +236,29 @@ abstract class BaseMainActivity : AppCompatActivity() { } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + + handleIntent(intent) + } + + private fun handleIntent(intent: Intent?) { + when (intent?.action) { + ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG -> { + viewModel.onCantFindAccessibilitySettings() + // Only clear the intent if it is handled in case it is used elsewhere + this.intent = null + } + + ACTION_START_SYSTEM_BRIDGE -> { + viewModel.launchProModeSetup() + + // Only clear the intent if it is handled in case it is used elsewhere + this.intent = null + } + } + } + private fun saveFile(originalFile: Uri, targetFile: Uri) { lifecycleScope.launch(Dispatchers.IO) { targetFile.openOutputStream(this@BaseMainActivity)?.use { output -> 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 4589823952..f57a042dbc 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 @@ -1,7 +1,14 @@ package io.github.sds100.keymapper.base import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel @@ -9,10 +16,23 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import io.github.sds100.keymapper.base.actions.ChooseActionScreen +import io.github.sds100.keymapper.base.actions.ChooseActionViewModel +import io.github.sds100.keymapper.base.actions.ConfigShellCommandViewModel +import io.github.sds100.keymapper.base.actions.ShellCommandActionScreen 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.logging.LogScreen +import io.github.sds100.keymapper.base.onboarding.HandleAccessibilityServiceDialogs +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegateImpl +import io.github.sds100.keymapper.base.promode.ProModeScreen +import io.github.sds100.keymapper.base.promode.ProModeSetupScreen +import io.github.sds100.keymapper.base.settings.AutomaticChangeImeSettingsScreen +import io.github.sds100.keymapper.base.settings.DefaultOptionsSettingsScreen +import io.github.sds100.keymapper.base.settings.SettingsScreen +import io.github.sds100.keymapper.base.settings.SettingsViewModel import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.handleRouteArgs import kotlinx.serialization.json.Json @@ -21,16 +41,27 @@ import kotlinx.serialization.json.Json fun BaseMainNavHost( modifier: Modifier = Modifier, navController: NavHostController, + setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegateImpl, composableDestinations: NavGraphBuilder.() -> Unit = {}, ) { + HandleAccessibilityServiceDialogs(setupAccessibilityServiceDelegate) + NavHost( modifier = modifier, navController = navController, startDestination = NavDestination.Home, - enterTransition = { slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Left) }, - exitTransition = { slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right) }, - popEnterTransition = { slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right) }, - popExitTransition = { slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right) }, + enterTransition = { + slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Left) + }, + exitTransition = { + slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right) + }, + popEnterTransition = { + slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right) + }, + popExitTransition = { + slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right) + }, ) { composable { backStackEntry -> val viewModel: InteractUiElementViewModel = hiltViewModel() @@ -45,6 +76,19 @@ fun BaseMainNavHost( ) } + composable { backStackEntry -> + val viewModel: ConfigShellCommandViewModel = hiltViewModel() + + backStackEntry.handleRouteArgs { destination -> + destination.actionJson?.let { viewModel.loadAction(Json.decodeFromString(it)) } + } + + ShellCommandActionScreen( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + } + composable { val viewModel: ChooseConstraintViewModel = hiltViewModel() @@ -54,6 +98,72 @@ fun BaseMainNavHost( ) } + composable { + val viewModel: ChooseActionViewModel = hiltViewModel() + + ChooseActionScreen( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + } + + composable { + val viewModel: SettingsViewModel = hiltViewModel() + + SettingsScreen( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + } + + composable { + val viewModel: SettingsViewModel = hiltViewModel() + + DefaultOptionsSettingsScreen( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + } + + composable { + val viewModel: SettingsViewModel = hiltViewModel() + + AutomaticChangeImeSettingsScreen( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + } + + composable { + ProModeScreen( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding( + WindowInsets.systemBars.only(sides = WindowInsetsSides.Horizontal) + .add( + WindowInsets.displayCutout.only( + sides = WindowInsetsSides.Horizontal, + ), + ), + ), + viewModel = hiltViewModel(), + ) + } + + composable { + ProModeSetupScreen( + viewModel = hiltViewModel(), + ) + } + + composable { + LogScreen( + modifier = Modifier.fillMaxSize(), + viewModel = hiltViewModel(), + onBackClick = { navController.popBackStack() }, + ) + } + composableDestinations() } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt index 55a0ffabfe..dbc2efb661 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt @@ -14,28 +14,40 @@ import io.github.sds100.keymapper.base.backup.BackupManager import io.github.sds100.keymapper.base.backup.BackupManagerImpl import io.github.sds100.keymapper.base.constraints.GetConstraintErrorUseCase import io.github.sds100.keymapper.base.constraints.GetConstraintErrorUseCaseImpl -import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapUseCase -import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapUseCaseController +import io.github.sds100.keymapper.base.input.InputEventHub +import io.github.sds100.keymapper.base.input.InputEventHubImpl +import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState +import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapStateImpl +import io.github.sds100.keymapper.base.keymaps.EnableKeyMapsUseCase +import io.github.sds100.keymapper.base.keymaps.EnableKeyMapsUseCaseImpl import io.github.sds100.keymapper.base.keymaps.FingerprintGesturesSupportedUseCase import io.github.sds100.keymapper.base.keymaps.FingerprintGesturesSupportedUseCaseImpl +import io.github.sds100.keymapper.base.keymaps.GetDefaultKeyMapOptionsUseCase +import io.github.sds100.keymapper.base.keymaps.GetDefaultKeyMapOptionsUseCaseImpl import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCaseImpl import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase import io.github.sds100.keymapper.base.onboarding.OnboardingUseCaseImpl +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegateImpl import io.github.sds100.keymapper.base.system.accessibility.AccessibilityServiceAdapterImpl import io.github.sds100.keymapper.base.system.accessibility.ControlAccessibilityServiceUseCase import io.github.sds100.keymapper.base.system.accessibility.ControlAccessibilityServiceUseCaseImpl +import io.github.sds100.keymapper.base.system.inputmethod.ImeInputEventInjector +import io.github.sds100.keymapper.base.system.inputmethod.ImeInputEventInjectorImpl import io.github.sds100.keymapper.base.system.inputmethod.ShowHideInputMethodUseCase import io.github.sds100.keymapper.base.system.inputmethod.ShowHideInputMethodUseCaseImpl import io.github.sds100.keymapper.base.system.inputmethod.ShowInputMethodPickerUseCase import io.github.sds100.keymapper.base.system.inputmethod.ShowInputMethodPickerUseCaseImpl +import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeAsyncImpl +import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface import io.github.sds100.keymapper.base.system.inputmethod.ToggleCompatibleImeUseCase import io.github.sds100.keymapper.base.system.inputmethod.ToggleCompatibleImeUseCaseImpl import io.github.sds100.keymapper.base.system.notifications.AndroidNotificationAdapter import io.github.sds100.keymapper.base.system.notifications.ManageNotificationsUseCase import io.github.sds100.keymapper.base.system.notifications.ManageNotificationsUseCaseImpl import io.github.sds100.keymapper.base.trigger.RecordTriggerController -import io.github.sds100.keymapper.base.trigger.RecordTriggerUseCase +import io.github.sds100.keymapper.base.trigger.RecordTriggerControllerImpl import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.NavigationProviderImpl import io.github.sds100.keymapper.base.utils.ui.DialogProvider @@ -45,6 +57,8 @@ import io.github.sds100.keymapper.base.utils.ui.ResourceProviderImpl import io.github.sds100.keymapper.common.utils.DefaultUuidGenerator import io.github.sds100.keymapper.common.utils.UuidGenerator import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter +import io.github.sds100.keymapper.system.inputmethod.KeyEventRelayServiceWrapper +import io.github.sds100.keymapper.system.inputmethod.KeyEventRelayServiceWrapperImpl import io.github.sds100.keymapper.system.notifications.NotificationAdapter import javax.inject.Singleton @@ -57,7 +71,9 @@ abstract class BaseSingletonHiltModule { @Singleton @Binds - abstract fun provideAccessibilityAdapter(impl: AccessibilityServiceAdapterImpl): AccessibilityServiceAdapter + abstract fun provideAccessibilityAdapter( + impl: AccessibilityServiceAdapterImpl, + ): AccessibilityServiceAdapter @Singleton @Binds @@ -73,23 +89,33 @@ abstract class BaseSingletonHiltModule { @Binds @Singleton - abstract fun bindShowInputMethodPickerUseCase(impl: ShowInputMethodPickerUseCaseImpl): ShowInputMethodPickerUseCase + abstract fun bindShowInputMethodPickerUseCase( + impl: ShowInputMethodPickerUseCaseImpl, + ): ShowInputMethodPickerUseCase @Binds @Singleton - abstract fun bindControlAccessibilityServiceUseCase(impl: ControlAccessibilityServiceUseCaseImpl): ControlAccessibilityServiceUseCase + abstract fun bindControlAccessibilityServiceUseCase( + impl: ControlAccessibilityServiceUseCaseImpl, + ): ControlAccessibilityServiceUseCase @Binds @Singleton - abstract fun bindToggleCompatibleImeUseCase(impl: ToggleCompatibleImeUseCaseImpl): ToggleCompatibleImeUseCase + abstract fun bindToggleCompatibleImeUseCase( + impl: ToggleCompatibleImeUseCaseImpl, + ): ToggleCompatibleImeUseCase @Binds @Singleton - abstract fun bindInteractUiElementUseCase(impl: InteractUiElementController): InteractUiElementUseCase + abstract fun bindInteractUiElementUseCase( + impl: InteractUiElementController, + ): InteractUiElementUseCase @Binds @Singleton - abstract fun bindShowHideInputMethodUseCase(impl: ShowHideInputMethodUseCaseImpl): ShowHideInputMethodUseCase + abstract fun bindShowHideInputMethodUseCase( + impl: ShowHideInputMethodUseCaseImpl, + ): ShowHideInputMethodUseCase @Binds @Singleton @@ -101,15 +127,15 @@ abstract class BaseSingletonHiltModule { @Binds @Singleton - abstract fun bindConfigKeyMapUseCase(impl: ConfigKeyMapUseCaseController): ConfigKeyMapUseCase + abstract fun bindRecordTriggerUseCase( + impl: RecordTriggerControllerImpl, + ): RecordTriggerController @Binds @Singleton - abstract fun bindRecordTriggerUseCase(impl: RecordTriggerController): RecordTriggerUseCase - - @Binds - @Singleton - abstract fun bindFingerprintGesturesSupportedUseCase(impl: FingerprintGesturesSupportedUseCaseImpl): FingerprintGesturesSupportedUseCase + abstract fun bindFingerprintGesturesSupportedUseCase( + impl: FingerprintGesturesSupportedUseCaseImpl, + ): FingerprintGesturesSupportedUseCase @Binds @Singleton @@ -117,11 +143,15 @@ abstract class BaseSingletonHiltModule { @Binds @Singleton - abstract fun bindGetConstraintErrorUseCase(impl: GetConstraintErrorUseCaseImpl): GetConstraintErrorUseCase + abstract fun bindGetConstraintErrorUseCase( + impl: GetConstraintErrorUseCaseImpl, + ): GetConstraintErrorUseCase @Binds @Singleton - abstract fun bindManageNotificationsUseCase(impl: ManageNotificationsUseCaseImpl): ManageNotificationsUseCase + abstract fun bindManageNotificationsUseCase( + impl: ManageNotificationsUseCaseImpl, + ): ManageNotificationsUseCase @Binds @Singleton @@ -134,4 +164,42 @@ abstract class BaseSingletonHiltModule { @Binds @Singleton abstract fun bindDialogProvider(impl: DialogProviderImpl): DialogProvider + + @Binds + @Singleton + abstract fun bindInputEventHub(impl: InputEventHubImpl): InputEventHub + + @Binds + @Singleton + abstract fun keyEventRelayServiceWrapper( + impl: KeyEventRelayServiceWrapperImpl, + ): KeyEventRelayServiceWrapper + + @Binds + @Singleton + abstract fun imeInputEvenInjector(impl: ImeInputEventInjectorImpl): ImeInputEventInjector + + @Binds + @Singleton + abstract fun bindConfigKeyMapState(impl: ConfigKeyMapStateImpl): ConfigKeyMapState + + @Binds + @Singleton + abstract fun bindGetDefaultKeyMapOptionsUseCas( + impl: GetDefaultKeyMapOptionsUseCaseImpl, + ): GetDefaultKeyMapOptionsUseCase + + @Binds + @Singleton + abstract fun bindSwitchImeInterface(impl: SwitchImeAsyncImpl): SwitchImeInterface + + @Binds + @Singleton + abstract fun bindEnableKeyMapsUseCase(impl: EnableKeyMapsUseCaseImpl): EnableKeyMapsUseCase + + @Binds + @Singleton + abstract fun bindSetupAccessibilityServiceDelegate( + impl: SetupAccessibilityServiceDelegateImpl, + ): SetupAccessibilityServiceDelegate } 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 526b59f6e7..22a957ad16 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 @@ -5,30 +5,44 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.base.actions.ConfigActionsUseCase +import io.github.sds100.keymapper.base.actions.ConfigActionsUseCaseImpl import io.github.sds100.keymapper.base.actions.CreateActionUseCase import io.github.sds100.keymapper.base.actions.CreateActionUseCaseImpl +import io.github.sds100.keymapper.base.actions.DisplayActionUseCase import io.github.sds100.keymapper.base.actions.TestActionUseCase import io.github.sds100.keymapper.base.actions.TestActionUseCaseImpl import io.github.sds100.keymapper.base.actions.keyevent.ConfigKeyEventUseCase import io.github.sds100.keymapper.base.actions.keyevent.ConfigKeyEventUseCaseImpl +import io.github.sds100.keymapper.base.actions.keyevent.FixKeyEventActionDelegate +import io.github.sds100.keymapper.base.actions.keyevent.FixKeyEventActionDelegateImpl import io.github.sds100.keymapper.base.actions.sound.ChooseSoundFileUseCase import io.github.sds100.keymapper.base.actions.sound.ChooseSoundFileUseCaseImpl import io.github.sds100.keymapper.base.backup.BackupRestoreMappingsUseCase import io.github.sds100.keymapper.base.backup.BackupRestoreMappingsUseCaseImpl +import io.github.sds100.keymapper.base.constraints.ConfigConstraintsUseCase +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.home.ListKeyMapsUseCase +import io.github.sds100.keymapper.base.home.ListKeyMapsUseCaseImpl import io.github.sds100.keymapper.base.home.ShowHomeScreenAlertsUseCase import io.github.sds100.keymapper.base.home.ShowHomeScreenAlertsUseCaseImpl -import io.github.sds100.keymapper.base.keymaps.CreateKeyMapShortcutUseCase -import io.github.sds100.keymapper.base.keymaps.CreateKeyMapShortcutUseCaseImpl import io.github.sds100.keymapper.base.keymaps.DisplayKeyMapUseCase import io.github.sds100.keymapper.base.keymaps.DisplayKeyMapUseCaseImpl -import io.github.sds100.keymapper.base.keymaps.ListKeyMapsUseCase -import io.github.sds100.keymapper.base.keymaps.ListKeyMapsUseCaseImpl import io.github.sds100.keymapper.base.logging.DisplayLogUseCase import io.github.sds100.keymapper.base.logging.DisplayLogUseCaseImpl +import io.github.sds100.keymapper.base.logging.ShareLogcatUseCase +import io.github.sds100.keymapper.base.logging.ShareLogcatUseCaseImpl +import io.github.sds100.keymapper.base.onboarding.OnboardingTipDelegate +import io.github.sds100.keymapper.base.onboarding.OnboardingTipDelegateImpl +import io.github.sds100.keymapper.base.promode.SystemBridgeSetupUseCase +import io.github.sds100.keymapper.base.promode.SystemBridgeSetupUseCaseImpl import io.github.sds100.keymapper.base.settings.ConfigSettingsUseCase import io.github.sds100.keymapper.base.settings.ConfigSettingsUseCaseImpl +import io.github.sds100.keymapper.base.shortcuts.CreateKeyMapShortcutUseCase +import io.github.sds100.keymapper.base.shortcuts.CreateKeyMapShortcutUseCaseImpl import io.github.sds100.keymapper.base.sorting.SortKeyMapsUseCase import io.github.sds100.keymapper.base.sorting.SortKeyMapsUseCaseImpl import io.github.sds100.keymapper.base.system.apps.DisplayAppShortcutsUseCase @@ -37,8 +51,12 @@ import io.github.sds100.keymapper.base.system.apps.DisplayAppsUseCase import io.github.sds100.keymapper.base.system.apps.DisplayAppsUseCaseImpl import io.github.sds100.keymapper.base.system.bluetooth.ChooseBluetoothDeviceUseCase import io.github.sds100.keymapper.base.system.bluetooth.ChooseBluetoothDeviceUseCaseImpl -import io.github.sds100.keymapper.base.trigger.SetupGuiKeyboardUseCase -import io.github.sds100.keymapper.base.trigger.SetupGuiKeyboardUseCaseImpl +import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCase +import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCaseImpl +import io.github.sds100.keymapper.base.trigger.SetupInputMethodUseCase +import io.github.sds100.keymapper.base.trigger.SetupInputMethodUseCaseImpl +import io.github.sds100.keymapper.base.trigger.TriggerSetupDelegate +import io.github.sds100.keymapper.base.trigger.TriggerSetupDelegateImpl @Module @InstallIn(ViewModelComponent::class) @@ -47,17 +65,31 @@ abstract class BaseViewModelHiltModule { @ViewModelScoped abstract fun bindDisplayKeyMapUseCase(impl: DisplayKeyMapUseCaseImpl): DisplayKeyMapUseCase + @Binds + @ViewModelScoped + abstract fun bindDisplayActionUseCase(impl: DisplayKeyMapUseCaseImpl): DisplayActionUseCase + + @Binds + @ViewModelScoped + abstract fun bindDisplayConstraintUseCase( + impl: DisplayKeyMapUseCaseImpl, + ): DisplayConstraintUseCase + @Binds @ViewModelScoped abstract fun bindListKeyMapsUseCase(impl: ListKeyMapsUseCaseImpl): ListKeyMapsUseCase @Binds @ViewModelScoped - abstract fun bindBackupRestoreMappingsUseCase(impl: BackupRestoreMappingsUseCaseImpl): BackupRestoreMappingsUseCase + abstract fun bindBackupRestoreMappingsUseCase( + impl: BackupRestoreMappingsUseCaseImpl, + ): BackupRestoreMappingsUseCase @Binds @ViewModelScoped - abstract fun bindShowHomeScreenAlertsUseCase(impl: ShowHomeScreenAlertsUseCaseImpl): ShowHomeScreenAlertsUseCase + abstract fun bindShowHomeScreenAlertsUseCase( + impl: ShowHomeScreenAlertsUseCaseImpl, + ): ShowHomeScreenAlertsUseCase @Binds @ViewModelScoped @@ -73,11 +105,15 @@ abstract class BaseViewModelHiltModule { @Binds @ViewModelScoped - abstract fun bindChooseBluetoothDeviceUseCase(impl: ChooseBluetoothDeviceUseCaseImpl): ChooseBluetoothDeviceUseCase + abstract fun bindChooseBluetoothDeviceUseCase( + impl: ChooseBluetoothDeviceUseCaseImpl, + ): ChooseBluetoothDeviceUseCase @Binds @ViewModelScoped - abstract fun bindChooseSoundFileUseCase(impl: ChooseSoundFileUseCaseImpl): ChooseSoundFileUseCase + abstract fun bindChooseSoundFileUseCase( + impl: ChooseSoundFileUseCaseImpl, + ): ChooseSoundFileUseCase @Binds @ViewModelScoped @@ -85,7 +121,9 @@ abstract class BaseViewModelHiltModule { @Binds @ViewModelScoped - abstract fun bindDisplayAppShortcutsUseCase(impl: DisplayAppShortcutsUseCaseImpl): DisplayAppShortcutsUseCase + abstract fun bindDisplayAppShortcutsUseCase( + impl: DisplayAppShortcutsUseCaseImpl, + ): DisplayAppShortcutsUseCase @Binds @ViewModelScoped @@ -97,17 +135,61 @@ abstract class BaseViewModelHiltModule { @Binds @ViewModelScoped - abstract fun bindCreateKeyMapShortcutUseCase(impl: CreateKeyMapShortcutUseCaseImpl): CreateKeyMapShortcutUseCase + abstract fun bindCreateKeyMapShortcutUseCase( + impl: CreateKeyMapShortcutUseCaseImpl, + ): CreateKeyMapShortcutUseCase @Binds @ViewModelScoped - abstract fun bindSetupGuiKeyboardUseCase(impl: SetupGuiKeyboardUseCaseImpl): SetupGuiKeyboardUseCase + abstract fun bindCreateActionUseCase(impl: CreateActionUseCaseImpl): CreateActionUseCase @Binds @ViewModelScoped - abstract fun bindCreateActionUseCase(impl: CreateActionUseCaseImpl): CreateActionUseCase + abstract fun bindCreateConstraintUseCase( + impl: CreateConstraintUseCaseImpl, + ): CreateConstraintUseCase + + @Binds + @ViewModelScoped + abstract fun bindProModeSetupUseCase( + impl: SystemBridgeSetupUseCaseImpl, + ): SystemBridgeSetupUseCase + + @Binds + @ViewModelScoped + abstract fun bindConfigConstraintsUseCase( + impl: ConfigConstraintsUseCaseImpl, + ): ConfigConstraintsUseCase + + @Binds + @ViewModelScoped + abstract fun bindConfigActionsUseCase(impl: ConfigActionsUseCaseImpl): ConfigActionsUseCase + + @Binds + @ViewModelScoped + abstract fun bindConfigTriggerUseCase(impl: ConfigTriggerUseCaseImpl): ConfigTriggerUseCase + + @Binds + @ViewModelScoped + abstract fun bindTriggerSetupDelegate(impl: TriggerSetupDelegateImpl): TriggerSetupDelegate + + @Binds + @ViewModelScoped + abstract fun bindSetupInputMethodUseCase( + impl: SetupInputMethodUseCaseImpl, + ): SetupInputMethodUseCase + + @Binds + @ViewModelScoped + abstract fun bindShareLogcatUseCase(impl: ShareLogcatUseCaseImpl): ShareLogcatUseCase + + @Binds + @ViewModelScoped + abstract fun bindOnboardingTipDelegate(impl: OnboardingTipDelegateImpl): OnboardingTipDelegate @Binds @ViewModelScoped - abstract fun bindCreateConstraintUseCase(impl: CreateConstraintUseCaseImpl): CreateConstraintUseCase + abstract fun bindFixKeyEventActionDelegate( + impl: FixKeyEventActionDelegateImpl, + ): FixKeyEventActionDelegate } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/ViewModelModule.kt b/base/src/main/java/io/github/sds100/keymapper/base/ViewModelModule.kt new file mode 100644 index 0000000000..56cb915470 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/ViewModelModule.kt @@ -0,0 +1,29 @@ +package io.github.sds100.keymapper.base + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.ViewModelLifecycle +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped +import javax.inject.Named +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel + +@Module +@InstallIn(ViewModelComponent::class) +class ViewModelModule { + + @Provides + @ViewModelScoped + @Named("viewmodel") + fun provideViewModelScope(lifecycle: ViewModelLifecycle): CoroutineScope { + val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + lifecycle.addOnClearedListener { + scope.cancel() + } + return scope + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/about/AboutFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/about/AboutFragment.kt index cabec8079b..b5a6caa040 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/about/AboutFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/about/AboutFragment.kt @@ -45,7 +45,11 @@ class AboutFragment : Fragment() { ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> val insets = - insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime()) + insets.getInsets( + WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() or + WindowInsetsCompat.Type.ime(), + ) v.updatePadding(insets.left, insets.top, insets.right, insets.bottom) WindowInsetsCompat.CONSUMED } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt index ca071be8d2..73197e3388 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt @@ -1,16 +1,16 @@ package io.github.sds100.keymapper.base.actions import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.hasFlag -import io.github.sds100.keymapper.common.utils.success import io.github.sds100.keymapper.common.utils.then import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.common.utils.withFlag import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.data.entities.EntityExtra import io.github.sds100.keymapper.data.entities.getData -import kotlinx.serialization.Serializable import java.util.UUID +import kotlinx.serialization.Serializable @Serializable data class Action( @@ -37,7 +37,7 @@ object ActionEntityMapper { val stopHoldDownWhenTriggerPressedAgain: Boolean = entity.extras.getData(ActionEntity.EXTRA_CUSTOM_HOLD_DOWN_BEHAVIOUR).then { - (it == ActionEntity.STOP_HOLD_DOWN_BEHAVIOR_TRIGGER_PRESSED_AGAIN.toString()).success() + Success(it == ActionEntity.STOP_HOLD_DOWN_BEHAVIOR_TRIGGER_PRESSED_AGAIN.toString()) }.valueOrNull() == true val repeatRate = @@ -72,7 +72,8 @@ object ActionEntityMapper { when (repeatBehaviourExtra) { ActionEntity.STOP_REPEAT_BEHAVIOUR_LIMIT_REACHED -> RepeatMode.LIMIT_REACHED - ActionEntity.STOP_REPEAT_BEHAVIOUR_TRIGGER_PRESSED_AGAIN -> RepeatMode.TRIGGER_PRESSED_AGAIN + ActionEntity.STOP_REPEAT_BEHAVIOUR_TRIGGER_PRESSED_AGAIN -> + RepeatMode.TRIGGER_PRESSED_AGAIN else -> RepeatMode.TRIGGER_RELEASED } } else { @@ -117,7 +118,9 @@ object ActionEntityMapper { add(EntityExtra(ActionEntity.EXTRA_MULTIPLIER, action.multiplier.toString())) } - if (keyMap.isHoldingDownActionBeforeRepeatingAllowed(action) && action.holdDownDuration != null) { + if (keyMap.isHoldingDownActionBeforeRepeatingAllowed(action) && + action.holdDownDuration != null + ) { add( EntityExtra( ActionEntity.EXTRA_HOLD_DOWN_DURATION, @@ -138,7 +141,8 @@ object ActionEntityMapper { add(EntityExtra(ActionEntity.EXTRA_REPEAT_LIMIT, action.repeatLimit.toString())) } - if (keyMap.isChangingRepeatModeAllowed(action) && action.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN + if (keyMap.isChangingRepeatModeAllowed(action) && + action.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN ) { add( EntityExtra( @@ -148,7 +152,9 @@ object ActionEntityMapper { ) } - if (keyMap.isChangingRepeatModeAllowed(action) && action.repeatMode == RepeatMode.LIMIT_REACHED) { + if (keyMap.isChangingRepeatModeAllowed(action) && + action.repeatMode == RepeatMode.LIMIT_REACHED + ) { add( EntityExtra( ActionEntity.EXTRA_CUSTOM_STOP_REPEAT_BEHAVIOUR, 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 3923f4d70b..35feedbca9 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 @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.actions +import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.NodeInteractionType import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.PinchScreenType @@ -19,9 +20,7 @@ sealed class ActionData : Comparable { override fun compareTo(other: ActionData) = id.compareTo(other.id) @Serializable - data class App( - val packageName: String, - ) : ActionData() { + data class App(val packageName: String) : ActionData() { override val id: ActionId = ActionId.APP override fun compareTo(other: ActionData) = when (other) { @@ -31,11 +30,8 @@ sealed class ActionData : Comparable { } @Serializable - data class AppShortcut( - val packageName: String?, - val shortcutTitle: String, - val uri: String, - ) : ActionData() { + data class AppShortcut(val packageName: String?, val shortcutTitle: String, val uri: String) : + ActionData() { override val id: ActionId = ActionId.APP_SHORTCUT override fun compareTo(other: ActionData) = when (other) { @@ -45,20 +41,13 @@ sealed class ActionData : Comparable { } @Serializable - data class InputKeyEvent( - val keyCode: Int, - val metaState: Int = 0, - val useShell: Boolean = false, - val device: Device? = null, - ) : ActionData() { + data class InputKeyEvent(val keyCode: Int, val metaState: Int = 0, val device: Device? = null) : + ActionData() { override val id: ActionId = ActionId.KEY_EVENT @Serializable - data class Device( - val descriptor: String, - val name: String, - ) + data class Device(val descriptor: String, val name: String) override fun compareTo(other: ActionData) = when (other) { is InputKeyEvent -> keyCode.compareTo(other.keyCode) @@ -71,10 +60,7 @@ sealed class ActionData : Comparable { override val id = ActionId.SOUND @Serializable - data class SoundFile( - val soundUid: String, - val soundDescription: String, - ) : Sound() { + data class SoundFile(val soundUid: String, val soundDescription: String) : Sound() { override fun compareTo(other: ActionData): Int { return when (other) { is SoundFile -> soundUid.compareTo(other.soundUid) @@ -84,9 +70,7 @@ sealed class ActionData : Comparable { } @Serializable - data class Ringtone( - val uri: String, - ) : Sound() { + data class Ringtone(val uri: String) : Sound() { override fun compareTo(other: ActionData): Int { return when (other) { is Ringtone -> uri.compareTo(other.uri) @@ -98,46 +82,36 @@ sealed class ActionData : Comparable { @Serializable sealed class Volume : ActionData() { - sealed class Stream : Volume() { - abstract val volumeStream: VolumeStream - abstract val showVolumeUi: Boolean + @Serializable + data class Up(val showVolumeUi: Boolean, val volumeStream: VolumeStream? = null) : + Volume() { + override val id = ActionId.VOLUME_UP override fun compareTo(other: ActionData) = when (other) { - is Stream -> compareValuesBy( + is Up -> compareValuesBy( this, other, - { it.id }, + { it.showVolumeUi }, { it.volumeStream }, ) - else -> super.compareTo(other) } - - @Serializable - data class Increase( - override val showVolumeUi: Boolean, - override val volumeStream: VolumeStream, - ) : Stream() { - override val id = ActionId.VOLUME_INCREASE_STREAM - } - - @Serializable - data class Decrease( - override val showVolumeUi: Boolean, - override val volumeStream: VolumeStream, - ) : Stream() { - override val id = ActionId.VOLUME_DECREASE_STREAM - } } @Serializable - data class Up(val showVolumeUi: Boolean) : Volume() { - override val id = ActionId.VOLUME_UP - } - - @Serializable - data class Down(val showVolumeUi: Boolean) : Volume() { + data class Down(val showVolumeUi: Boolean, val volumeStream: VolumeStream? = null) : + Volume() { override val id = ActionId.VOLUME_DOWN + + override fun compareTo(other: ActionData) = when (other) { + is Down -> compareValuesBy( + this, + other, + { it.showVolumeUi }, + { it.volumeStream }, + ) + else -> super.compareTo(other) + } } @Serializable @@ -156,9 +130,7 @@ sealed class ActionData : Comparable { } @Serializable - data class SetRingerMode( - val ringerMode: RingerMode, - ) : Volume() { + data class SetRingerMode(val ringerMode: RingerMode) : Volume() { override val id: ActionId = ActionId.CHANGE_RINGER_MODE override fun compareTo(other: ActionData) = when (other) { @@ -183,6 +155,24 @@ sealed class ActionData : Comparable { } } + @Serializable + sealed class Microphone : ActionData() { + @Serializable + data object Mute : Microphone() { + override val id = ActionId.MUTE_MICROPHONE + } + + @Serializable + data object Unmute : Microphone() { + override val id = ActionId.UNMUTE_MICROPHONE + } + + @Serializable + data object Toggle : Microphone() { + override val id = ActionId.TOGGLE_MUTE_MICROPHONE + } + } + @Serializable sealed class Flashlight : ActionData() { abstract val lens: CameraLens @@ -242,10 +232,7 @@ sealed class ActionData : Comparable { } @Serializable - data class SwitchKeyboard( - val imeId: String, - val savedImeName: String, - ) : ActionData() { + data class SwitchKeyboard(val imeId: String, val savedImeName: String) : ActionData() { override val id = ActionId.SWITCH_KEYBOARD override fun compareTo(other: ActionData) = when (other) { @@ -316,9 +303,7 @@ sealed class ActionData : Comparable { } @Serializable - data class CycleRotations( - val orientations: List, - ) : Rotation() { + data class CycleRotations(val orientations: List) : Rotation() { override val id = ActionId.CYCLE_ROTATIONS override fun compareTo(other: ActionData) = when (other) { @@ -468,11 +453,7 @@ sealed class ActionData : Comparable { } @Serializable - data class TapScreen( - val x: Int, - val y: Int, - val description: String?, - ) : ActionData() { + data class TapScreen(val x: Int, val y: Int, val description: String?) : ActionData() { override val id = ActionId.TAP_SCREEN override fun compareTo(other: ActionData) = when (other) { @@ -547,9 +528,7 @@ sealed class ActionData : Comparable { } @Serializable - data class PhoneCall( - val number: String, - ) : ActionData() { + data class PhoneCall(val number: String) : ActionData() { override val id = ActionId.PHONE_CALL override fun compareTo(other: ActionData) = when (other) { @@ -559,9 +538,27 @@ sealed class ActionData : Comparable { } @Serializable - data class Url( - val url: String, - ) : ActionData() { + data class SendSms(val number: String, val message: String) : ActionData() { + override val id = ActionId.SEND_SMS + + override fun compareTo(other: ActionData) = when (other) { + is SendSms -> compareValuesBy(this, other, { it.number }, { it.message }) + else -> super.compareTo(other) + } + } + + @Serializable + data class ComposeSms(val number: String, val message: String) : ActionData() { + override val id = ActionId.COMPOSE_SMS + + override fun compareTo(other: ActionData) = when (other) { + is ComposeSms -> compareValuesBy(this, other, { it.number }, { it.message }) + else -> super.compareTo(other) + } + } + + @Serializable + data class Url(val url: String) : ActionData() { override val id = ActionId.URL override fun compareTo(other: ActionData) = when (other) { @@ -571,9 +568,7 @@ sealed class ActionData : Comparable { } @Serializable - data class Text( - val text: String, - ) : ActionData() { + data class Text(val text: String) : ActionData() { override val id = ActionId.TEXT override fun compareTo(other: ActionData) = when (other) { @@ -907,6 +902,24 @@ sealed class ActionData : Comparable { } } + @Serializable + data class ShellCommand( + val description: String, + val command: String, + val executionMode: ShellExecutionMode, + val timeoutMillis: Int = 10000, + ) : ActionData() { + override val id: ActionId = ActionId.SHELL_COMMAND + + override fun toString(): String { + // Do not leak sensitive command info to logs. + return """ + ShellCommand(description=$description, executionMode=$executionMode, + timeoutMs=$timeoutMillis) + """.trimIndent() + } + } + @Serializable data class InteractUiElement( val description: String, @@ -926,4 +939,14 @@ sealed class ActionData : Comparable { ) : ActionData() { override val id: ActionId = ActionId.INTERACT_UI_ELEMENT } + + @Serializable + data object ForceStopApp : ActionData() { + override val id: ActionId = ActionId.FORCE_STOP_APP + } + + @Serializable + data object ClearRecentApp : ActionData() { + override val id: ActionId = ActionId.CLEAR_RECENT_APP + } } 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 d9c0dd80cc..b13b0757e3 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 @@ -1,6 +1,8 @@ package io.github.sds100.keymapper.base.actions +import android.util.Base64 import androidx.core.net.toUri +import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.NodeInteractionType @@ -39,12 +41,15 @@ object ActionDataEntityMapper { 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 } return when (actionId) { @@ -79,11 +84,6 @@ object ActionDataEntityMapper { entity.extras.getData(ActionEntity.EXTRA_KEY_EVENT_DEVICE_NAME) .valueOrNull() ?: "" - val useShell = - entity.extras.getData(ActionEntity.EXTRA_KEY_EVENT_USE_SHELL).then { - (it == "true").success() - }.valueOrNull() ?: false - val device = if (deviceDescriptor != null) { ActionData.InputKeyEvent.Device(deviceDescriptor, deviceName) } else { @@ -93,7 +93,6 @@ object ActionDataEntityMapper { ActionData.InputKeyEvent( keyCode = entity.data.toInt(), metaState = metaState, - useShell = useShell, device = device, ) } @@ -235,6 +234,23 @@ object ActionDataEntityMapper { ActionId.PHONE_CALL -> ActionData.PhoneCall(number = entity.data) + ActionId.SEND_SMS, ActionId.COMPOSE_SMS -> { + val message = entity.extras.getData(ActionEntity.EXTRA_SMS_MESSAGE) + .valueOrNull() ?: return null + + when (actionId) { + ActionId.SEND_SMS -> ActionData.SendSms( + number = entity.data, + message = message, + ) + ActionId.COMPOSE_SMS -> ActionData.ComposeSms( + number = entity.data, + message = message, + ) + else -> return null + } + } + ActionId.SOUND -> { val isRingtoneUri = try { entity.data.toUri().scheme != null @@ -258,7 +274,7 @@ object ActionDataEntityMapper { ActionId.VOLUME_INCREASE_STREAM, ActionId.VOLUME_DECREASE_STREAM, - -> { + -> { val stream = entity.extras.getData(ActionEntity.EXTRA_STREAM_TYPE).then { VOLUME_STREAM_MAP.getKey(it)!!.success() @@ -267,12 +283,13 @@ object ActionDataEntityMapper { val showVolumeUi = entity.flags.hasFlag(ActionEntity.ACTION_FLAG_SHOW_VOLUME_UI) + // Convert old stream actions to new volume up/down with stream parameter when (actionId) { ActionId.VOLUME_INCREASE_STREAM -> - ActionData.Volume.Stream.Increase(showVolumeUi, stream) + ActionData.Volume.Up(showVolumeUi, stream) ActionId.VOLUME_DECREASE_STREAM -> - ActionData.Volume.Stream.Decrease(showVolumeUi, stream) + ActionData.Volume.Down(showVolumeUi, stream) else -> throw Exception("don't know how to create system action for $actionId") } @@ -283,13 +300,24 @@ object ActionDataEntityMapper { ActionId.VOLUME_TOGGLE_MUTE, ActionId.VOLUME_UNMUTE, ActionId.VOLUME_MUTE, - -> { + -> { val showVolumeUi = entity.flags.hasFlag(ActionEntity.ACTION_FLAG_SHOW_VOLUME_UI) + // For VOLUME_UP and VOLUME_DOWN, optionally read the stream type + val volumeStream = if (actionId == ActionId.VOLUME_UP || + actionId == ActionId.VOLUME_DOWN + ) { + entity.extras.getData(ActionEntity.EXTRA_STREAM_TYPE).then { + VOLUME_STREAM_MAP.getKey(it)?.success() ?: null.success() + }.valueOrNull() + } else { + null + } + when (actionId) { - ActionId.VOLUME_UP -> ActionData.Volume.Up(showVolumeUi) - ActionId.VOLUME_DOWN -> ActionData.Volume.Down(showVolumeUi) + ActionId.VOLUME_UP -> ActionData.Volume.Up(showVolumeUi, volumeStream) + ActionId.VOLUME_DOWN -> ActionData.Volume.Down(showVolumeUi, volumeStream) ActionId.VOLUME_TOGGLE_MUTE -> ActionData.Volume.ToggleMute( showVolumeUi, ) @@ -301,10 +329,14 @@ object ActionDataEntityMapper { } } + ActionId.MUTE_MICROPHONE -> ActionData.Microphone.Mute + ActionId.UNMUTE_MICROPHONE -> ActionData.Microphone.Unmute + ActionId.TOGGLE_MUTE_MICROPHONE -> ActionData.Microphone.Toggle + ActionId.TOGGLE_FLASHLIGHT, ActionId.ENABLE_FLASHLIGHT, ActionId.CHANGE_FLASHLIGHT_STRENGTH, - -> { + -> { val lens = entity.extras.getData(ActionEntity.EXTRA_LENS).then { LENS_MAP.getKey(it)!!.success() }.valueOrNull() ?: return null @@ -330,7 +362,7 @@ object ActionDataEntityMapper { } ActionId.DISABLE_FLASHLIGHT, - -> { + -> { val lens = entity.extras.getData(ActionEntity.EXTRA_LENS).then { LENS_MAP.getKey(it)!!.success() }.valueOrNull() ?: return null @@ -339,7 +371,7 @@ object ActionDataEntityMapper { ActionId.TOGGLE_DND_MODE, ActionId.ENABLE_DND_MODE, - -> { + -> { val dndMode = entity.extras.getData(ActionEntity.EXTRA_DND_MODE).then { DND_MODE_MAP.getKey(it)!!.success() }.valueOrNull() ?: return null @@ -369,7 +401,7 @@ object ActionDataEntityMapper { ActionId.STOP_MEDIA_PACKAGE, ActionId.STEP_FORWARD_PACKAGE, ActionId.STEP_BACKWARD_PACKAGE, - -> { + -> { val packageName = entity.extras.getData(ActionEntity.EXTRA_PACKAGE_NAME).valueOrNull() ?: return null @@ -618,26 +650,79 @@ object ActionDataEntityMapper { val type = entity.extras.getData(ActionEntity.EXTRA_MOVE_CURSOR_TYPE).then { value -> when (value) { - 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")) + 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"), + ) } }.valueOrNull() ?: return null val direction = entity.extras.getData(ActionEntity.EXTRA_MOVE_CURSOR_DIRECTION).then { value -> when (value) { - 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")) + 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"), + ) } }.valueOrNull() ?: return null ActionData.MoveCursor(moveType = type, direction = direction) } + + ActionId.SHELL_COMMAND -> { + val useRoot = entity.flags.hasFlag(ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ROOT) + val useAdb = entity.flags.hasFlag(ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ADB) + + val executionMode = when { + useAdb -> ShellExecutionMode.ADB + useRoot -> ShellExecutionMode.ROOT + else -> ShellExecutionMode.STANDARD + } + + val description = + entity.extras.getData(ActionEntity.EXTRA_SHELL_COMMAND_DESCRIPTION) + .valueOrNull() ?: return null + + val timeoutMs = entity.extras.getData(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT) + .valueOrNull()?.toIntOrNull() ?: 10000 + + // Decode Base64 command + val command = try { + String(Base64.decode(entity.data, Base64.DEFAULT)) + } catch (e: Exception) { + return null + } + + ActionData.ShellCommand( + description = description, + command = command, + executionMode = executionMode, + timeoutMillis = timeoutMs, + ) + } + + ActionId.FORCE_STOP_APP -> ActionData.ForceStopApp + ActionId.CLEAR_RECENT_APP -> ActionData.ClearRecentApp } } @@ -654,6 +739,8 @@ object ActionDataEntityMapper { is ActionData.App -> ActionEntity.Type.APP is ActionData.AppShortcut -> ActionEntity.Type.APP_SHORTCUT is ActionData.PhoneCall -> ActionEntity.Type.PHONE_CALL + is ActionData.SendSms -> ActionEntity.Type.SEND_SMS + is ActionData.ComposeSms -> ActionEntity.Type.COMPOSE_SMS is ActionData.TapScreen -> ActionEntity.Type.TAP_COORDINATE is ActionData.SwipeScreen -> ActionEntity.Type.SWIPE_COORDINATE is ActionData.PinchScreen -> ActionEntity.Type.PINCH_COORDINATE @@ -661,6 +748,7 @@ object ActionDataEntityMapper { is ActionData.Url -> ActionEntity.Type.URL is ActionData.Sound -> ActionEntity.Type.SOUND is ActionData.InteractUiElement -> ActionEntity.Type.INTERACT_UI_ELEMENT + is ActionData.ShellCommand -> ActionEntity.Type.SHELL_COMMAND else -> ActionEntity.Type.SYSTEM_ACTION } @@ -673,8 +761,9 @@ object ActionDataEntityMapper { } private fun getFlags(data: ActionData): Int { + var flags = 0 + val showVolumeUiFlag = when (data) { - is ActionData.Volume.Stream -> data.showVolumeUi is ActionData.Volume.Up -> data.showVolumeUi is ActionData.Volume.Down -> data.showVolumeUi is ActionData.Volume.Mute -> data.showVolumeUi @@ -684,18 +773,37 @@ object ActionDataEntityMapper { } if (showVolumeUiFlag) { - return ActionEntity.ACTION_FLAG_SHOW_VOLUME_UI - } else { - return 0 + flags = flags or ActionEntity.ACTION_FLAG_SHOW_VOLUME_UI } + + if (data is ActionData.ShellCommand) { + when (data.executionMode) { + ShellExecutionMode.ROOT -> { + flags = flags or ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ROOT + } + + ShellExecutionMode.ADB -> { + flags = flags or ActionEntity.ACTION_FLAG_SHELL_COMMAND_USE_ADB + } + + ShellExecutionMode.STANDARD -> { + // No flag needed for standard mode + } + } + } + + return flags } + @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}" @@ -707,6 +815,11 @@ object ActionDataEntityMapper { } 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 + 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]!! @@ -724,15 +837,6 @@ object ActionDataEntityMapper { ) is ActionData.InputKeyEvent -> sequence { - if (data.useShell) { - val string = if (data.useShell) { - "true" - } else { - "false" - } - yield(EntityExtra(ActionEntity.EXTRA_KEY_EVENT_USE_SHELL, string)) - } - if (data.metaState != 0) { yield( EntityExtra( @@ -764,6 +868,14 @@ object ActionDataEntityMapper { is ActionData.PhoneCall -> emptyList() + is ActionData.SendSms -> listOf( + EntityExtra(ActionEntity.EXTRA_SMS_MESSAGE, data.message), + ) + + is ActionData.ComposeSms -> listOf( + EntityExtra(ActionEntity.EXTRA_SMS_MESSAGE, data.message), + ) + is ActionData.DoNotDisturb.Enable -> listOf( EntityExtra(ActionEntity.EXTRA_DND_MODE, DND_MODE_MAP[data.dndMode]!!), ) @@ -783,7 +895,9 @@ object ActionDataEntityMapper { is ActionData.Rotation.CycleRotations -> listOf( EntityExtra( ActionEntity.EXTRA_ORIENTATIONS, - data.orientations.joinToString(",") { ConstantTypeConverters.ORIENTATION_MAP[it]!! }, + data.orientations.joinToString(",") { + ConstantTypeConverters.ORIENTATION_MAP[it]!! + }, ), ) @@ -837,12 +951,31 @@ object ActionDataEntityMapper { is ActionData.Volume -> when (data) { - is ActionData.Volume.Stream -> listOf( - EntityExtra( - ActionEntity.EXTRA_STREAM_TYPE, - VOLUME_STREAM_MAP[data.volumeStream]!!, - ), - ) + is ActionData.Volume.Up -> buildList { + if (data.volumeStream != null) { + VOLUME_STREAM_MAP[data.volumeStream]?.let { streamValue -> + add( + EntityExtra( + ActionEntity.EXTRA_STREAM_TYPE, + streamValue, + ), + ) + } + } + } + + is ActionData.Volume.Down -> buildList { + if (data.volumeStream != null) { + VOLUME_STREAM_MAP[data.volumeStream]?.let { streamValue -> + add( + EntityExtra( + ActionEntity.EXTRA_STREAM_TYPE, + streamValue, + ), + ) + } + } + } else -> emptyList() } @@ -967,6 +1100,11 @@ object ActionDataEntityMapper { add(EntityExtra(ActionEntity.EXTRA_MOVE_CURSOR_DIRECTION, directionString)) } + is ActionData.ShellCommand -> listOf( + EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_DESCRIPTION, data.description), + EntityExtra(ActionEntity.EXTRA_SHELL_COMMAND_TIMEOUT, data.timeoutMillis.toString()), + ) + else -> emptyList() } @@ -1056,6 +1194,9 @@ object ActionDataEntityMapper { ActionId.VOLUME_UNMUTE to "volume_unmute", ActionId.VOLUME_MUTE to "volume_mute", ActionId.VOLUME_TOGGLE_MUTE to "volume_toggle_mute", + ActionId.MUTE_MICROPHONE to "mute_microphone", + ActionId.UNMUTE_MICROPHONE to "unmute_microphone", + ActionId.TOGGLE_MUTE_MICROPHONE to "toggle_mute_microphone", ActionId.EXPAND_NOTIFICATION_DRAWER to "expand_notification_drawer", ActionId.TOGGLE_NOTIFICATION_DRAWER to "toggle_notification_drawer", @@ -1136,5 +1277,7 @@ object ActionDataEntityMapper { ActionId.END_PHONE_CALL to "end_phone_call", ActionId.DEVICE_CONTROLS to "device_controls", ActionId.HTTP_REQUEST to "http_request", + ActionId.FORCE_STOP_APP to "force_stop_app", + ActionId.CLEAR_RECENT_APP to "clear_recent_app", ) } 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 fb2d13bde6..a20a449237 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,12 +1,22 @@ package io.github.sds100.keymapper.base.actions +import android.annotation.SuppressLint 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 import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.models.ShellExecutionMode +import io.github.sds100.keymapper.common.utils.Constants import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.onSuccess import io.github.sds100.keymapper.common.utils.valueOrNull +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.isConnected +import io.github.sds100.keymapper.sysbridge.utils.SystemBridgeError import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.camera.CameraAdapter @@ -17,18 +27,19 @@ import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter import io.github.sds100.keymapper.system.ringtones.RingtoneAdapter -import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter class LazyActionErrorSnapshot( private val packageManager: PackageManagerAdapter, private val inputMethodAdapter: InputMethodAdapter, + private val switchImeInterface: SwitchImeInterface, private val permissionAdapter: PermissionAdapter, systemFeatureAdapter: SystemFeatureAdapter, cameraAdapter: CameraAdapter, private val soundsManager: SoundsManager, - shizukuAdapter: ShizukuAdapter, private val ringtoneAdapter: RingtoneAdapter, private val buildConfigProvider: BuildConfigProvider, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val preferenceRepository: PreferenceRepository, ) : ActionErrorSnapshot, IsActionSupportedUseCase by IsActionSupportedUseCaseImpl( systemFeatureAdapter, @@ -36,12 +47,10 @@ class LazyActionErrorSnapshot( permissionAdapter, ) { private val keyMapperImeHelper = - KeyMapperImeHelper(inputMethodAdapter, buildConfigProvider.packageName) + KeyMapperImeHelper(switchImeInterface, inputMethodAdapter, buildConfigProvider.packageName) private val isCompatibleImeEnabled by lazy { keyMapperImeHelper.isCompatibleImeEnabled() } private val isCompatibleImeChosen by lazy { keyMapperImeHelper.isCompatibleImeChosen() } - private val isShizukuInstalled by lazy { shizukuAdapter.isInstalled.value } - private val isShizukuStarted by lazy { shizukuAdapter.isStarted.value } private val isVoiceAssistantInstalled by lazy { packageManager.isVoiceAssistantInstalled() } private val grantedPermissions: MutableMap = mutableMapOf() private val flashLenses by lazy { @@ -56,6 +65,22 @@ class LazyActionErrorSnapshot( } } + private val isSystemBridgeConnected: Boolean by lazy { + if (buildConfigProvider.sdkInt >= Constants.SYSTEM_BRIDGE_MIN_API) { + systemBridgeConnectionManager.isConnected() + } else { + false + } + } + + private val keyEventActionsUseSystemBridge: Boolean by lazy { + if (buildConfigProvider.sdkInt >= Constants.SYSTEM_BRIDGE_MIN_API) { + preferenceRepository.get(Keys.keyEventActionsUseSystemBridge).firstBlocking() ?: false + } else { + false + } + } + override fun getErrors(actions: List): Map { // Fixes #797 and #1719 // Store which input method would be selected if the actions run successfully. @@ -71,7 +96,14 @@ class LazyActionErrorSnapshot( var error = getError(action) - if (error == KMError.NoCompatibleImeChosen && currentImeFromActions != null) { + val isImeNotChosenError = + error == KMError.NoCompatibleImeChosen || + ( + error is KMError.KeyEventActionError && + error.baseError == KMError.NoCompatibleImeChosen + ) + + if (isImeNotChosenError && currentImeFromActions != null) { val isCurrentImeCompatible = KeyMapperImeHelper.isKeyMapperInputMethod( currentImeFromActions.packageName, @@ -96,26 +128,39 @@ class LazyActionErrorSnapshot( return isSupportedError } - if (action.canUseShizukuToPerform() && isShizukuInstalled) { - if (!(action.canUseImeToPerform() && isCompatibleImeChosen)) { - when { - !isShizukuStarted -> return KMError.ShizukuNotStarted - - !isPermissionGranted(Permission.SHIZUKU) -> return SystemError.PermissionDenied( - Permission.SHIZUKU, - ) - } + if (buildConfigProvider.sdkInt >= Constants.SYSTEM_BRIDGE_MIN_API && + action is ActionData.InputKeyEvent && + keyEventActionsUseSystemBridge + ) { + if (!isSystemBridgeConnected) { + return KMError.KeyEventActionError(SystemBridgeError.Disconnected) } } else if (action.canUseImeToPerform()) { if (!isCompatibleImeEnabled) { - return KMError.NoCompatibleImeEnabled + if (action is ActionData.InputKeyEvent) { + return KMError.KeyEventActionError(KMError.NoCompatibleImeEnabled) + } else { + return KMError.NoCompatibleImeEnabled + } } if (!isCompatibleImeChosen) { - return KMError.NoCompatibleImeChosen + if (action is ActionData.InputKeyEvent) { + return KMError.KeyEventActionError(KMError.NoCompatibleImeChosen) + } else { + return KMError.NoCompatibleImeChosen + } } } + @SuppressLint("NewApi") + if (buildConfigProvider.sdkInt >= Constants.SYSTEM_BRIDGE_MIN_API && + ActionUtils.isSystemBridgeRequired(action.id) && + !isSystemBridgeConnected + ) { + return SystemBridgeError.Disconnected + } + for (permission in ActionUtils.getRequiredPermissions(action.id)) { if (!isPermissionGranted(permission)) { return SystemError.PermissionDenied(permission) @@ -133,13 +178,6 @@ class LazyActionErrorSnapshot( return getAppError(action.packageName) } - is ActionData.InputKeyEvent -> - if ( - action.useShell && !isPermissionGranted(Permission.ROOT) - ) { - return SystemError.PermissionDenied(Permission.ROOT) - } - is ActionData.Sound.SoundFile -> { soundsManager.getSound(action.soundUid).onFailure { error -> return error @@ -171,6 +209,28 @@ class LazyActionErrorSnapshot( return it } + is ActionData.ShellCommand -> { + return when (action.executionMode) { + ShellExecutionMode.ROOT -> { + if (!isPermissionGranted(Permission.ROOT)) { + SystemError.PermissionDenied(Permission.ROOT) + } else { + null + } + } + + ShellExecutionMode.ADB -> { + if (!isSystemBridgeConnected) { + SystemBridgeError.Disconnected + } else { + null + } + } + + ShellExecutionMode.STANDARD -> null + } + } + else -> {} } 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 03798087ed..481dc59c5b 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 @@ -11,6 +11,7 @@ enum class ActionId { PINCH_SCREEN, URL, HTTP_REQUEST, + SHELL_COMMAND, INTENT, PHONE_CALL, INTERACT_UI_ELEMENT, @@ -44,7 +45,11 @@ enum class ActionId { VOLUME_UP, VOLUME_DOWN, VOLUME_SHOW_DIALOG, + + @Deprecated("Use VOLUME_DOWN with volumeStream parameter instead") VOLUME_DECREASE_STREAM, + + @Deprecated("Use VOLUME_UP with volumeStream parameter instead") VOLUME_INCREASE_STREAM, CYCLE_RINGER_MODE, CHANGE_RINGER_MODE, @@ -55,6 +60,9 @@ enum class ActionId { VOLUME_UNMUTE, VOLUME_MUTE, VOLUME_TOGGLE_MUTE, + MUTE_MICROPHONE, + UNMUTE_MICROPHONE, + TOGGLE_MUTE_MICROPHONE, EXPAND_NOTIFICATION_DRAWER, TOGGLE_NOTIFICATION_DRAWER, @@ -133,5 +141,10 @@ enum class ActionId { ANSWER_PHONE_CALL, END_PHONE_CALL, + SEND_SMS, + COMPOSE_SMS, DEVICE_CONTROLS, + + FORCE_STOP_APP, + CLEAR_RECENT_APP, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt index fac1d4ae7c..c9b11661c5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt @@ -69,6 +69,7 @@ fun ActionOptionsBottomSheet( val helpUrl = stringResource(R.string.url_keymap_action_options_guide) val scope = rememberCoroutineScope() + @Suppress("ktlint:standard:max-line-length") Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Spacer(modifier = Modifier.height(12.dp)) Box(modifier = Modifier.fillMaxWidth()) { @@ -205,7 +206,9 @@ fun ActionOptionsBottomSheet( RadioButtonText( isSelected = state.repeatMode == RepeatMode.TRIGGER_RELEASED, text = stringResource(R.string.stop_repeating_when_trigger_released), - onSelected = { callback.onSelectRepeatMode(RepeatMode.TRIGGER_RELEASED) }, + onSelected = { + callback.onSelectRepeatMode(RepeatMode.TRIGGER_RELEASED) + }, ) } @@ -213,7 +216,9 @@ fun ActionOptionsBottomSheet( RadioButtonText( isSelected = state.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN, text = stringResource(R.string.stop_repeating_trigger_pressed_again), - onSelected = { callback.onSelectRepeatMode(RepeatMode.TRIGGER_PRESSED_AGAIN) }, + onSelected = { + callback.onSelectRepeatMode(RepeatMode.TRIGGER_PRESSED_AGAIN) + }, ) } @@ -250,6 +255,8 @@ fun ActionOptionsBottomSheet( if (state.showHoldDownDuration) { Spacer(Modifier.height(8.dp)) + val holdDownDurationMin = SliderMinimums.ACTION_HOLD_DOWN_DURATION.toFloat() + val holdDownDurationMax = SliderMaximums.ACTION_HOLD_DOWN_DURATION.toFloat() SliderOptionText( modifier = Modifier .fillMaxWidth() @@ -259,7 +266,7 @@ fun ActionOptionsBottomSheet( value = state.holdDownDuration.toFloat(), valueText = { "${it.toInt()} ms" }, onValueChange = { callback.onHoldDownDurationChanged(it.toInt()) }, - valueRange = SliderMinimums.ACTION_HOLD_DOWN_DURATION.toFloat()..SliderMaximums.ACTION_HOLD_DOWN_DURATION.toFloat(), + valueRange = holdDownDurationMin..holdDownDurationMax, stepSize = SliderStepSizes.ACTION_HOLD_DOWN_DURATION, ) } @@ -282,13 +289,17 @@ fun ActionOptionsBottomSheet( RadioButtonText( isSelected = state.holdDownMode == HoldDownMode.TRIGGER_RELEASED, text = stringResource(R.string.stop_holding_down_when_trigger_released), - onSelected = { callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_RELEASED) }, + onSelected = { + callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_RELEASED) + }, ) RadioButtonText( isSelected = state.holdDownMode == HoldDownMode.TRIGGER_PRESSED_AGAIN, text = stringResource(R.string.stop_holding_down_trigger_pressed_again), - onSelected = { callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_PRESSED_AGAIN) }, + onSelected = { + callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_PRESSED_AGAIN) + }, ) Spacer(Modifier.width(8.dp)) @@ -303,6 +314,10 @@ fun ActionOptionsBottomSheet( if (state.showDelayBeforeNextAction) { Spacer(Modifier.height(8.dp)) + val delayBeforeNextActionMin = + SliderMinimums.DELAY_BEFORE_NEXT_ACTION.toFloat() + val delayBeforeNextActionMax = + SliderMaximums.DELAY_BEFORE_NEXT_ACTION.toFloat() SliderOptionText( modifier = Modifier .fillMaxWidth() @@ -312,13 +327,15 @@ fun ActionOptionsBottomSheet( value = state.delayBeforeNextAction.toFloat(), valueText = { "${it.toInt()} ms" }, onValueChange = { callback.onDelayBeforeNextActionChanged(it.toInt()) }, - valueRange = SliderMinimums.DELAY_BEFORE_NEXT_ACTION.toFloat()..SliderMaximums.DELAY_BEFORE_NEXT_ACTION.toFloat(), + valueRange = delayBeforeNextActionMin..delayBeforeNextActionMax, stepSize = SliderStepSizes.DELAY_BEFORE_NEXT_ACTION, ) } Spacer(Modifier.height(8.dp)) + val actionMultiplierMin = SliderMinimums.ACTION_MULTIPLIER.toFloat() + val actionMultiplierMax = SliderMaximums.ACTION_MULTIPLIER.toFloat() SliderOptionText( modifier = Modifier .fillMaxWidth() @@ -332,7 +349,7 @@ fun ActionOptionsBottomSheet( value = state.multiplier.toFloat(), valueText = { "${it.toInt()}x" }, onValueChange = { callback.onMultiplierChanged(it.toInt()) }, - valueRange = SliderMinimums.ACTION_MULTIPLIER.toFloat()..SliderMaximums.ACTION_MULTIPLIER.toFloat(), + valueRange = actionMultiplierMin..actionMultiplierMax, stepSize = SliderStepSizes.ACTION_MULTIPLIER, ) 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 64cd9d1d3a..7659de15ef 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 @@ -7,20 +7,21 @@ import androidx.compose.material.icons.outlined.Android import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.utils.DndModeStrings -import io.github.sds100.keymapper.base.utils.InputEventStrings +import io.github.sds100.keymapper.base.utils.KeyCodeStrings import io.github.sds100.keymapper.base.utils.RingerModeStrings import io.github.sds100.keymapper.base.utils.VolumeStreamStrings import io.github.sds100.keymapper.base.utils.ui.IconInfo import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.TintType import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.common.models.ShellExecutionMode +import io.github.sds100.keymapper.common.utils.InputDeviceUtils import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.PinchScreenType import io.github.sds100.keymapper.common.utils.handle import io.github.sds100.keymapper.common.utils.hasFlag import io.github.sds100.keymapper.common.utils.toPercentString import io.github.sds100.keymapper.system.camera.CameraLens -import io.github.sds100.keymapper.system.devices.InputDeviceUtils import io.github.sds100.keymapper.system.intents.IntentTarget class ActionUiHelper( @@ -46,46 +47,41 @@ class ActionUiHelper( } // only a key code can be inputted through the shell - if (action.useShell) { - getString(R.string.description_keyevent_through_shell, keyCodeString) - } else { - val metaStateString = buildString { - for (label in InputEventStrings.MODIFIER_LABELS.entries) { - val modifier = label.key - val labelRes = label.value - if (action.metaState.hasFlag(modifier)) { - append("${getString(labelRes)} + ") - } - } - } + val metaStateString = buildString { + for (label in KeyCodeStrings.MODIFIER_LABELS.entries) { + val modifier = label.key + val labelRes = label.value - if (action.device != null) { - val name = if (action.device.name.isBlank()) { - getString(R.string.unknown_device_name) - } else { - action.device.name + if (action.metaState.hasFlag(modifier)) { + append("${getString(labelRes)} + ") } + } + } - val nameToShow = if (showDeviceDescriptors) { - InputDeviceUtils.appendDeviceDescriptorToName( - action.device.descriptor, - name, - ) - } else { - name - } + if (action.device != null) { + val name = action.device.name.ifBlank { + getString(R.string.unknown_device_name) + } - getString( - R.string.description_keyevent_from_device, - arrayOf(metaStateString, keyCodeString, nameToShow), + val nameToShow = if (showDeviceDescriptors) { + InputDeviceUtils.appendDeviceDescriptorToName( + action.device.descriptor, + name, ) } else { - getString( - R.string.description_keyevent, - args = arrayOf(metaStateString, keyCodeString), - ) + name } + + getString( + R.string.description_keyevent_from_device, + arrayOf(metaStateString, keyCodeString, nameToShow), + ) + } else { + getString( + R.string.description_keyevent, + args = arrayOf(metaStateString, keyCodeString), + ) } } @@ -118,34 +114,21 @@ class ActionUiHelper( val string: String when (action) { - is ActionData.Volume.Stream -> { - val streamString = getString( - VolumeStreamStrings.getLabel(action.volumeStream), - ) - + is ActionData.Volume.Down -> { if (action.showVolumeUi) { hasShowVolumeUiFlag = true } - string = when (action) { - is ActionData.Volume.Stream.Decrease -> getString( + string = if (action.volumeStream != null) { + val streamString = + getString(VolumeStreamStrings.getLabel(action.volumeStream)) + getString( R.string.action_decrease_stream_formatted, streamString, ) - - is ActionData.Volume.Stream.Increase -> getString( - R.string.action_increase_stream_formatted, - streamString, - ) - } - } - - is ActionData.Volume.Down -> { - if (action.showVolumeUi) { - hasShowVolumeUiFlag = true + } else { + getString(R.string.action_volume_down) } - - string = getString(R.string.action_volume_down) } is ActionData.Volume.Mute -> { @@ -177,7 +160,16 @@ class ActionUiHelper( hasShowVolumeUiFlag = true } - string = getString(R.string.action_volume_up) + string = if (action.volumeStream != null) { + val streamString = + getString(VolumeStreamStrings.getLabel(action.volumeStream)) + getString( + R.string.action_increase_stream_formatted, + streamString, + ) + } else { + getString(R.string.action_volume_up) + } } ActionData.Volume.CycleRingerMode -> { @@ -215,32 +207,52 @@ class ActionUiHelper( getAppName(action.packageName).handle( onSuccess = { appName -> val resId = when (action) { - is ActionData.ControlMediaForApp.Play -> R.string.action_play_media_package_formatted - is ActionData.ControlMediaForApp.FastForward -> R.string.action_fast_forward_package_formatted - is ActionData.ControlMediaForApp.NextTrack -> R.string.action_next_track_package_formatted - is ActionData.ControlMediaForApp.Pause -> R.string.action_pause_media_package_formatted - is ActionData.ControlMediaForApp.PlayPause -> R.string.action_play_pause_media_package_formatted - is ActionData.ControlMediaForApp.PreviousTrack -> R.string.action_previous_track_package_formatted - is ActionData.ControlMediaForApp.Rewind -> R.string.action_rewind_package_formatted - is ActionData.ControlMediaForApp.Stop -> R.string.action_stop_media_package_formatted - is ActionData.ControlMediaForApp.StepForward -> R.string.action_step_forward_media_package_formatted - is ActionData.ControlMediaForApp.StepBackward -> R.string.action_step_backward_media_package_formatted + is ActionData.ControlMediaForApp.Play -> + R.string.action_play_media_package_formatted + is ActionData.ControlMediaForApp.FastForward -> + R.string.action_fast_forward_package_formatted + is ActionData.ControlMediaForApp.NextTrack -> + R.string.action_next_track_package_formatted + is ActionData.ControlMediaForApp.Pause -> + R.string.action_pause_media_package_formatted + is ActionData.ControlMediaForApp.PlayPause -> + R.string.action_play_pause_media_package_formatted + is ActionData.ControlMediaForApp.PreviousTrack -> + R.string.action_previous_track_package_formatted + is ActionData.ControlMediaForApp.Rewind -> + R.string.action_rewind_package_formatted + is ActionData.ControlMediaForApp.Stop -> + R.string.action_stop_media_package_formatted + is ActionData.ControlMediaForApp.StepForward -> + R.string.action_step_forward_media_package_formatted + is ActionData.ControlMediaForApp.StepBackward -> + R.string.action_step_backward_media_package_formatted } getString(resId, appName) }, onError = { val resId = when (action) { - is ActionData.ControlMediaForApp.Play -> R.string.action_play_media_package - is ActionData.ControlMediaForApp.FastForward -> R.string.action_fast_forward_package - is ActionData.ControlMediaForApp.NextTrack -> R.string.action_next_track_package - is ActionData.ControlMediaForApp.Pause -> R.string.action_pause_media_package - is ActionData.ControlMediaForApp.PlayPause -> R.string.action_play_pause_media_package - is ActionData.ControlMediaForApp.PreviousTrack -> R.string.action_previous_track_package - is ActionData.ControlMediaForApp.Rewind -> R.string.action_rewind_package - is ActionData.ControlMediaForApp.Stop -> R.string.action_stop_media_package - is ActionData.ControlMediaForApp.StepForward -> R.string.action_step_forward_media_package - is ActionData.ControlMediaForApp.StepBackward -> R.string.action_step_backward_media_package + is ActionData.ControlMediaForApp.Play -> + R.string.action_play_media_package + is ActionData.ControlMediaForApp.FastForward -> + R.string.action_fast_forward_package + is ActionData.ControlMediaForApp.NextTrack -> + R.string.action_next_track_package + is ActionData.ControlMediaForApp.Pause -> + R.string.action_pause_media_package + is ActionData.ControlMediaForApp.PlayPause -> + R.string.action_play_pause_media_package + is ActionData.ControlMediaForApp.PreviousTrack -> + R.string.action_previous_track_package + is ActionData.ControlMediaForApp.Rewind -> + R.string.action_rewind_package + is ActionData.ControlMediaForApp.Stop -> + R.string.action_stop_media_package + is ActionData.ControlMediaForApp.StepForward -> + R.string.action_step_forward_media_package + is ActionData.ControlMediaForApp.StepBackward -> + R.string.action_step_backward_media_package } getString(resId) @@ -250,7 +262,9 @@ class ActionUiHelper( is ActionData.Flashlight -> { when (action) { is ActionData.Flashlight.Toggle -> { - if (action.strengthPercent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + if (action.strengthPercent == null || + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + ) { if (action.lens == CameraLens.FRONT) { getString(R.string.action_toggle_front_flashlight_formatted) } else { @@ -261,7 +275,6 @@ class ActionUiHelper( getString( R.string.action_toggle_front_flashlight_with_strength, action.strengthPercent.toPercentString(), - ) } else { getString( @@ -273,7 +286,9 @@ class ActionUiHelper( } is ActionData.Flashlight.Enable -> { - if (action.strengthPercent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + if (action.strengthPercent == null || + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + ) { if (action.lens == CameraLens.FRONT) { getString(R.string.action_enable_front_flashlight_formatted) } else { @@ -496,21 +511,41 @@ class ActionUiHelper( when (action.direction) { ActionData.MoveCursor.Direction.START -> { when (action.moveType) { - ActionData.MoveCursor.Type.CHAR -> getString(R.string.action_move_cursor_prev_character) - ActionData.MoveCursor.Type.WORD -> getString(R.string.action_move_cursor_start_word) - ActionData.MoveCursor.Type.LINE -> getString(R.string.action_move_cursor_start_line) - ActionData.MoveCursor.Type.PARAGRAPH -> getString(R.string.action_move_cursor_start_paragraph) - ActionData.MoveCursor.Type.PAGE -> getString(R.string.action_move_cursor_start_page) + ActionData.MoveCursor.Type.CHAR -> getString( + R.string.action_move_cursor_prev_character, + ) + ActionData.MoveCursor.Type.WORD -> getString( + R.string.action_move_cursor_start_word, + ) + ActionData.MoveCursor.Type.LINE -> getString( + R.string.action_move_cursor_start_line, + ) + ActionData.MoveCursor.Type.PARAGRAPH -> getString( + R.string.action_move_cursor_start_paragraph, + ) + ActionData.MoveCursor.Type.PAGE -> getString( + R.string.action_move_cursor_start_page, + ) } } ActionData.MoveCursor.Direction.END -> { when (action.moveType) { - ActionData.MoveCursor.Type.CHAR -> getString(R.string.action_move_cursor_next_character) - ActionData.MoveCursor.Type.WORD -> getString(R.string.action_move_cursor_end_word) - ActionData.MoveCursor.Type.LINE -> getString(R.string.action_move_cursor_end_line) - ActionData.MoveCursor.Type.PARAGRAPH -> getString(R.string.action_move_cursor_end_paragraph) - ActionData.MoveCursor.Type.PAGE -> getString(R.string.action_move_cursor_end_page) + ActionData.MoveCursor.Type.CHAR -> getString( + R.string.action_move_cursor_next_character, + ) + ActionData.MoveCursor.Type.WORD -> getString( + R.string.action_move_cursor_end_word, + ) + ActionData.MoveCursor.Type.LINE -> getString( + R.string.action_move_cursor_end_line, + ) + ActionData.MoveCursor.Type.PARAGRAPH -> getString( + R.string.action_move_cursor_end_paragraph, + ) + ActionData.MoveCursor.Type.PAGE -> getString( + R.string.action_move_cursor_end_page, + ) } } } @@ -555,9 +590,13 @@ class ActionUiHelper( ActionData.ShowPowerMenu -> getString(R.string.action_show_power_menu) ActionData.StatusBar.Collapse -> getString(R.string.action_collapse_status_bar) - ActionData.StatusBar.ExpandNotifications -> getString(R.string.action_expand_notification_drawer) + ActionData.StatusBar.ExpandNotifications -> getString( + R.string.action_expand_notification_drawer, + ) ActionData.StatusBar.ExpandQuickSettings -> getString(R.string.action_expand_quick_settings) - ActionData.StatusBar.ToggleNotifications -> getString(R.string.action_toggle_notification_drawer) + ActionData.StatusBar.ToggleNotifications -> getString( + R.string.action_toggle_notification_drawer, + ) ActionData.StatusBar.ToggleQuickSettings -> getString(R.string.action_toggle_quick_settings) ActionData.ToggleKeyboard -> getString(R.string.action_toggle_keyboard) @@ -568,7 +607,9 @@ class ActionUiHelper( ActionData.Wifi.Enable -> getString(R.string.action_enable_wifi) ActionData.Wifi.Toggle -> getString(R.string.action_toggle_wifi) ActionData.DismissAllNotifications -> getString(R.string.action_dismiss_all_notifications) - ActionData.DismissLastNotification -> getString(R.string.action_dismiss_most_recent_notification) + ActionData.DismissLastNotification -> getString( + R.string.action_dismiss_most_recent_notification, + ) ActionData.AnswerCall -> getString(R.string.action_answer_call) ActionData.EndCall -> getString(R.string.action_end_call) @@ -576,7 +617,40 @@ class ActionUiHelper( ActionData.DeviceControls -> getString(R.string.action_device_controls) is ActionData.HttpRequest -> action.description + is ActionData.ShellCommand -> when (action.executionMode) { + ShellExecutionMode.ROOT -> getString( + R.string.action_shell_command_description_with_root, + action.description, + ) + + ShellExecutionMode.ADB -> getString( + R.string.action_shell_command_description_with_adb, + action.description, + ) + + ShellExecutionMode.STANDARD -> getString( + R.string.action_shell_command_description_with_standard, + action.description, + ) + } + is ActionData.InteractUiElement -> action.description + + ActionData.ClearRecentApp -> getString(R.string.action_clear_recent_app) + ActionData.ForceStopApp -> getString(R.string.action_force_stop_app) + is ActionData.ComposeSms -> getString( + R.string.action_compose_sms_description, + arrayOf(action.message, action.number), + ) + + is ActionData.SendSms -> getString( + R.string.action_send_sms_description, + arrayOf(action.message, action.number), + ) + + ActionData.Microphone.Mute -> getString(R.string.action_mute_microphone) + ActionData.Microphone.Toggle -> getString(R.string.action_toggle_mute_microphone) + ActionData.Microphone.Unmute -> getString(R.string.action_unmute_microphone) } fun getIcon(action: ActionData): ComposeIconInfo = when (action) { @@ -692,7 +766,9 @@ class ActionUiHelper( } RepeatMode.TRIGGER_PRESSED_AGAIN -> { - append(getString(R.string.flag_repeat_build_description_until_pressed_again)) + append( + getString(R.string.flag_repeat_build_description_until_pressed_again), + ) } else -> Unit 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 903c562c44..0902ef74db 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 @@ -3,9 +3,11 @@ package io.github.sds100.keymapper.base.actions import android.content.pm.PackageManager import android.os.Build import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.Message import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.automirrored.outlined.ShortText import androidx.compose.material.icons.automirrored.outlined.Undo @@ -23,6 +25,7 @@ import androidx.compose.material.icons.outlined.CallEnd import androidx.compose.material.icons.outlined.CameraAlt import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.ClearAll +import androidx.compose.material.icons.outlined.Dangerous import androidx.compose.material.icons.outlined.DataObject import androidx.compose.material.icons.outlined.DoNotDisturb import androidx.compose.material.icons.outlined.DoNotDisturbOff @@ -38,6 +41,8 @@ import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.material.icons.outlined.KeyboardHide import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Mic +import androidx.compose.material.icons.outlined.MicOff import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Nfc import androidx.compose.material.icons.outlined.NotStarted @@ -60,6 +65,7 @@ import androidx.compose.material.icons.outlined.StayCurrentPortrait import androidx.compose.material.icons.outlined.StopCircle import androidx.compose.material.icons.outlined.Swipe import androidx.compose.material.icons.outlined.TouchApp +import androidx.compose.material.icons.outlined.VerticalSplit import androidx.compose.material.icons.outlined.ViewArray import androidx.compose.material.icons.rounded.Abc import androidx.compose.material.icons.rounded.Android @@ -68,10 +74,10 @@ import androidx.compose.material.icons.rounded.BluetoothDisabled import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.ContentCut import androidx.compose.material.icons.rounded.ContentPaste +import androidx.compose.material.icons.rounded.Terminal import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material.icons.rounded.WifiOff import androidx.compose.ui.graphics.vector.ImageVector -import io.github.sds100.keymapper.base.Constants import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.utils.ui.compose.icons.HomeIotDevice import io.github.sds100.keymapper.base.utils.ui.compose.icons.InstantMix @@ -82,10 +88,13 @@ import io.github.sds100.keymapper.base.utils.ui.compose.icons.NfcOff import io.github.sds100.keymapper.base.utils.ui.compose.icons.TextSelectEnd import io.github.sds100.keymapper.base.utils.ui.compose.icons.TopPanelClose import io.github.sds100.keymapper.base.utils.ui.compose.icons.TopPanelOpen +import io.github.sds100.keymapper.common.utils.Constants import io.github.sds100.keymapper.system.permissions.Permission object ActionUtils { + val isSystemBridgeSupported = Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API + @StringRes fun getCategoryLabel(category: ActionCategory): Int = when (category) { ActionCategory.NAVIGATION -> R.string.action_cat_navigation @@ -120,6 +129,7 @@ object ActionUtils { ActionId.INTENT -> ActionCategory.APPS ActionId.URL -> ActionCategory.APPS ActionId.HTTP_REQUEST -> ActionCategory.APPS + ActionId.SHELL_COMMAND -> ActionCategory.APPS ActionId.TOGGLE_WIFI -> ActionCategory.CONNECTIVITY ActionId.ENABLE_WIFI -> ActionCategory.CONNECTIVITY @@ -162,6 +172,9 @@ object ActionUtils { ActionId.VOLUME_UNMUTE -> ActionCategory.VOLUME ActionId.VOLUME_MUTE -> ActionCategory.VOLUME ActionId.VOLUME_TOGGLE_MUTE -> ActionCategory.VOLUME + ActionId.MUTE_MICROPHONE -> ActionCategory.VOLUME + ActionId.UNMUTE_MICROPHONE -> ActionCategory.VOLUME + ActionId.TOGGLE_MUTE_MICROPHONE -> ActionCategory.VOLUME ActionId.EXPAND_NOTIFICATION_DRAWER -> ActionCategory.NAVIGATION ActionId.TOGGLE_NOTIFICATION_DRAWER -> ActionCategory.NAVIGATION @@ -230,12 +243,16 @@ object ActionUtils { ActionId.PHONE_CALL -> ActionCategory.TELEPHONY ActionId.ANSWER_PHONE_CALL -> ActionCategory.TELEPHONY ActionId.END_PHONE_CALL -> ActionCategory.TELEPHONY + ActionId.SEND_SMS -> ActionCategory.TELEPHONY + ActionId.COMPOSE_SMS -> ActionCategory.TELEPHONY ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> ActionCategory.NOTIFICATIONS ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionCategory.NOTIFICATIONS ActionId.DEVICE_CONTROLS -> ActionCategory.APPS ActionId.INTERACT_UI_ELEMENT -> ActionCategory.APPS + ActionId.FORCE_STOP_APP -> ActionCategory.APPS + ActionId.CLEAR_RECENT_APP -> ActionCategory.APPS ActionId.CONSUME_KEY_EVENT -> ActionCategory.SPECIAL } @@ -277,6 +294,9 @@ object ActionUtils { ActionId.VOLUME_UNMUTE -> R.string.action_volume_unmute ActionId.VOLUME_MUTE -> R.string.action_volume_mute ActionId.VOLUME_TOGGLE_MUTE -> R.string.action_toggle_mute + ActionId.MUTE_MICROPHONE -> R.string.action_mute_microphone + ActionId.UNMUTE_MICROPHONE -> R.string.action_unmute_microphone + ActionId.TOGGLE_MUTE_MICROPHONE -> R.string.action_toggle_mute_microphone ActionId.EXPAND_NOTIFICATION_DRAWER -> R.string.action_expand_notification_drawer ActionId.TOGGLE_NOTIFICATION_DRAWER -> R.string.action_toggle_notification_drawer ActionId.EXPAND_QUICK_SETTINGS -> R.string.action_expand_quick_settings @@ -350,13 +370,19 @@ object ActionUtils { ActionId.INTENT -> R.string.action_send_intent ActionId.PHONE_CALL -> R.string.action_phone_call ActionId.SOUND -> R.string.action_play_sound - ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> R.string.action_dismiss_most_recent_notification + ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> + R.string.action_dismiss_most_recent_notification ActionId.DISMISS_ALL_NOTIFICATIONS -> R.string.action_dismiss_all_notifications ActionId.ANSWER_PHONE_CALL -> R.string.action_answer_call ActionId.END_PHONE_CALL -> R.string.action_end_call + ActionId.SEND_SMS -> R.string.action_send_sms + ActionId.COMPOSE_SMS -> R.string.action_compose_sms ActionId.DEVICE_CONTROLS -> R.string.action_device_controls ActionId.HTTP_REQUEST -> R.string.action_http_request + ActionId.SHELL_COMMAND -> R.string.action_shell_command ActionId.INTERACT_UI_ELEMENT -> R.string.action_interact_ui_element_title + ActionId.FORCE_STOP_APP -> R.string.action_force_stop_app + ActionId.CLEAR_RECENT_APP -> R.string.action_clear_recent_app } @DrawableRes @@ -396,6 +422,9 @@ object ActionUtils { ActionId.VOLUME_UNMUTE -> R.drawable.ic_outline_volume_up_24 ActionId.VOLUME_MUTE -> R.drawable.ic_outline_volume_mute_24 ActionId.VOLUME_TOGGLE_MUTE -> R.drawable.ic_outline_volume_mute_24 + ActionId.MUTE_MICROPHONE -> null + ActionId.UNMUTE_MICROPHONE -> null + ActionId.TOGGLE_MUTE_MICROPHONE -> null ActionId.EXPAND_NOTIFICATION_DRAWER -> null ActionId.TOGGLE_NOTIFICATION_DRAWER -> null ActionId.EXPAND_QUICK_SETTINGS -> null @@ -473,9 +502,10 @@ object ActionUtils { ActionId.DISMISS_ALL_NOTIFICATIONS -> R.drawable.ic_baseline_clear_all_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 + ActionId.COMPOSE_SMS -> R.drawable.ic_outline_message_24 ActionId.DEVICE_CONTROLS -> R.drawable.ic_home_automation - ActionId.HTTP_REQUEST -> null - ActionId.INTERACT_UI_ELEMENT -> null + else -> null } fun getMinApi(id: ActionId): Int = when (id) { @@ -489,29 +519,32 @@ object ActionUtils { ActionId.VOLUME_MUTE, ActionId.VOLUME_UNMUTE, ActionId.VOLUME_TOGGLE_MUTE, + ActionId.MUTE_MICROPHONE, + ActionId.UNMUTE_MICROPHONE, + ActionId.TOGGLE_MUTE_MICROPHONE, ActionId.TOGGLE_DND_MODE, ActionId.ENABLE_DND_MODE, ActionId.DISABLE_DND_MODE, - -> Build.VERSION_CODES.M + -> Build.VERSION_CODES.M ActionId.DISABLE_FLASHLIGHT, ActionId.ENABLE_FLASHLIGHT, ActionId.TOGGLE_FLASHLIGHT, - -> Build.VERSION_CODES.M + -> Build.VERSION_CODES.M ActionId.CHANGE_FLASHLIGHT_STRENGTH, - -> Build.VERSION_CODES.TIRAMISU + -> Build.VERSION_CODES.TIRAMISU ActionId.TOGGLE_KEYBOARD, ActionId.SHOW_KEYBOARD, ActionId.HIDE_KEYBOARD, - -> Build.VERSION_CODES.N + -> Build.VERSION_CODES.N ActionId.TEXT_CUT, ActionId.TEXT_COPY, ActionId.TEXT_PASTE, ActionId.SELECT_WORD_AT_CURSOR, - -> Build.VERSION_CODES.JELLY_BEAN_MR2 + -> Build.VERSION_CODES.JELLY_BEAN_MR2 ActionId.SHOW_POWER_MENU -> Build.VERSION_CODES.LOLLIPOP ActionId.DEVICE_CONTROLS -> Build.VERSION_CODES.S @@ -525,11 +558,6 @@ object ActionUtils { // The global action still fails even though the API exists in SDK 34. ActionId.COLLAPSE_STATUS_BAR -> Build.VERSION_CODES.TIRAMISU - ActionId.ENABLE_BLUETOOTH, - ActionId.DISABLE_BLUETOOTH, - ActionId.TOGGLE_BLUETOOTH, - -> Build.VERSION_CODES.S_V2 - // See https://issuetracker.google.com/issues/225186417. The global action // is not marked as deprecated even though it doesn't work. ActionId.TOGGLE_SPLIT_SCREEN -> Build.VERSION_CODES.S @@ -541,48 +569,84 @@ object ActionUtils { ActionId.END_PHONE_CALL, ActionId.ANSWER_PHONE_CALL, ActionId.PHONE_CALL, - -> listOf(PackageManager.FEATURE_TELEPHONY) + ActionId.SEND_SMS, + ActionId.COMPOSE_SMS, + ActionId.TOGGLE_MOBILE_DATA, + ActionId.ENABLE_MOBILE_DATA, + ActionId.DISABLE_MOBILE_DATA, + // For some reason on API 34 emulator the system says it does not have + // FEATURE_TELEPHONY_SMS even tho SMS works. So to prevent false negatives + // check that the generic TELEPHONY feature exists. + -> listOf(PackageManager.FEATURE_TELEPHONY) ActionId.SECURE_LOCK_DEVICE, - -> listOf(PackageManager.FEATURE_DEVICE_ADMIN) - - ActionId.TOGGLE_WIFI, - ActionId.ENABLE_WIFI, - ActionId.DISABLE_WIFI, - -> listOf(PackageManager.FEATURE_WIFI) + -> listOf(PackageManager.FEATURE_DEVICE_ADMIN) ActionId.TOGGLE_NFC, ActionId.ENABLE_NFC, ActionId.DISABLE_NFC, - -> listOf(PackageManager.FEATURE_NFC) + -> listOf(PackageManager.FEATURE_NFC) ActionId.TOGGLE_BLUETOOTH, ActionId.ENABLE_BLUETOOTH, ActionId.DISABLE_BLUETOOTH, - -> listOf(PackageManager.FEATURE_BLUETOOTH) + -> listOf(PackageManager.FEATURE_BLUETOOTH) ActionId.TOGGLE_FLASHLIGHT, ActionId.ENABLE_FLASHLIGHT, ActionId.DISABLE_FLASHLIGHT, ActionId.CHANGE_FLASHLIGHT_STRENGTH, - -> listOf(PackageManager.FEATURE_CAMERA_FLASH) + -> listOf(PackageManager.FEATURE_CAMERA_FLASH) else -> emptyList() } - fun getRequiredPermissions(id: ActionId): List { - when (id) { - ActionId.TOGGLE_WIFI, + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) + fun isSystemBridgeRequired(id: ActionId): Boolean { + return when (id) { ActionId.ENABLE_WIFI, ActionId.DISABLE_WIFI, - -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return listOf(Permission.ROOT) - } + ActionId.TOGGLE_WIFI, + -> true ActionId.TOGGLE_MOBILE_DATA, ActionId.ENABLE_MOBILE_DATA, ActionId.DISABLE_MOBILE_DATA, - -> return listOf(Permission.ROOT) + -> true + + ActionId.ENABLE_NFC, + ActionId.DISABLE_NFC, + ActionId.TOGGLE_NFC, + -> true + + ActionId.TOGGLE_AIRPLANE_MODE, + ActionId.ENABLE_AIRPLANE_MODE, + ActionId.DISABLE_AIRPLANE_MODE, + -> true + + ActionId.TOGGLE_BLUETOOTH, + ActionId.ENABLE_BLUETOOTH, + ActionId.DISABLE_BLUETOOTH, + -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2 + + ActionId.POWER_ON_OFF_DEVICE -> true + + ActionId.FORCE_STOP_APP, ActionId.CLEAR_RECENT_APP -> true + + else -> false + } + } + + fun getRequiredPermissions(id: ActionId): List { + when (id) { + ActionId.TOGGLE_MOBILE_DATA, + ActionId.ENABLE_MOBILE_DATA, + ActionId.DISABLE_MOBILE_DATA, + -> return if (isSystemBridgeSupported) { + emptyList() + } else { + listOf(Permission.ROOT) + } ActionId.PLAY_PAUSE_MEDIA_PACKAGE, ActionId.PAUSE_MEDIA_PACKAGE, @@ -591,7 +655,7 @@ object ActionUtils { ActionId.PREVIOUS_TRACK_PACKAGE, ActionId.FAST_FORWARD_PACKAGE, ActionId.REWIND_PACKAGE, - -> return listOf(Permission.NOTIFICATION_LISTENER) + -> return listOf(Permission.NOTIFICATION_LISTENER) ActionId.VOLUME_UP, ActionId.VOLUME_DOWN, @@ -604,10 +668,13 @@ object ActionUtils { ActionId.VOLUME_MUTE, ActionId.VOLUME_UNMUTE, ActionId.VOLUME_TOGGLE_MUTE, + ActionId.MUTE_MICROPHONE, + ActionId.UNMUTE_MICROPHONE, + ActionId.TOGGLE_MUTE_MICROPHONE, ActionId.TOGGLE_DND_MODE, ActionId.DISABLE_DND_MODE, ActionId.ENABLE_DND_MODE, - -> return listOf(Permission.ACCESS_NOTIFICATION_POLICY) + -> return listOf(Permission.ACCESS_NOTIFICATION_POLICY) ActionId.TOGGLE_AUTO_ROTATE, ActionId.ENABLE_AUTO_ROTATE, @@ -616,25 +683,29 @@ object ActionUtils { ActionId.LANDSCAPE_MODE, ActionId.SWITCH_ORIENTATION, ActionId.CYCLE_ROTATIONS, - -> return listOf(Permission.WRITE_SETTINGS) + -> return listOf(Permission.WRITE_SETTINGS) ActionId.TOGGLE_AUTO_BRIGHTNESS, ActionId.ENABLE_AUTO_BRIGHTNESS, ActionId.DISABLE_AUTO_BRIGHTNESS, ActionId.INCREASE_BRIGHTNESS, ActionId.DECREASE_BRIGHTNESS, - -> return listOf(Permission.WRITE_SETTINGS) + -> return listOf(Permission.WRITE_SETTINGS) ActionId.TOGGLE_FLASHLIGHT, ActionId.ENABLE_FLASHLIGHT, ActionId.DISABLE_FLASHLIGHT, ActionId.CHANGE_FLASHLIGHT_STRENGTH, - -> return listOf(Permission.CAMERA) + -> return listOf(Permission.CAMERA) ActionId.ENABLE_NFC, ActionId.DISABLE_NFC, ActionId.TOGGLE_NFC, - -> return listOf(Permission.ROOT) + -> return if (isSystemBridgeSupported) { + emptyList() + } else { + listOf(Permission.ROOT) + } ActionId.SHOW_KEYBOARD_PICKER -> if (Build.VERSION.SDK_INT in Build.VERSION_CODES.O_MR1..Build.VERSION_CODES.P) { @@ -648,7 +719,11 @@ object ActionUtils { ActionId.TOGGLE_AIRPLANE_MODE, ActionId.ENABLE_AIRPLANE_MODE, ActionId.DISABLE_AIRPLANE_MODE, - -> return listOf(Permission.ROOT) + -> return if (isSystemBridgeSupported) { + emptyList() + } else { + listOf(Permission.ROOT) + } ActionId.SCREENSHOT -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { return listOf(Permission.ROOT) @@ -659,20 +734,29 @@ object ActionUtils { } ActionId.SECURE_LOCK_DEVICE -> return listOf(Permission.DEVICE_ADMIN) - ActionId.POWER_ON_OFF_DEVICE -> return listOf(Permission.ROOT) + ActionId.POWER_ON_OFF_DEVICE -> return if (isSystemBridgeSupported) { + emptyList() + } else { + listOf(Permission.ROOT) + } ActionId.DISMISS_ALL_NOTIFICATIONS, ActionId.DISMISS_MOST_RECENT_NOTIFICATION, - -> return listOf(Permission.NOTIFICATION_LISTENER) + -> return listOf(Permission.NOTIFICATION_LISTENER) ActionId.ANSWER_PHONE_CALL, ActionId.END_PHONE_CALL, - -> return listOf(Permission.ANSWER_PHONE_CALL) + -> return listOf(Permission.ANSWER_PHONE_CALL) ActionId.PHONE_CALL -> return listOf(Permission.CALL_PHONE) + ActionId.SEND_SMS, + ActionId.COMPOSE_SMS, + -> return listOf(Permission.SEND_SMS) + ActionId.ENABLE_BLUETOOTH, ActionId.DISABLE_BLUETOOTH, ActionId.TOGGLE_BLUETOOTH -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // On S_V2 and newer, the system bridge is used which means no permissions are required + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.S) { return listOf(Permission.FIND_NEARBY_DEVICES) } @@ -718,6 +802,9 @@ object ActionUtils { ActionId.VOLUME_UNMUTE -> Icons.AutoMirrored.Outlined.VolumeUp ActionId.VOLUME_MUTE -> Icons.AutoMirrored.Outlined.VolumeMute ActionId.VOLUME_TOGGLE_MUTE -> Icons.AutoMirrored.Outlined.VolumeMute + ActionId.MUTE_MICROPHONE -> Icons.Outlined.MicOff + ActionId.UNMUTE_MICROPHONE -> Icons.Outlined.Mic + ActionId.TOGGLE_MUTE_MICROPHONE -> Icons.Outlined.MicOff ActionId.EXPAND_NOTIFICATION_DRAWER -> KeyMapperIcons.TopPanelOpen ActionId.TOGGLE_NOTIFICATION_DRAWER -> KeyMapperIcons.TopPanelClose ActionId.EXPAND_QUICK_SETTINGS -> KeyMapperIcons.TopPanelOpen @@ -790,6 +877,8 @@ object ActionUtils { ActionId.URL -> Icons.Outlined.Link ActionId.INTENT -> Icons.Outlined.DataObject ActionId.PHONE_CALL -> Icons.Outlined.Call + ActionId.SEND_SMS -> Icons.AutoMirrored.Outlined.Message + ActionId.COMPOSE_SMS -> Icons.AutoMirrored.Outlined.Message ActionId.SOUND -> Icons.AutoMirrored.Outlined.VolumeUp ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> Icons.Outlined.ClearAll ActionId.DISMISS_ALL_NOTIFICATIONS -> Icons.Outlined.ClearAll @@ -797,24 +886,21 @@ object ActionUtils { ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice ActionId.HTTP_REQUEST -> Icons.Outlined.Http + ActionId.SHELL_COMMAND -> Icons.Rounded.Terminal ActionId.INTERACT_UI_ELEMENT -> KeyMapperIcons.JumpToElement + ActionId.FORCE_STOP_APP -> Icons.Outlined.Dangerous + ActionId.CLEAR_RECENT_APP -> Icons.Outlined.VerticalSplit } } fun ActionData.canBeHeldDown(): Boolean = when (this) { - is ActionData.InputKeyEvent -> !useShell - is ActionData.TapScreen -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + is ActionData.InputKeyEvent -> true else -> false } fun ActionData.canUseImeToPerform(): Boolean = when (this) { - is ActionData.InputKeyEvent -> !useShell - is ActionData.Text -> true - else -> false -} - -fun ActionData.canUseShizukuToPerform(): Boolean = when (this) { is ActionData.InputKeyEvent -> true + is ActionData.Text -> true else -> false } @@ -831,8 +917,6 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.Volume.Mute, is ActionData.Volume.UnMute, is ActionData.Volume.ToggleMute, - is ActionData.Volume.Stream.Increase, - is ActionData.Volume.Stream.Decrease, is ActionData.Volume.SetRingerMode, is ActionData.DoNotDisturb.Enable, is ActionData.DoNotDisturb.Toggle, @@ -846,10 +930,13 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.Text, is ActionData.Url, is ActionData.PhoneCall, + is ActionData.SendSms, + is ActionData.ComposeSms, is ActionData.HttpRequest, + is ActionData.ShellCommand, is ActionData.InteractUiElement, is ActionData.MoveCursor, - -> true + -> true else -> false } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt index 14eadb9da1..55c61681ab 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt @@ -38,9 +38,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.keyevent.FixKeyEventActionBottomSheet import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.keymaps.ShortcutModel import io.github.sds100.keymapper.base.keymaps.ShortcutRow +import io.github.sds100.keymapper.base.onboarding.OnboardingTipModel +import io.github.sds100.keymapper.base.onboarding.TipCard import io.github.sds100.keymapper.base.utils.ui.LinkType import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.compose.DraggableItem @@ -55,6 +58,7 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val state by viewModel.state.collectAsStateWithLifecycle() val optionsState by viewModel.actionOptionsState.collectAsStateWithLifecycle() + val actionTipModel by viewModel.actionsTip.collectAsStateWithLifecycle() if (optionsState != null) { ActionOptionsBottomSheet( @@ -66,13 +70,31 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod ) } - EnableFlashlightActionBottomSheet(viewModel.createActionDelegate) - ChangeFlashlightStrengthActionBottomSheet(viewModel.createActionDelegate) - HttpRequestBottomSheet(viewModel.createActionDelegate) + val fixKeyEventActionState by viewModel.fixKeyEventActionState.collectAsStateWithLifecycle() + + if (fixKeyEventActionState != null) { + FixKeyEventActionBottomSheet( + modifier = Modifier.systemBarsPadding(), + state = fixKeyEventActionState!!, + sheetState = sheetState, + onDismissRequest = viewModel::dismissFixKeyEventActionBottomSheet, + onEnableAccessibilityServiceClick = viewModel::onEnableAccessibilityServiceClick, + onEnableProModeClick = viewModel::onEnableProModeForKeyEventActionsClick, + onEnableInputMethodClick = viewModel::onEnableImeClick, + onChooseInputMethodClick = viewModel::onChooseImeClick, + onDoneClick = viewModel::dismissFixKeyEventActionBottomSheet, + onSelectProMode = viewModel::onSelectProMode, + onSelectInputMethod = viewModel::onSelectInputMethod, + onAutoSwitchImeCheckedChange = viewModel::onAutoSwitchImeCheckedChange, + ) + } + + HandleActionBottomSheets(viewModel.createActionDelegate) ActionsScreen( modifier = modifier, state = state, + tipModel = actionTipModel, onRemoveClick = viewModel::onRemoveClick, onEditClick = viewModel::onEditClick, onMoveAction = viewModel::onMoveAction, @@ -80,6 +102,8 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod onClickShortcut = viewModel::onClickShortcut, onTestClick = viewModel::onTestClick, onAddClick = viewModel::onAddActionClick, + onActionTipDismiss = viewModel::onActionTipDismissClick, + onTipButtonClick = viewModel::onTipButtonClick, ) } @@ -87,6 +111,7 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod private fun ActionsScreen( modifier: Modifier = Modifier, state: State, + tipModel: OnboardingTipModel? = null, onAddClick: () -> Unit = {}, onRemoveClick: (String) -> Unit = {}, onEditClick: (String) -> Unit = {}, @@ -94,6 +119,8 @@ private fun ActionsScreen( onFixErrorClick: (String) -> Unit = {}, onTestClick: (String) -> Unit = {}, onClickShortcut: (ActionData) -> Unit = {}, + onActionTipDismiss: () -> Unit = {}, + onTipButtonClick: (String) -> Unit = {}, ) { var showDeleteDialog by rememberSaveable { mutableStateOf(false) } var actionToDelete by rememberSaveable { mutableStateOf(null) } @@ -125,6 +152,25 @@ private fun ActionsScreen( State.Loading -> Loading() is State.Data -> Surface(modifier = modifier) { Column { + Spacer(Modifier.height(8.dp)) + + // Display action tip if available + tipModel?.let { tip -> + TipCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = tip.title, + message = tip.message, + isDismissable = tip.isDismissable, + onDismiss = onActionTipDismiss, + buttonText = tip.buttonText, + onButtonClick = { onTipButtonClick(tip.id) }, + ) + + Spacer(Modifier.height(8.dp)) + } + when (val data = state.data) { is ConfigActionsState.Empty -> { Column( @@ -160,8 +206,6 @@ private fun ActionsScreen( } is ConfigActionsState.Loaded -> { - Spacer(Modifier.height(8.dp)) - if (data.actions.isNotEmpty()) { Spacer(Modifier.height(8.dp)) @@ -314,7 +358,7 @@ private fun EmptyPreview() { ), ShortcutModel( icon = ComposeIconInfo.Vector(Icons.Rounded.Pinch), - text = "Pinch in with 2 finger(s) on coordinates 5/4 with a pinch distance of 8px in 200ms", + text = "Pinch in with 2 finger(s) on coordinates 5/4", data = ActionData.ConsumeKeyEvent, ), ), @@ -361,7 +405,7 @@ private fun LoadedPreview() { ), ShortcutModel( icon = ComposeIconInfo.Vector(Icons.Rounded.Pinch), - text = "Pinch in with 2 finger(s) on coordinates 5/4 with a pinch distance of 8px in 200ms", + text = "Pinch in with 2 finger(s) on coordinates 5/4", data = ActionData.ConsumeKeyEvent, ), ), 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 fce1f2f42c..c3365db29f 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 @@ -2,7 +2,6 @@ package io.github.sds100.keymapper.base.actions import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding @@ -20,10 +19,12 @@ import androidx.compose.material.icons.rounded.Bluetooth import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.BottomAppBar import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -49,16 +50,20 @@ import io.github.sds100.keymapper.common.utils.State import kotlinx.coroutines.flow.update @Composable -fun ChooseActionScreen( - modifier: Modifier = Modifier, - viewModel: ChooseActionViewModel, -) { +fun HandleActionBottomSheets(delegate: CreateActionDelegate) { + EnableFlashlightActionBottomSheet(delegate) + ChangeFlashlightStrengthActionBottomSheet(delegate) + HttpRequestBottomSheet(delegate) + SmsActionBottomSheet(delegate) + VolumeActionBottomSheet(delegate) +} + +@Composable +fun ChooseActionScreen(modifier: Modifier = Modifier, viewModel: ChooseActionViewModel) { val state by viewModel.groups.collectAsStateWithLifecycle() val query by viewModel.searchQuery.collectAsStateWithLifecycle() - EnableFlashlightActionBottomSheet(viewModel.createActionDelegate) - ChangeFlashlightStrengthActionBottomSheet(viewModel.createActionDelegate) - HttpRequestBottomSheet(viewModel.createActionDelegate) + HandleActionBottomSheets(viewModel.createActionDelegate) ChooseActionScreen( modifier = modifier, @@ -71,6 +76,7 @@ fun ChooseActionScreen( ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChooseActionScreen( modifier: Modifier = Modifier, @@ -83,6 +89,11 @@ private fun ChooseActionScreen( ) { Scaffold( modifier = modifier.displayCutoutPadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.choose_action_title)) }, + ) + }, bottomBar = { BottomAppBar( modifier = Modifier.imePadding(), @@ -113,33 +124,20 @@ private fun ChooseActionScreen( ), ) { - Column { - Text( - modifier = Modifier.padding( - start = 16.dp, - end = 16.dp, - top = 16.dp, - bottom = 8.dp, - ), - text = stringResource(R.string.choose_action_title), - style = MaterialTheme.typography.titleLarge, - ) - - when (state) { - State.Loading -> LoadingScreen(modifier = Modifier.fillMaxSize()) + when (state) { + State.Loading -> LoadingScreen(modifier = Modifier.fillMaxSize()) - is State.Data -> { - if (state.data.isEmpty()) { - EmptyScreen( - modifier = Modifier.fillMaxSize(), - ) - } else { - ListScreen( - modifier = Modifier.fillMaxSize(), - groups = state.data, - onClickAction = onClickAction, - ) - } + is State.Data -> { + if (state.data.isEmpty()) { + EmptyScreen( + modifier = Modifier.fillMaxSize(), + ) + } else { + ListScreen( + modifier = Modifier.fillMaxSize(), + groups = state.data, + onClickAction = onClickAction, + ) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionViewModel.kt index 5961652602..a49fb86f53 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionViewModel.kt @@ -18,6 +18,7 @@ import io.github.sds100.keymapper.base.utils.ui.showDialog import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.permissions.Permission +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -28,7 +29,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import javax.inject.Inject @HiltViewModel class ChooseActionViewModel @Inject constructor( @@ -132,9 +132,7 @@ class ChooseActionViewModel @Inject constructor( add(unsupportedGroup) } - private fun buildListItems( - actionIds: List, - ): List = buildList { + private fun buildListItems(actionIds: List): List = buildList { for (actionId in actionIds) { // See Issue #1593. This action should no longer exist because it is a relic // of the past when most apps had a 3-dot menu with a consistent content description @@ -151,7 +149,9 @@ class ChooseActionViewModel @Inject constructor( val icon = ActionUtils.getComposeIcon(actionId) val subtitle = when { - error == SystemError.PermissionDenied(Permission.ROOT) -> getString(R.string.choose_action_warning_requires_root) + error == SystemError.PermissionDenied( + Permission.ROOT, + ) -> getString(R.string.choose_action_warning_requires_root) error != null -> error.getFullMessage(this@ChooseActionViewModel) else -> null } @@ -180,7 +180,9 @@ class ChooseActionViewModel @Inject constructor( DialogModel.Alert( message = getString(R.string.action_open_app_dialog_message), title = getString(R.string.action_open_app_dialog_title), - positiveButtonText = getString(R.string.action_open_app_dialog_read_more_button), + positiveButtonText = getString( + R.string.action_open_app_dialog_read_more_button, + ), negativeButtonText = getString(R.string.action_open_app_dialog_ignore_button), ), ) @@ -199,19 +201,18 @@ class ChooseActionViewModel @Inject constructor( val messageToShow: Int? = when (id) { ActionId.FAST_FORWARD_PACKAGE, ActionId.FAST_FORWARD, - -> R.string.action_fast_forward_message + -> R.string.action_fast_forward_message ActionId.REWIND_PACKAGE, ActionId.REWIND, - -> R.string.action_rewind_message + -> R.string.action_rewind_message ActionId.TOGGLE_KEYBOARD, ActionId.SHOW_KEYBOARD, ActionId.HIDE_KEYBOARD, - -> R.string.action_toggle_keyboard_message + -> R.string.action_toggle_keyboard_message ActionId.SECURE_LOCK_DEVICE -> R.string.action_secure_lock_device_message - ActionId.POWER_ON_OFF_DEVICE -> R.string.action_power_on_off_device_message else -> null } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt new file mode 100644 index 0000000000..96aee5ec15 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt @@ -0,0 +1,283 @@ +package io.github.sds100.keymapper.base.actions + +import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.base.constraints.ConfigConstraintsUseCase +import io.github.sds100.keymapper.base.constraints.ConstraintData +import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState +import io.github.sds100.keymapper.base.keymaps.GetDefaultKeyMapOptionsUseCase +import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey +import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.moveElement +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.system.inputevents.KeyEventUtils +import java.util.LinkedList +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +@ViewModelScoped +class ConfigActionsUseCaseImpl @Inject constructor( + private val state: ConfigKeyMapState, + private val preferenceRepository: PreferenceRepository, + private val configConstraints: ConfigConstraintsUseCase, + defaultKeyMapOptionsUseCase: GetDefaultKeyMapOptionsUseCase, +) : ConfigActionsUseCase, + GetDefaultKeyMapOptionsUseCase by defaultKeyMapOptionsUseCase { + + override val keyMap: StateFlow> = state.keyMap + + /** + * The most recently used is first. + */ + override val recentlyUsedActions: Flow> = + preferenceRepository.get(Keys.recentlyUsedActions) + .map(::getActionShortcuts) + .map { it.take(5) } + + override fun addAction(data: ActionData) { + state.update { keyMap -> + val newActionList = keyMap.actionList.toMutableList().apply { + add(createAction(keyMap, data)) + } + + preferenceRepository.update( + Keys.recentlyUsedActions, + { old -> + val oldList: List = if (old == null) { + emptyList() + } else { + Json.decodeFromString>(old) + } + + val newShortcuts = LinkedList(oldList) + .also { it.addFirst(data) } + .distinct() + + Json.encodeToString(newShortcuts) + }, + ) + + keyMap.copy(actionList = newActionList) + } + } + + override fun moveAction(fromIndex: Int, toIndex: Int) { + updateActionList { actionList -> + actionList.toMutableList().apply { + moveElement(fromIndex, toIndex) + } + } + } + + override fun removeAction(uid: String) { + updateActionList { actionList -> + actionList.toMutableList().apply { + removeAll { it.uid == uid } + } + } + } + + override fun setActionData(uid: String, data: ActionData) { + updateActionList { actionList -> + actionList.map { action -> + if (action.uid == uid) { + action.copy(data = data) + } else { + action + } + } + } + } + + override fun setActionRepeatEnabled(uid: String, repeat: Boolean) { + setActionOption(uid) { action -> action.copy(repeat = repeat) } + } + + override fun setActionRepeatRate(uid: String, repeatRate: Int) { + setActionOption(uid) { action -> + if (repeatRate == defaultRepeatRate.value) { + action.copy(repeatRate = null) + } else { + action.copy(repeatRate = repeatRate) + } + } + } + + override fun setActionRepeatDelay(uid: String, repeatDelay: Int) { + setActionOption(uid) { action -> + if (repeatDelay == defaultRepeatDelay.value) { + action.copy(repeatDelay = null) + } else { + action.copy(repeatDelay = repeatDelay) + } + } + } + + override fun setActionRepeatLimit(uid: String, repeatLimit: Int) { + setActionOption(uid) { action -> + if (action.repeatMode == RepeatMode.LIMIT_REACHED) { + if (repeatLimit == 1) { + action.copy(repeatLimit = null) + } else { + action.copy(repeatLimit = repeatLimit) + } + } else { + if (repeatLimit == Int.MAX_VALUE) { + action.copy(repeatLimit = null) + } else { + action.copy(repeatLimit = repeatLimit) + } + } + } + } + + override fun setActionHoldDownEnabled(uid: String, holdDown: Boolean) = setActionOption(uid) { + it.copy(holdDown = holdDown) + } + + override fun setActionHoldDownDuration(uid: String, holdDownDuration: Int) { + setActionOption(uid) { action -> + if (holdDownDuration == defaultHoldDownDuration.value) { + action.copy(holdDownDuration = null) + } else { + action.copy(holdDownDuration = holdDownDuration) + } + } + } + + override fun setActionStopRepeatingWhenTriggerPressedAgain(uid: String) = + setActionOption(uid) { it.copy(repeatMode = RepeatMode.TRIGGER_PRESSED_AGAIN) } + + override fun setActionStopRepeatingWhenLimitReached(uid: String) = + setActionOption(uid) { it.copy(repeatMode = RepeatMode.LIMIT_REACHED) } + + override fun setActionStopRepeatingWhenTriggerReleased(uid: String) = + setActionOption(uid) { it.copy(repeatMode = RepeatMode.TRIGGER_RELEASED) } + + override fun setActionStopHoldingDownWhenTriggerPressedAgain(uid: String, enabled: Boolean) = + setActionOption(uid) { it.copy(stopHoldDownWhenTriggerPressedAgain = enabled) } + + override fun setActionMultiplier(uid: String, multiplier: Int) { + setActionOption(uid) { action -> + if (multiplier == 1) { + action.copy(multiplier = null) + } else { + action.copy(multiplier = multiplier) + } + } + } + + override fun setDelayBeforeNextAction(uid: String, delay: Int) { + setActionOption(uid) { action -> + if (delay == 0) { + action.copy(delayBeforeNextAction = null) + } else { + action.copy(delayBeforeNextAction = delay) + } + } + } + + private suspend fun getActionShortcuts(json: String?): List { + if (json == null) { + return emptyList() + } + + try { + return withContext(Dispatchers.Default) { + val list = Json.decodeFromString>(json) + + list.distinct() + } + } catch (_: Exception) { + preferenceRepository.set(Keys.recentlyUsedActions, null) + return emptyList() + } + } + + private fun createAction(keyMap: KeyMap, data: ActionData): Action { + var holdDown = false + var repeat = false + + if (data is ActionData.InputKeyEvent) { + val containsDpadKey: Boolean = + keyMap.trigger.keys + .mapNotNull { it as? KeyEventTriggerKey } + .any { KeyEventUtils.isDpadKeyCode(it.keyCode) } + + if (KeyEventUtils.isModifierKey(data.keyCode) || containsDpadKey) { + holdDown = true + repeat = false + } else { + repeat = true + } + } + + if (data is ActionData.Volume.Down || data is ActionData.Volume.Up) { + repeat = true + } + + if (data is ActionData.AnswerCall) { + configConstraints.addConstraint(ConstraintData.PhoneRinging) + } + + if (data is ActionData.EndCall) { + configConstraints.addConstraint(ConstraintData.InPhoneCall) + } + + return Action( + data = data, + repeat = repeat, + holdDown = holdDown, + ) + } + + private fun updateActionList(block: (actionList: List) -> List) { + state.update { it.copy(actionList = block(it.actionList)) } + } + + private fun setActionOption(uid: String, block: (action: Action) -> Action) { + state.update { keyMap -> + val newActionList = keyMap.actionList.map { action -> + if (action.uid == uid) { + block.invoke(action) + } else { + action + } + } + + keyMap.copy( + actionList = newActionList, + ) + } + } +} + +interface ConfigActionsUseCase : GetDefaultKeyMapOptionsUseCase { + val keyMap: StateFlow> + + fun addAction(data: ActionData) + fun moveAction(fromIndex: Int, toIndex: Int) + fun removeAction(uid: String) + + val recentlyUsedActions: Flow> + fun setActionData(uid: String, data: ActionData) + fun setActionMultiplier(uid: String, multiplier: Int) + fun setDelayBeforeNextAction(uid: String, delay: Int) + fun setActionRepeatRate(uid: String, repeatRate: Int) + fun setActionRepeatLimit(uid: String, repeatLimit: Int) + fun setActionStopRepeatingWhenTriggerPressedAgain(uid: String) + fun setActionStopRepeatingWhenLimitReached(uid: String) + fun setActionRepeatEnabled(uid: String, repeat: Boolean) + fun setActionRepeatDelay(uid: String, repeatDelay: Int) + fun setActionHoldDownEnabled(uid: String, holdDown: Boolean) + fun setActionHoldDownDuration(uid: String, holdDownDuration: Int) + fun setActionStopRepeatingWhenTriggerReleased(uid: String) + + fun setActionStopHoldingDownWhenTriggerPressedAgain(uid: String, enabled: Boolean) +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt index 0e1a4ca5d2..80d3c6547f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt @@ -1,24 +1,27 @@ package io.github.sds100.keymapper.base.actions +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapUseCase +import io.github.sds100.keymapper.base.actions.keyevent.FixKeyEventActionDelegate import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.keymaps.ShortcutModel +import io.github.sds100.keymapper.base.onboarding.OnboardingTapTarget +import io.github.sds100.keymapper.base.onboarding.OnboardingTipDelegate import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.isFixable import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.navigate -import io.github.sds100.keymapper.base.utils.ui.ChooseAppStoreModel -import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider -import io.github.sds100.keymapper.base.utils.ui.DialogResponse import io.github.sds100.keymapper.base.utils.ui.LinkType import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.ViewModelHelper import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo -import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.common.utils.AccessibilityServiceError import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.dataOrNull @@ -26,7 +29,7 @@ import io.github.sds100.keymapper.common.utils.mapData import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.permissions.Permission -import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -40,23 +43,30 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class ConfigActionsViewModel( - private val coroutineScope: CoroutineScope, +@HiltViewModel +class ConfigActionsViewModel @Inject constructor( private val displayAction: DisplayActionUseCase, private val createAction: CreateActionUseCase, private val testAction: TestActionUseCase, - private val config: ConfigKeyMapUseCase, - private val onboarding: OnboardingUseCase, + private val config: ConfigActionsUseCase, + private val onboardingUseCase: OnboardingUseCase, + setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, + fixKeyEventActionDelegate: FixKeyEventActionDelegate, + onboardingTipDelegate: OnboardingTipDelegate, resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, -) : ActionOptionsBottomSheetCallback, +) : ViewModel(), + ActionOptionsBottomSheetCallback, + SetupAccessibilityServiceDelegate by setupAccessibilityServiceDelegate, ResourceProvider by resourceProvider, DialogProvider by dialogProvider, - NavigationProvider by navigationProvider { + NavigationProvider by navigationProvider, + FixKeyEventActionDelegate by fixKeyEventActionDelegate, + OnboardingTipDelegate by onboardingTipDelegate { val createActionDelegate = - CreateActionDelegate(coroutineScope, createAction, this, this, this) + CreateActionDelegate(viewModelScope, createAction, this, this, this) private val uiHelper = ActionUiHelper(displayAction, resourceProvider) private val _state = MutableStateFlow>(State.Loading) @@ -65,15 +75,17 @@ class ConfigActionsViewModel( private val shortcuts: StateFlow>> = config.recentlyUsedActions.map { actions -> actions.map(::buildShortcut).toSet() - }.stateIn(coroutineScope, SharingStarted.Lazily, emptySet()) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptySet()) val actionOptionsUid = MutableStateFlow(null) val actionOptionsState: StateFlow = combine(config.keyMap, actionOptionsUid, transform = ::buildOptionsState) - .stateIn(coroutineScope, SharingStarted.Lazily, null) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + + private var editedActionUid: String? = null private val actionErrorSnapshot: StateFlow = - displayAction.actionErrorSnapshot.stateIn(coroutineScope, SharingStarted.Lazily, null) + displayAction.actionErrorSnapshot.stateIn(viewModelScope, SharingStarted.Lazily, null) init { combine( @@ -85,11 +97,11 @@ class ConfigActionsViewModel( _state.value = keyMapState.mapData { keyMap -> buildState(keyMap, shortcuts, errorSnapshot, showDeviceDescriptors) } - }.launchIn(coroutineScope) + }.launchIn(viewModelScope) - coroutineScope.launch { + viewModelScope.launch { createActionDelegate.actionResult.filterNotNull().collect { action -> - val actionUid = actionOptionsUid.value ?: return@collect + val actionUid = editedActionUid ?: return@collect config.setActionData(actionUid, action) actionOptionsUid.update { null } } @@ -101,54 +113,56 @@ class ConfigActionsViewModel( } fun onClickShortcut(action: ActionData) { - coroutineScope.launch { + viewModelScope.launch { config.addAction(action) } } fun onFixError(actionUid: String) { - coroutineScope.launch { + viewModelScope.launch { val actionData = getActionData(actionUid) ?: return@launch val error = actionErrorSnapshot.filterNotNull().first().getError(actionData) ?: return@launch - if (error == SystemError.PermissionDenied(Permission.ACCESS_NOTIFICATION_POLICY)) { - coroutineScope.launch { - ViewModelHelper.showDialogExplainingDndAccessBeingUnavailable( + when (error) { + SystemError.PermissionDenied(Permission.ACCESS_NOTIFICATION_POLICY) -> { + viewModelScope.launch { + ViewModelHelper.showDialogExplainingDndAccessBeingUnavailable( + resourceProvider = this@ConfigActionsViewModel, + dialogProvider = this@ConfigActionsViewModel, + neverShowDndTriggerErrorAgain = { + displayAction.neverShowDndTriggerError() + }, + fixError = { displayAction.fixError(error) }, + ) + } + } + + is KMError.KeyEventActionError -> { + showFixKeyEventActionBottomSheet() + } + + else -> { + ViewModelHelper.showFixErrorDialog( resourceProvider = this@ConfigActionsViewModel, dialogProvider = this@ConfigActionsViewModel, - neverShowDndTriggerErrorAgain = { displayAction.neverShowDndTriggerError() }, - fixError = { displayAction.fixError(error) }, - ) - } - } else { - ViewModelHelper.showFixErrorDialog( - resourceProvider = this@ConfigActionsViewModel, - dialogProvider = this@ConfigActionsViewModel, - error, - ) { - displayAction.fixError(error) + error, + ) { + displayAction.fixError(error) + } } } } } fun onAddActionClick() { - coroutineScope.launch { + viewModelScope.launch { val actionData = navigate("add_action", NavDestination.ChooseAction) ?: return@launch - val showInstallShizukuPrompt = onboarding.showInstallShizukuPrompt(actionData) - val showInstallGuiKeyboardPrompt = - onboarding.showInstallGuiKeyboardPrompt(actionData) - - when { - showInstallShizukuPrompt && showInstallGuiKeyboardPrompt -> - promptToInstallShizukuOrGuiKeyboard() - - showInstallGuiKeyboardPrompt -> promptToInstallGuiKeyboard() - } - config.addAction(actionData) + + // Never show the tap target to add an action again. + onboardingUseCase.completedTapTarget(OnboardingTapTarget.CHOOSE_ACTION) } } @@ -165,7 +179,7 @@ class ConfigActionsViewModel( } fun onTestClick(actionUid: String) { - coroutineScope.launch { + viewModelScope.launch { val actionData = getActionData(actionUid) ?: return@launch attemptTestAction(actionData) } @@ -173,7 +187,11 @@ class ConfigActionsViewModel( override fun onEditClick() { val actionUid = actionOptionsUid.value ?: return - coroutineScope.launch { + viewModelScope.launch { + // Clear the bottom sheet so navigating back with predicted-back works + actionOptionsUid.update { null } + editedActionUid = actionUid + val keyMap = config.keyMap.first().dataOrNull() ?: return@launch val oldAction = keyMap.actionList.find { it.uid == actionUid } ?: return@launch @@ -183,11 +201,13 @@ class ConfigActionsViewModel( override fun onReplaceClick() { val actionUid = actionOptionsUid.value ?: return - coroutineScope.launch { + viewModelScope.launch { + // Clear the bottom sheet so navigating back with predicted-back works + actionOptionsUid.update { null } + val newActionData = navigate("replace_action", NavDestination.ChooseAction) ?: return@launch - actionOptionsUid.update { null } config.setActionData(actionUid, newActionData) } } @@ -241,168 +261,16 @@ class ConfigActionsViewModel( ) RepeatMode.LIMIT_REACHED -> config.setActionStopRepeatingWhenLimitReached(uid) - RepeatMode.TRIGGER_PRESSED_AGAIN -> config.setActionStopRepeatingWhenTriggerPressedAgain( - uid, - ) + RepeatMode.TRIGGER_PRESSED_AGAIN -> + config.setActionStopRepeatingWhenTriggerPressedAgain(uid) } } } private suspend fun attemptTestAction(actionData: ActionData) { testAction.invoke(actionData).onFailure { error -> - - if (error is KMError.AccessibilityServiceDisabled) { - ViewModelHelper.handleAccessibilityServiceStoppedDialog( - resourceProvider = this, - dialogProvider = this, - startService = displayAction::startAccessibilityService, - ) - } - - if (error is KMError.AccessibilityServiceCrashed) { - ViewModelHelper.handleAccessibilityServiceCrashedDialog( - resourceProvider = this, - dialogProvider = this, - restartService = displayAction::restartAccessibilityService, - ) - } - } - } - - private suspend fun promptToInstallGuiKeyboard() { - if (onboarding.isTvDevice()) { - val appStoreModel = ChooseAppStoreModel( - githubLink = getString(R.string.url_github_keymapper_leanback_keyboard), - ) - - val dialog = DialogModel.ChooseAppStore( - title = getString(R.string.dialog_title_install_leanback_keyboard), - message = getString(R.string.dialog_message_install_leanback_keyboard), - appStoreModel, - positiveButtonText = getString(R.string.pos_never_show_again), - negativeButtonText = getString(R.string.neg_cancel), - ) - - val response = showDialog("download_leanback_ime", dialog) ?: return - - if (response == DialogResponse.POSITIVE) { - onboarding.neverShowGuiKeyboardPromptsAgain() - } - } else { - val appStoreModel = ChooseAppStoreModel( - playStoreLink = getString(R.string.url_play_store_keymapper_gui_keyboard), - fdroidLink = getString(R.string.url_fdroid_keymapper_gui_keyboard), - githubLink = getString(R.string.url_github_keymapper_gui_keyboard), - ) - - val dialog = DialogModel.ChooseAppStore( - title = getString(R.string.dialog_title_install_gui_keyboard), - message = getString(R.string.dialog_message_install_gui_keyboard), - appStoreModel, - positiveButtonText = getString(R.string.pos_never_show_again), - negativeButtonText = getString(R.string.neg_cancel), - ) - - val response = showDialog("download_gui_keyboard", dialog) ?: return - - if (response == DialogResponse.POSITIVE) { - onboarding.neverShowGuiKeyboardPromptsAgain() - } - } - } - - private suspend fun promptToInstallShizukuOrGuiKeyboard() { - if (onboarding.isTvDevice()) { - val chooseSolutionDialog = DialogModel.Alert( - title = getText(R.string.dialog_title_install_shizuku_or_leanback_keyboard), - message = getText(R.string.dialog_message_install_shizuku_or_leanback_keyboard), - positiveButtonText = getString(R.string.dialog_button_install_shizuku), - negativeButtonText = getString(R.string.dialog_button_install_leanback_keyboard), - neutralButtonText = getString(R.string.dialog_button_install_nothing), - ) - - val chooseSolutionResponse = - showDialog("choose_solution", chooseSolutionDialog) ?: return - - when (chooseSolutionResponse) { - // install shizuku - DialogResponse.POSITIVE -> { - navigate("shizuku", NavDestination.ShizukuSettings) - onboarding.neverShowGuiKeyboardPromptsAgain() - - return - } - // do nothing - DialogResponse.NEUTRAL -> { - onboarding.neverShowGuiKeyboardPromptsAgain() - return - } - - // download leanback keyboard - DialogResponse.NEGATIVE -> { - val chooseAppStoreDialog = DialogModel.ChooseAppStore( - title = getString(R.string.dialog_title_choose_download_leanback_keyboard), - message = getString(R.string.dialog_message_choose_download_leanback_keyboard), - model = ChooseAppStoreModel( - githubLink = getString(R.string.url_github_keymapper_leanback_keyboard), - ), - positiveButtonText = getString(R.string.pos_never_show_again), - negativeButtonText = getString(R.string.neg_cancel), - ) - - val response = showDialog("install_leanback_keyboard", chooseAppStoreDialog) - - if (response == DialogResponse.POSITIVE) { - onboarding.neverShowGuiKeyboardPromptsAgain() - } - } - } - } else { - val chooseSolutionDialog = DialogModel.Alert( - title = getText(R.string.dialog_title_install_shizuku_or_gui_keyboard), - message = getText(R.string.dialog_message_install_shizuku_or_gui_keyboard), - positiveButtonText = getString(R.string.dialog_button_install_shizuku), - negativeButtonText = getString(R.string.dialog_button_install_gui_keyboard), - neutralButtonText = getString(R.string.dialog_button_install_nothing), - ) - - val chooseSolutionResponse = - showDialog("choose_solution", chooseSolutionDialog) ?: return - - when (chooseSolutionResponse) { - // install shizuku - DialogResponse.POSITIVE -> { - navigate("shizuku_error", NavDestination.ShizukuSettings) - onboarding.neverShowGuiKeyboardPromptsAgain() - - return - } - // do nothing - DialogResponse.NEUTRAL -> { - onboarding.neverShowGuiKeyboardPromptsAgain() - return - } - - // download gui keyboard - DialogResponse.NEGATIVE -> { - val chooseAppStoreDialog = DialogModel.ChooseAppStore( - title = getString(R.string.dialog_title_choose_download_gui_keyboard), - message = getString(R.string.dialog_message_choose_download_gui_keyboard), - model = ChooseAppStoreModel( - playStoreLink = getString(R.string.url_play_store_keymapper_gui_keyboard), - fdroidLink = getString(R.string.url_fdroid_keymapper_gui_keyboard), - githubLink = getString(R.string.url_github_keymapper_gui_keyboard), - ), - positiveButtonText = getString(R.string.pos_never_show_again), - negativeButtonText = getString(R.string.neg_cancel), - ) - - val response = showDialog("install_gui_keyboard", chooseAppStoreDialog) - - if (response == DialogResponse.POSITIVE) { - onboarding.neverShowGuiKeyboardPromptsAgain() - } - } + if (error is AccessibilityServiceError) { + showFixAccessibilityServiceDialog(error) } } } @@ -466,7 +334,9 @@ class ConfigActionsViewModel( } action.delayBeforeNextAction.apply { - if (keyMap.isDelayBeforeNextActionAllowed() && action.delayBeforeNextAction != null) { + if (keyMap.isDelayBeforeNextActionAllowed() && + action.delayBeforeNextAction != null + ) { if (this@buildString.isNotBlank()) { append(" $midDot ") } @@ -573,9 +443,8 @@ class ConfigActionsViewModel( } sealed class ConfigActionsState { - data class Empty( - val shortcuts: Set> = emptySet(), - ) : ConfigActionsState() + data class Empty(val shortcuts: Set> = emptySet()) : + ConfigActionsState() data class Loaded( val actions: List = emptyList(), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt new file mode 100644 index 0000000000..954e188961 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt @@ -0,0 +1,174 @@ +package io.github.sds100.keymapper.base.actions + +import android.os.Build +import android.util.Base64 +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.ProModeStatus +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.common.models.ShellExecutionMode +import io.github.sds100.keymapper.common.models.isExecuting +import io.github.sds100.keymapper.common.utils.Constants +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.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +@HiltViewModel +class ConfigShellCommandViewModel @Inject constructor( + private val executeShellCommandUseCase: ExecuteShellCommandUseCase, + private val navigationProvider: NavigationProvider, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val preferenceRepository: PreferenceRepository, +) : ViewModel() { + + var state: ShellCommandActionState by mutableStateOf(ShellCommandActionState()) + private set + + private var testJob: Job? = null + + init { + // Update ProModeStatus in state + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + viewModelScope.launch { + systemBridgeConnectionManager.connectionState.map { connectionState -> + when (connectionState) { + is SystemBridgeConnectionState.Connected -> ProModeStatus.ENABLED + is SystemBridgeConnectionState.Disconnected -> ProModeStatus.DISABLED + } + }.collect { proModeStatus -> + state = state.copy(proModeStatus = proModeStatus) + } + } + } + + // Load saved script text + loadScriptText() + } + + fun loadAction(action: ActionData.ShellCommand) { + state = state.copy( + description = action.description, + command = action.command, + executionMode = action.executionMode, + timeoutSeconds = action.timeoutMillis / 1000, + ) + } + + fun onDescriptionChanged(newDescription: String) { + state = state.copy(description = newDescription) + } + + fun onCommandChanged(newCommand: String) { + state = state.copy(command = newCommand) + saveScriptText(newCommand) + } + + fun onExecutionModeChanged(newExecutionMode: ShellExecutionMode) { + state = state.copy(executionMode = newExecutionMode) + } + + fun onTimeoutChanged(newTimeoutSeconds: Int) { + state = state.copy(timeoutSeconds = newTimeoutSeconds) + } + + fun onTestClick() { + testJob?.cancel() + + state = state.copy( + isRunning = true, + testResult = null, + ) + + testJob = viewModelScope.launch { + testCommand() + } + } + + private suspend fun testCommand() { + executeShellCommandUseCase.executeWithStreamingOutput( + command = state.command, + executionMode = state.executionMode, + timeoutMillis = state.timeoutSeconds * 1000L, + ).collect { result -> + val isRunning = result.handle( + onSuccess = { it.isExecuting() }, + onError = { false }, + ) + + state = state.copy(isRunning = isRunning, testResult = result) + } + } + + fun onKillClick() { + testJob?.cancel() + state = state.copy( + isRunning = false, + ) + } + + fun onDoneClick() { + val action = ActionData.ShellCommand( + description = state.description, + command = state.command, + executionMode = state.executionMode, + timeoutMillis = state.timeoutSeconds * 1000, + ) + + // Save script text before navigating away + saveScriptText(state.command) + + viewModelScope.launch { + navigationProvider.popBackStackWithResult(Json.encodeToString(action)) + } + } + + fun onCancelClick() { + // Save script text before navigating away + saveScriptText(state.command) + + viewModelScope.launch { + navigationProvider.popBackStack() + } + } + + fun onSetupProModeClick() { + viewModelScope.launch { + navigationProvider.navigate("shell_command_setup_pro_mode", NavDestination.ProModeSetup) + } + } + + private fun saveScriptText(scriptText: String) { + viewModelScope.launch { + val encodedText = Base64.encodeToString(scriptText.toByteArray(), Base64.DEFAULT).trim() + preferenceRepository.set(Keys.shellCommandScriptText, encodedText) + } + } + + private fun loadScriptText() { + viewModelScope.launch { + preferenceRepository.get(Keys.shellCommandScriptText).collect { savedScriptText -> + if (savedScriptText != null && state.command.isEmpty()) { + try { + val decodedText = String(Base64.decode(savedScriptText, Base64.DEFAULT)) + state = state.copy(command = decodedText) + } catch (e: Exception) { + // If decoding fails, ignore the saved text + } + } + } + } + } +} 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 b25b3ce758..ad6ed0c4f3 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 @@ -11,7 +11,6 @@ import io.github.sds100.keymapper.base.actions.tapscreen.PickCoordinateResult import io.github.sds100.keymapper.base.system.intents.ConfigIntentResult import io.github.sds100.keymapper.base.utils.DndModeStrings import io.github.sds100.keymapper.base.utils.RingerModeStrings -import io.github.sds100.keymapper.base.utils.VolumeStreamStrings 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 @@ -21,6 +20,8 @@ import io.github.sds100.keymapper.base.utils.ui.MultiChoiceItem import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog import io.github.sds100.keymapper.common.utils.Orientation +import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.network.HttpMethod import io.github.sds100.keymapper.system.volume.DndMode @@ -51,6 +52,8 @@ class CreateActionDelegate( ) var httpRequestBottomSheetState: ActionData.HttpRequest? by mutableStateOf(null) + var smsActionBottomSheetState: SmsActionBottomSheetState? by mutableStateOf(null) + var volumeActionState: VolumeActionBottomSheetState? by mutableStateOf(null) init { coroutineScope.launch { @@ -138,6 +141,61 @@ class CreateActionDelegate( } } + fun onDoneSmsClick() { + val state = smsActionBottomSheetState ?: return + + val action = when (state) { + is SmsActionBottomSheetState.ComposeSms -> ActionData.ComposeSms( + state.number, + state.message, + ) + + is SmsActionBottomSheetState.SendSms -> ActionData.SendSms( + state.number, + state.message, + ) + } + + smsActionBottomSheetState = null + actionResult.update { action } + } + + fun onDoneConfigVolumeClick() { + volumeActionState?.also { state -> + val action = when (state.actionId) { + ActionId.VOLUME_UP -> ActionData.Volume.Up( + showVolumeUi = state.showVolumeUi, + volumeStream = state.volumeStream, + ) + ActionId.VOLUME_DOWN -> ActionData.Volume.Down( + showVolumeUi = state.showVolumeUi, + volumeStream = state.volumeStream, + ) + else -> return + } + + volumeActionState = null + actionResult.update { action } + } + } + + fun onTestSmsClick() { + coroutineScope.launch { + (smsActionBottomSheetState as? SmsActionBottomSheetState.SendSms)?.also { state -> + smsActionBottomSheetState = state.copy(testResult = State.Loading) + + val result = useCase.testSms(state.number, state.message) + + if (result is SystemError.PermissionDenied) { + useCase.requestPermission(result.permission) + smsActionBottomSheetState = state.copy(testResult = null) + } else { + smsActionBottomSheetState = state.copy(testResult = State.Data(result)) + } + } + } + } + suspend fun editAction(oldData: ActionData) { if (!oldData.isEditable()) { throw IllegalArgumentException("This action ${oldData.javaClass.name} can't be edited!") @@ -179,7 +237,7 @@ class CreateActionDelegate( ActionId.STOP_MEDIA_PACKAGE, ActionId.STEP_FORWARD_PACKAGE, ActionId.STEP_BACKWARD_PACKAGE, - -> { + -> { val packageName = navigate( "choose_app_for_media_action", @@ -224,17 +282,33 @@ class CreateActionDelegate( return action } - ActionId.VOLUME_UP, - ActionId.VOLUME_DOWN, + ActionId.VOLUME_UP -> { + val oldVolumeUpData = oldData as? ActionData.Volume.Up + volumeActionState = VolumeActionBottomSheetState( + actionId = ActionId.VOLUME_UP, + volumeStream = oldVolumeUpData?.volumeStream, + showVolumeUi = oldVolumeUpData?.showVolumeUi ?: false, + ) + return null + } + + ActionId.VOLUME_DOWN -> { + val oldVolumeDownData = oldData as? ActionData.Volume.Down + volumeActionState = VolumeActionBottomSheetState( + actionId = ActionId.VOLUME_DOWN, + volumeStream = oldVolumeDownData?.volumeStream, + showVolumeUi = oldVolumeDownData?.showVolumeUi ?: false, + ) + return null + } + ActionId.VOLUME_MUTE, ActionId.VOLUME_UNMUTE, ActionId.VOLUME_TOGGLE_MUTE, - -> { + -> { val showVolumeUiId = 0 val isVolumeUiChecked = when (oldData) { - is ActionData.Volume.Up -> oldData.showVolumeUi - is ActionData.Volume.Down -> oldData.showVolumeUi is ActionData.Volume.Mute -> oldData.showVolumeUi is ActionData.Volume.UnMute -> oldData.showVolumeUi is ActionData.Volume.ToggleMute -> oldData.showVolumeUi @@ -256,8 +330,6 @@ class CreateActionDelegate( val showVolumeUi = chosenFlags.contains(showVolumeUiId) val action = when (actionId) { - ActionId.VOLUME_UP -> ActionData.Volume.Up(showVolumeUi) - ActionId.VOLUME_DOWN -> ActionData.Volume.Down(showVolumeUi) ActionId.VOLUME_MUTE -> ActionData.Volume.Mute(showVolumeUi) ActionId.VOLUME_UNMUTE -> ActionData.Volume.UnMute(showVolumeUi) ActionId.VOLUME_TOGGLE_MUTE -> ActionData.Volume.ToggleMute( @@ -270,48 +342,49 @@ class CreateActionDelegate( return action } - ActionId.VOLUME_INCREASE_STREAM, - ActionId.VOLUME_DECREASE_STREAM, - -> { - val showVolumeUiId = 0 - val isVolumeUiChecked = if (oldData is ActionData.Volume.Stream) { - oldData.showVolumeUi - } else { - false - } - - val dialogItems = listOf( - MultiChoiceItem( - showVolumeUiId, - getString(R.string.flag_show_volume_dialog), - isVolumeUiChecked, - ), - ) - - val showVolumeUiDialog = DialogModel.MultiChoice(items = dialogItems) - - val chosenFlags = - showDialog("show_volume_ui", showVolumeUiDialog) ?: return null - - val showVolumeUi = chosenFlags.contains(showVolumeUiId) + ActionId.MUTE_MICROPHONE -> { + return ActionData.Microphone.Mute + } - val items = VolumeStream.entries - .map { it to getString(VolumeStreamStrings.getLabel(it)) } + ActionId.UNMUTE_MICROPHONE -> { + return ActionData.Microphone.Unmute + } - val stream = showDialog("pick_volume_stream", DialogModel.SingleChoice(items)) - ?: return null + ActionId.TOGGLE_MUTE_MICROPHONE -> { + return ActionData.Microphone.Toggle + } - val action = when (actionId) { - ActionId.VOLUME_INCREASE_STREAM -> - ActionData.Volume.Stream.Increase(showVolumeUi = showVolumeUi, stream) + ActionId.VOLUME_INCREASE_STREAM, + ActionId.VOLUME_DECREASE_STREAM, + -> { + // These deprecated actions are now converted to Volume.Up/Down with stream parameter + // Determine which action ID to use based on the old action + val newActionId = when (actionId) { + ActionId.VOLUME_INCREASE_STREAM -> ActionId.VOLUME_UP + ActionId.VOLUME_DECREASE_STREAM -> ActionId.VOLUME_DOWN + else -> return null + } - ActionId.VOLUME_DECREASE_STREAM -> - ActionData.Volume.Stream.Decrease(showVolumeUi = showVolumeUi, stream) + // Get the old stream if this is being edited + val oldStream = when (oldData) { + is ActionData.Volume.Up -> oldData.volumeStream + is ActionData.Volume.Down -> oldData.volumeStream + else -> null + } - else -> throw Exception("don't know how to create action for $actionId") + val oldShowVolumeUi = when (oldData) { + is ActionData.Volume.Up -> oldData.showVolumeUi + is ActionData.Volume.Down -> oldData.showVolumeUi + else -> false } - return action + volumeActionState = VolumeActionBottomSheetState( + actionId = newActionId, + // Default to MUSIC for old stream actions + volumeStream = oldStream ?: VolumeStream.MUSIC, + showVolumeUi = oldShowVolumeUi, + ) + return null } ActionId.CHANGE_RINGER_MODE -> { @@ -328,7 +401,7 @@ class CreateActionDelegate( // don't need to show options for disabling do not disturb ActionId.TOGGLE_DND_MODE, ActionId.ENABLE_DND_MODE, - -> { + -> { val items = DndMode.entries .map { it to getString(DndModeStrings.getLabel(it)) } @@ -446,7 +519,7 @@ class CreateActionDelegate( } ActionId.DISABLE_FLASHLIGHT, - -> { + -> { val items = useCase.getFlashlightLenses().map { lens -> when (lens) { CameraLens.FRONT -> lens to getString(R.string.lens_front) @@ -691,6 +764,35 @@ class CreateActionDelegate( return ActionData.PhoneCall(text) } + ActionId.SEND_SMS, ActionId.COMPOSE_SMS -> { + val number = when (oldData) { + is ActionData.SendSms -> oldData.number + is ActionData.ComposeSms -> oldData.number + else -> "" + } + + val message = when (oldData) { + is ActionData.SendSms -> oldData.message + is ActionData.ComposeSms -> oldData.message + else -> "" + } + + smsActionBottomSheetState = if (actionId == ActionId.SEND_SMS) { + SmsActionBottomSheetState.SendSms( + number = number, + message = message, + testResult = null, + ) + } else { + SmsActionBottomSheetState.ComposeSms( + number = number, + message = message, + ) + } + + return null + } + ActionId.SOUND -> { return navigate( "choose_sound_file", @@ -800,6 +902,19 @@ class CreateActionDelegate( return null } + ActionId.SHELL_COMMAND -> { + val oldAction = oldData as? ActionData.ShellCommand + + return navigate( + "config_shell_command_action", + NavDestination.ConfigShellCommand( + oldAction?.let { + Json.encodeToString(oldAction) + }, + ), + ) + } + ActionId.INTERACT_UI_ELEMENT -> { val oldAction = oldData as? ActionData.InteractUiElement @@ -810,6 +925,8 @@ class CreateActionDelegate( } ActionId.MOVE_CURSOR -> return createMoverCursorAction() + ActionId.FORCE_STOP_APP -> return ActionData.ForceStopApp + ActionId.CLEAR_RECENT_APP -> return ActionData.ClearRecentApp } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt index 90ba5f6d72..2e7071def9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionUseCase.kt @@ -1,22 +1,27 @@ package io.github.sds100.keymapper.base.actions +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.camera.CameraAdapter import io.github.sds100.keymapper.system.camera.CameraFlashInfo import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter +import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter +import io.github.sds100.keymapper.system.phone.PhoneAdapter +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.merge -import javax.inject.Inject class CreateActionUseCaseImpl @Inject constructor( private val inputMethodAdapter: InputMethodAdapter, private val systemFeatureAdapter: SystemFeatureAdapter, private val cameraAdapter: CameraAdapter, private val permissionAdapter: PermissionAdapter, + private val phoneAdapter: PhoneAdapter, ) : CreateActionUseCase, IsActionSupportedUseCase by IsActionSupportedUseCaseImpl( systemFeatureAdapter, @@ -52,6 +57,18 @@ class CreateActionUseCaseImpl @Inject constructor( cameraAdapter.isFlashlightOnFlow(CameraLens.BACK), ) } + + override fun requestPermission(permission: Permission) { + permissionAdapter.request(permission) + } + + override suspend fun testSms(number: String, message: String): KMResult { + if (!permissionAdapter.isGranted(Permission.SEND_SMS)) { + return SystemError.PermissionDenied(Permission.SEND_SMS) + } + + return phoneAdapter.sendSms(number, message) + } } interface CreateActionUseCase : IsActionSupportedUseCase { @@ -63,4 +80,7 @@ interface CreateActionUseCase : IsActionSupportedUseCase { fun disableFlashlight() fun getFlashlightLenses(): Set fun getFlashInfo(lens: CameraLens): CameraFlashInfo? + + fun requestPermission(permission: Permission) + suspend fun testSms(number: String, message: String): KMResult } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt new file mode 100644 index 0000000000..cffaf1b859 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt @@ -0,0 +1,76 @@ +package io.github.sds100.keymapper.base.actions + +import android.os.Build +import io.github.sds100.keymapper.common.models.ShellExecutionMode +import io.github.sds100.keymapper.common.models.ShellResult +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.system.root.SuAdapter +import io.github.sds100.keymapper.system.shell.ShellAdapter +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withContext + +class ExecuteShellCommandUseCase @Inject constructor( + private val shellAdapter: ShellAdapter, + private val suAdapter: SuAdapter, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, +) { + suspend fun execute( + command: String, + executionMode: ShellExecutionMode, + timeoutMillis: Long, + ): KMResult = withContext(Dispatchers.IO) { + when (executionMode) { + ShellExecutionMode.STANDARD -> shellAdapter.execute(command, timeoutMillis) + ShellExecutionMode.ROOT -> suAdapter.execute(command, timeoutMillis) + ShellExecutionMode.ADB -> executeCommandSystemBridge(command, timeoutMillis) + } + } + + suspend fun executeWithStreamingOutput( + command: String, + executionMode: ShellExecutionMode, + timeoutMillis: Long, + ): Flow> { + return when (executionMode) { + ShellExecutionMode.STANDARD -> shellAdapter.executeWithStreamingOutput( + command, + timeoutMillis, + ) + + ShellExecutionMode.ROOT -> suAdapter.executeWithStreamingOutput(command, timeoutMillis) + + // ADB mode doesn't support streaming + ShellExecutionMode.ADB -> flowOf(executeCommandSystemBridge(command, timeoutMillis)) + } + } + + /** + * Useful shell command for testing this is: + * for i in 1 2 3 4 5 6; do sleep 1; echo $i; done + */ + private suspend fun executeCommandSystemBridge( + command: String, + timeoutMillis: Long, + ): KMResult { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + runInterruptible(Dispatchers.IO) { + try { + systemBridgeConnectionManager.run { systemBridge -> + systemBridge.executeCommand(command, timeoutMillis) + } + // Only some standard exceptions can be thrown across Binder. + } catch (e: IllegalStateException) { + KMError.ShellCommandTimeout(timeoutMillis, null) + } + } + } else { + KMError.SdkVersionTooLow(Build.VERSION_CODES.Q) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/FlashlightActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/FlashlightActionBottomSheet.kt index 13b4490d76..60f4fa07c8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/FlashlightActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/FlashlightActionBottomSheet.kt @@ -53,8 +53,8 @@ import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText import io.github.sds100.keymapper.system.camera.CameraFlashInfo import io.github.sds100.keymapper.system.camera.CameraLens -import kotlinx.coroutines.launch import kotlin.math.roundToInt +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -193,7 +193,9 @@ private fun EnableFlashlightActionBottomSheet( IconButton(onClick = { onSelectStrength(sliderDefault) }) { Icon( Icons.Rounded.RestartAlt, - contentDescription = stringResource(R.string.slider_reset_content_description), + contentDescription = stringResource( + R.string.slider_reset_content_description, + ), ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/GetActionErrorUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/GetActionErrorUseCase.kt index d165b5025b..f8ee5083f8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/GetActionErrorUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/GetActionErrorUseCase.kt @@ -1,34 +1,42 @@ package io.github.sds100.keymapper.base.actions +import android.os.Build import io.github.sds100.keymapper.base.actions.sound.SoundsManager +import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.system.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.camera.CameraAdapter import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter import io.github.sds100.keymapper.system.ringtones.RingtoneAdapter -import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge -import javax.inject.Inject -import javax.inject.Singleton @Singleton class GetActionErrorUseCaseImpl @Inject constructor( private val packageManagerAdapter: PackageManagerAdapter, private val inputMethodAdapter: InputMethodAdapter, + private val switchImeInterface: SwitchImeInterface, private val permissionAdapter: PermissionAdapter, private val systemFeatureAdapter: SystemFeatureAdapter, private val cameraAdapter: CameraAdapter, private val soundsManager: SoundsManager, - private val shizukuAdapter: ShizukuAdapter, private val ringtoneAdapter: RingtoneAdapter, private val buildConfigProvider: BuildConfigProvider, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val preferenceRepository: PreferenceRepository, ) : GetActionErrorUseCase { private val invalidateActionErrors = merge( @@ -37,9 +45,15 @@ class GetActionErrorUseCaseImpl @Inject constructor( inputMethodAdapter.inputMethods.drop(1).map { }, permissionAdapter.onPermissionsUpdate, soundsManager.soundFiles.drop(1).map { }, - shizukuAdapter.isStarted.drop(1).map { }, - shizukuAdapter.isInstalled.drop(1).map { }, packageManagerAdapter.onPackagesChanged, + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + merge( + systemBridgeConnectionManager.connectionState.drop(1).map { }, + preferenceRepository.get(Keys.keyEventActionsUseSystemBridge), + ) + } else { + emptyFlow() + }, ) override val actionErrorSnapshot: Flow = channelFlow { @@ -50,17 +64,21 @@ class GetActionErrorUseCaseImpl @Inject constructor( } } - private fun createSnapshot(): ActionErrorSnapshot = LazyActionErrorSnapshot( - packageManagerAdapter, - inputMethodAdapter, - permissionAdapter, - systemFeatureAdapter, - cameraAdapter, - soundsManager, - shizukuAdapter, - ringtoneAdapter, - buildConfigProvider, - ) + private fun createSnapshot(): ActionErrorSnapshot { + return LazyActionErrorSnapshot( + packageManagerAdapter, + inputMethodAdapter, + switchImeInterface, + permissionAdapter, + systemFeatureAdapter, + cameraAdapter, + soundsManager, + ringtoneAdapter, + buildConfigProvider, + systemBridgeConnectionManager, + preferenceRepository, + ) + } } interface GetActionErrorUseCase { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/IsActionSupportedUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/IsActionSupportedUseCase.kt index d1c7a3d404..bb2b26ea5c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/IsActionSupportedUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/IsActionSupportedUseCase.kt @@ -37,7 +37,10 @@ class IsActionSupportedUseCaseImpl( } } - if (id == ActionId.ENABLE_FLASHLIGHT || id == ActionId.DISABLE_FLASHLIGHT || id == ActionId.TOGGLE_FLASHLIGHT) { + if (id == ActionId.ENABLE_FLASHLIGHT || + id == ActionId.DISABLE_FLASHLIGHT || + id == ActionId.TOGGLE_FLASHLIGHT + ) { if (cameraAdapter.getFlashInfo(CameraLens.BACK) == null && cameraAdapter.getFlashInfo(CameraLens.FRONT) == null ) { 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 690635ed06..ab1fe35d3b 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 @@ -10,15 +10,20 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.sound.SoundsManager +import io.github.sds100.keymapper.base.input.InjectKeyEventModel +import io.github.sds100.keymapper.base.input.InputEventHub import io.github.sds100.keymapper.base.system.accessibility.AccessibilityNodeAction import io.github.sds100.keymapper.base.system.accessibility.AccessibilityNodeModel import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityService import io.github.sds100.keymapper.base.system.inputmethod.ImeInputEventInjector +import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface import io.github.sds100.keymapper.base.system.navigation.OpenMenuHelper import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.ui.ResourceProvider -import io.github.sds100.keymapper.common.utils.InputEventType +import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMError.SdkVersionTooLow import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.Success @@ -35,6 +40,8 @@ import io.github.sds100.keymapper.common.utils.withFlag 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.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.isConnected import io.github.sds100.keymapper.system.airplanemode.AirplaneModeAdapter import io.github.sds100.keymapper.system.apps.AppShortcutAdapter import io.github.sds100.keymapper.system.apps.PackageManagerAdapter @@ -44,8 +51,8 @@ import io.github.sds100.keymapper.system.devices.DevicesAdapter import io.github.sds100.keymapper.system.display.DisplayAdapter import io.github.sds100.keymapper.system.files.FileAdapter import io.github.sds100.keymapper.system.files.FileUtils -import io.github.sds100.keymapper.system.inputevents.InputEventUtils -import io.github.sds100.keymapper.system.inputmethod.InputKeyModel +import io.github.sds100.keymapper.system.inputevents.KeyEventUtils +import io.github.sds100.keymapper.system.inputevents.Scancode import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.intents.IntentAdapter import io.github.sds100.keymapper.system.intents.IntentTarget @@ -55,14 +62,11 @@ import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.nfc.NfcAdapter import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapter import io.github.sds100.keymapper.system.notifications.NotificationServiceEvent -import io.github.sds100.keymapper.system.permissions.Permission -import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.phone.PhoneAdapter import io.github.sds100.keymapper.system.popup.ToastAdapter import io.github.sds100.keymapper.system.ringtones.RingtoneAdapter import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.shell.ShellAdapter -import io.github.sds100.keymapper.system.shizuku.ShizukuInputEventInjector import io.github.sds100.keymapper.system.url.OpenUrlAdapter import io.github.sds100.keymapper.system.volume.RingerMode import io.github.sds100.keymapper.system.volume.VolumeAdapter @@ -72,22 +76,27 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber class PerformActionsUseCaseImpl @AssistedInject constructor( - private val appCoroutineScope: CoroutineScope, + @Assisted + private val coroutineScope: CoroutineScope, @Assisted private val service: IAccessibilityService, private val inputMethodAdapter: InputMethodAdapter, + private val switchImeInterface: SwitchImeInterface, private val fileAdapter: FileAdapter, private val suAdapter: SuAdapter, private val shell: ShellAdapter, private val intentAdapter: IntentAdapter, private val getActionErrorUseCase: GetActionErrorUseCase, - @Assisted + private val executeShellCommandUseCase: ExecuteShellCommandUseCase, private val keyMapperImeMessenger: ImeInputEventInjector, private val packageManagerAdapter: PackageManagerAdapter, private val appShortcutAdapter: AppShortcutAdapter, @@ -106,42 +115,36 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( private val openUrlAdapter: OpenUrlAdapter, private val resourceProvider: ResourceProvider, private val soundsManager: SoundsManager, - private val permissionAdapter: PermissionAdapter, private val notificationReceiverAdapter: NotificationReceiverAdapter, private val ringtoneAdapter: RingtoneAdapter, private val settingsRepository: PreferenceRepository, + private val inputEventHub: InputEventHub, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, ) : PerformActionsUseCase { @AssistedFactory interface Factory { fun create( + coroutineScope: CoroutineScope, accessibilityService: IAccessibilityService, - imeInputEventInjector: ImeInputEventInjector, ): PerformActionsUseCaseImpl } - private val shizukuInputEventInjector: ShizukuInputEventInjector = ShizukuInputEventInjector() - private val openMenuHelper by lazy { OpenMenuHelper( - suAdapter, service, - shizukuInputEventInjector, - permissionAdapter, - appCoroutineScope, + inputEventHub, ) } - /** - * Cache this so we aren't checking every time a key event must be inputted. - */ - private val inputKeyEventsWithShizuku: StateFlow = - permissionAdapter.isGrantedFlow(Permission.SHIZUKU) - .stateIn(appCoroutineScope, SharingStarted.Eagerly, false) + private val injectKeyEventsWithSystemBridge: StateFlow = + settingsRepository.get(Keys.keyEventActionsUseSystemBridge) + .map { it ?: PreferenceDefaults.KEY_EVENT_ACTIONS_USE_SYSTEM_BRIDGE } + .stateIn(coroutineScope, SharingStarted.Eagerly, false) override suspend fun perform( action: ActionData, - inputEventType: InputEventType, + inputEventAction: InputEventAction, keyMetaState: Int, ) { /** @@ -167,32 +170,43 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( // See issue #1683. Some apps ignore key events which do not have a source. val source = when { - InputEventUtils.isDpadKeyCode(action.keyCode) -> InputDevice.SOURCE_DPAD - InputEventUtils.isGamepadButton(action.keyCode) -> InputDevice.SOURCE_GAMEPAD + KeyEventUtils.isDpadKeyCode(action.keyCode) -> InputDevice.SOURCE_DPAD + KeyEventUtils.isGamepadButton(action.keyCode) -> InputDevice.SOURCE_GAMEPAD else -> InputDevice.SOURCE_KEYBOARD } - val model = InputKeyModel( + val firstInputAction = if (inputEventAction == InputEventAction.UP) { + KeyEvent.ACTION_UP + } else { + KeyEvent.ACTION_DOWN + } + + val model = InjectKeyEventModel( keyCode = action.keyCode, - inputType = inputEventType, + action = firstInputAction, metaState = keyMetaState.withFlag(action.metaState), deviceId = deviceId, source = source, + repeatCount = 0, + scanCode = 0, ) - result = when { - inputKeyEventsWithShizuku.value -> { - shizukuInputEventInjector.inputKeyEvent(model) - Success(Unit) - } - - action.useShell -> suAdapter.execute("input keyevent ${model.keyCode}") - - else -> { - keyMapperImeMessenger.inputKeyEvent(model) - - Success(Unit) - } + if (inputEventAction == InputEventAction.DOWN_UP) { + result = inputEventHub.injectKeyEvent( + model, + useSystemBridgeIfAvailable = injectKeyEventsWithSystemBridge.value, + ) + .then { + inputEventHub.injectKeyEvent( + model.copy(action = KeyEvent.ACTION_UP), + useSystemBridgeIfAvailable = injectKeyEventsWithSystemBridge.value, + ) + } + } else { + result = inputEventHub.injectKeyEvent( + model, + useSystemBridgeIfAvailable = injectKeyEventsWithSystemBridge.value, + ) } } @@ -200,6 +214,14 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = phoneAdapter.startCall(action.number) } + is ActionData.SendSms -> { + result = phoneAdapter.sendSms(action.number, action.message) + } + + is ActionData.ComposeSms -> { + result = phoneAdapter.composeSms(action.number, action.message) + } + is ActionData.DoNotDisturb.Enable -> { result = audioAdapter.enableDndMode(action.dndMode) } @@ -289,43 +311,38 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.SwitchKeyboard -> { - result = inputMethodAdapter - .chooseImeWithoutUserInput(action.imeId) - .onSuccess { - val message = resourceProvider.getString( - R.string.toast_chose_keyboard, - it.label, - ) - toastAdapter.show(message) - } - } + switchImeInterface.switchIme(action.imeId) - is ActionData.Volume.Down -> { - result = audioAdapter.lowerVolume(showVolumeUi = action.showVolumeUi) - } - - is ActionData.Volume.Up -> { - result = audioAdapter.raiseVolume(showVolumeUi = action.showVolumeUi) - } + // See issue #1064. Wait for the input method to finish switching before returning. + val chosenIme = withTimeoutOrNull(2000) { + inputMethodAdapter.chosenIme.filterNotNull().first { it.id == action.imeId } + } - is ActionData.Volume.Mute -> { - result = audioAdapter.muteVolume(showVolumeUi = action.showVolumeUi) + if (chosenIme == null) { + result = KMError.SwitchImeFailed + } else { + result = Success(Unit) + } } - is ActionData.Volume.Stream.Decrease -> { + is ActionData.Volume.Down -> { result = audioAdapter.lowerVolume( stream = action.volumeStream, showVolumeUi = action.showVolumeUi, ) } - is ActionData.Volume.Stream.Increase -> { + is ActionData.Volume.Up -> { result = audioAdapter.raiseVolume( stream = action.volumeStream, showVolumeUi = action.showVolumeUi, ) } + is ActionData.Volume.Mute -> { + result = audioAdapter.muteVolume(showVolumeUi = action.showVolumeUi) + } + is ActionData.Volume.ToggleMute -> { result = audioAdapter.toggleMuteVolume(showVolumeUi = action.showVolumeUi) } @@ -334,8 +351,36 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = audioAdapter.unmuteVolume(showVolumeUi = action.showVolumeUi) } + is ActionData.Microphone.Mute -> { + result = audioAdapter.muteMicrophone().onSuccess { + toastAdapter.show(resourceProvider.getString(R.string.toast_microphone_muted)) + } + } + + is ActionData.Microphone.Unmute -> { + result = audioAdapter.unmuteMicrophone().onSuccess { + toastAdapter.show(resourceProvider.getString(R.string.toast_microphone_unmuted)) + } + } + + is ActionData.Microphone.Toggle -> { + result = if (audioAdapter.isMicrophoneMuted) { + audioAdapter.unmuteMicrophone().onSuccess { + toastAdapter.show( + resourceProvider.getString(R.string.toast_microphone_unmuted), + ) + } + } else { + audioAdapter.muteMicrophone().onSuccess { + toastAdapter.show( + resourceProvider.getString(R.string.toast_microphone_muted), + ) + } + } + } + is ActionData.TapScreen -> { - result = service.tapScreen(action.x, action.y, inputEventType) + result = service.tapScreen(action.x, action.y, inputEventAction) } is ActionData.SwipeScreen -> { @@ -346,7 +391,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( action.yEnd, action.fingerCount, action.duration, - inputEventType, + inputEventAction, ) } @@ -358,7 +403,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( action.pinchType, action.fingerCount, action.duration, - inputEventType, + inputEventAction, ) } @@ -516,7 +561,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val globalAction = AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS result = service.doGlobalAction(globalAction).otherwise { - shell.execute("cmd statusbar expand-notifications") + getShellAdapter(useRoot = false).execute("cmd statusbar expand-notifications") } } @@ -528,7 +573,9 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val globalAction = AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS service.doGlobalAction(globalAction).otherwise { - shell.execute("cmd statusbar expand-notifications") + getShellAdapter( + useRoot = false, + ).execute("cmd statusbar expand-notifications") } } } @@ -538,7 +585,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = service.doGlobalAction(globalAction).otherwise { - shell.execute("cmd statusbar expand-settings") + getShellAdapter(useRoot = false).execute("cmd statusbar expand-settings") } } @@ -550,7 +597,9 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val globalAction = AccessibilityService.GLOBAL_ACTION_QUICK_SETTINGS service.doGlobalAction(globalAction).otherwise { - shell.execute("cmd statusbar expand-settings") + getShellAdapter( + useRoot = false, + ).execute("cmd statusbar expand-settings") } } } @@ -615,11 +664,8 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.ToggleSplitScreen -> { - result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + result = service.doGlobalAction(AccessibilityService.GLOBAL_ACTION_TOGGLE_SPLIT_SCREEN) - } else { - KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.N) - } } is ActionData.GoLastApp -> { @@ -652,22 +698,30 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( is ActionData.MoveCursor -> { result = service.performActionOnNode({ it.isFocused }) { val actionType = when (action.direction) { - ActionData.MoveCursor.Direction.START -> AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY - ActionData.MoveCursor.Direction.END -> AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY + ActionData.MoveCursor.Direction.START -> + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY + ActionData.MoveCursor.Direction.END -> + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY } val granularity = when (action.moveType) { - ActionData.MoveCursor.Type.CHAR -> AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER - ActionData.MoveCursor.Type.WORD -> AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD - ActionData.MoveCursor.Type.LINE -> AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE - ActionData.MoveCursor.Type.PARAGRAPH -> AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH - ActionData.MoveCursor.Type.PAGE -> AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE + ActionData.MoveCursor.Type.CHAR -> + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER + ActionData.MoveCursor.Type.WORD -> + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD + ActionData.MoveCursor.Type.LINE -> + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE + ActionData.MoveCursor.Type.PARAGRAPH -> + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH + ActionData.MoveCursor.Type.PAGE -> + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE } AccessibilityNodeAction( actionType, mapOf( - AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT to granularity, + AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT to + granularity, AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN to false, ), ) @@ -728,10 +782,12 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( ?: return@performActionOnNode null val extras = mapOf( - AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT to wordBoundary.first, + AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT to + wordBoundary.first, // The index of the cursor is the index of the last char in the word + 1 - AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT to wordBoundary.second + 1, + AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT to + wordBoundary.second + 1, ) AccessibilityNodeAction( @@ -767,7 +823,11 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( val fileDate = FileUtils.createFileDate() result = - suAdapter.execute("mkdir -p $screenshotsFolder; screencap -p $screenshotsFolder/Screenshot_$fileDate.png") + getShellAdapter( + useRoot = true, + ).execute( + "mkdir -p $screenshotsFolder; screencap -p $screenshotsFolder/Screenshot_$fileDate.png", + ) .onSuccess { // Wait 3 seconds so the message isn't shown in the screenshot. delay(3000) @@ -798,14 +858,39 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( is ActionData.LockDevice -> { result = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { - suAdapter.execute("input keyevent ${KeyEvent.KEYCODE_POWER}") + getShellAdapter( + useRoot = true, + ).execute("input keyevent ${KeyEvent.KEYCODE_POWER}") } else { service.doGlobalAction(AccessibilityService.GLOBAL_ACTION_LOCK_SCREEN) } } is ActionData.ScreenOnOff -> { - result = suAdapter.execute("input keyevent ${KeyEvent.KEYCODE_POWER}") + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API && + systemBridgeConnectionManager.isConnected() + ) { + val model = InjectKeyEventModel( + keyCode = KeyEvent.KEYCODE_POWER, + action = KeyEvent.ACTION_DOWN, + metaState = 0, + deviceId = -1, + scanCode = Scancode.KEY_POWER, + source = InputDevice.SOURCE_UNKNOWN, + ) + result = inputEventHub.injectKeyEvent(model, useSystemBridgeIfAvailable = true) + .then { + inputEventHub.injectKeyEvent( + model.copy(action = KeyEvent.ACTION_UP), + useSystemBridgeIfAvailable = true, + ) + } + } else { + result = + getShellAdapter( + useRoot = true, + ).execute("input keyevent ${KeyEvent.KEYCODE_POWER}") + } } is ActionData.SecureLock -> { @@ -831,12 +916,16 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( ActionData.DismissAllNotifications -> { result = - notificationReceiverAdapter.send(NotificationServiceEvent.DismissAllNotifications) + notificationReceiverAdapter.send( + NotificationServiceEvent.DismissAllNotifications, + ) } ActionData.DismissLastNotification -> { result = - notificationReceiverAdapter.send(NotificationServiceEvent.DismissLastNotification) + notificationReceiverAdapter.send( + NotificationServiceEvent.DismissLastNotification, + ) } ActionData.AnswerCall -> { @@ -850,6 +939,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } ActionData.DeviceControls -> { + @Suppress("ktlint:standard:max-line-length") result = intentAdapter.send( IntentTarget.ACTIVITY, uri = "#Intent;action=android.intent.action.MAIN;package=com.android.systemui;component=com.android.systemui/.controls.ui.ControlsActivity;end", @@ -866,6 +956,14 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( ) } + is ActionData.ShellCommand -> { + result = executeShellCommandUseCase.execute( + command = action.command, + executionMode = action.executionMode, + timeoutMillis = action.timeoutMillis.toLong(), + ) + } + is ActionData.InteractUiElement -> { if (service.activeWindowPackage.first() != action.packageName) { result = KMError.UiElementNotFound @@ -874,16 +972,60 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( findNode = { node -> matchAccessibilityNode(node, action) }, - performAction = { AccessibilityNodeAction(action = action.nodeAction.accessibilityActionId) }, + performAction = { + AccessibilityNodeAction( + action = action.nodeAction.accessibilityActionId, + ) + }, ).otherwise { KMError.UiElementNotFound } } } + + ActionData.ForceStopApp -> { + val packageName = service.activeWindowPackageNames + .firstOrNull { + !it.contains("io.github.sds100.keymapper") && + it != "com.android.systemui" + } + + if (packageName == null) { + result = KMError.Exception(Exception("No foreground app found to kill")) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + result = systemBridgeConnectionManager.run { systemBridge -> + systemBridge.forceStopPackage(packageName) + } + } else { + result = SdkVersionTooLow(minSdk = Constants.SYSTEM_BRIDGE_MIN_API) + } + } + + ActionData.ClearRecentApp -> { + val packageName = service.activeWindowPackageNames + .firstOrNull { it != "com.android.systemui" } + + if (packageName == null) { + result = + KMError.Exception( + Exception("No foreground app found to clear from recents"), + ) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + result = systemBridgeConnectionManager.run { systemBridge -> + systemBridge.removeTasks(packageName) + } + } else { + result = SdkVersionTooLow(minSdk = Constants.SYSTEM_BRIDGE_MIN_API) + } + } } when (result) { - is Success -> Timber.d("Performed action $action, input event type: $inputEventType, key meta state: $keyMetaState") + is Success -> Timber.d( + "Performed action $action, input event type: $inputEventAction, key meta state: $keyMetaState", + ) is KMError -> Timber.d( - "Failed to perform action $action, reason: ${result.getFullMessage(resourceProvider)}, action: $action, input event type: $inputEventType, key meta state: $keyMetaState", + "Failed to perform action $action, reason: ${result.getFullMessage( + resourceProvider, + )}, action: $action, input event type: $inputEventAction, key meta state: $keyMetaState", ) } @@ -913,7 +1055,7 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( if (action.device?.descriptor == null) { // automatically select a game controller as the input device for game controller key events - if (InputEventUtils.isGamepadKeyCode(action.keyCode)) { + if (KeyEventUtils.isGamepadKeyCode(action.keyCode)) { devicesAdapter.connectedInputDevices.value.ifIsData { inputDevices -> val device = inputDevices.find { it.isGameController } @@ -961,7 +1103,17 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( return service .doGlobalAction(AccessibilityService.GLOBAL_ACTION_DISMISS_NOTIFICATION_SHADE) } else { - return shell.execute("cmd statusbar collapse") + return runBlocking { + getShellAdapter(useRoot = false).execute("cmd statusbar collapse") + } + } + } + + private fun getShellAdapter(useRoot: Boolean): ShellAdapter { + return if (useRoot) { + suAdapter + } else { + shell } } @@ -1022,7 +1174,7 @@ interface PerformActionsUseCase { suspend fun perform( action: ActionData, - inputEventType: InputEventType = InputEventType.DOWN_UP, + inputEventAction: InputEventAction = InputEventAction.DOWN_UP, keyMetaState: Int = 0, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt new file mode 100644 index 0000000000..7df39b3771 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ShellCommandActionScreen.kt @@ -0,0 +1,730 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +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.OutlinedTextField +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.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.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +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 io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ProModeStatus +import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow +import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +import io.github.sds100.keymapper.common.models.ShellExecutionMode +import io.github.sds100.keymapper.common.models.ShellResult +import io.github.sds100.keymapper.common.models.isError +import io.github.sds100.keymapper.common.models.isSuccess +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.system.SystemError +import io.github.sds100.keymapper.system.permissions.Permission +import kotlinx.coroutines.launch + +data class ShellCommandActionState( + val description: String = "", + val command: String = "", + val executionMode: ShellExecutionMode = ShellExecutionMode.STANDARD, + /** + * UI works with seconds for user-friendliness + */ + val timeoutSeconds: Int = 10, + val isRunning: Boolean = false, + val testResult: KMResult? = null, + val proModeStatus: ProModeStatus = ProModeStatus.UNSUPPORTED, +) + +@Composable +fun ShellCommandActionScreen( + modifier: Modifier = Modifier, + viewModel: ConfigShellCommandViewModel, +) { + ShellCommandActionScreen( + modifier = modifier, + state = viewModel.state, + onDescriptionChanged = viewModel::onDescriptionChanged, + onCommandChanged = viewModel::onCommandChanged, + onExecutionModeChanged = viewModel::onExecutionModeChanged, + onTimeoutChanged = viewModel::onTimeoutChanged, + onTestClick = viewModel::onTestClick, + onKillClick = viewModel::onKillClick, + onDoneClick = viewModel::onDoneClick, + onCancelClick = viewModel::onCancelClick, + onSetupProModeClick = viewModel::onSetupProModeClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ShellCommandActionScreen( + modifier: Modifier = Modifier, + state: ShellCommandActionState, + onDescriptionChanged: (String) -> Unit = {}, + onCommandChanged: (String) -> Unit = {}, + onExecutionModeChanged: (ShellExecutionMode) -> Unit = {}, + onTimeoutChanged: (Int) -> Unit = {}, + onTestClick: () -> Unit = {}, + onKillClick: () -> Unit = {}, + onDoneClick: () -> Unit = {}, + onCancelClick: () -> Unit = {}, + onSetupProModeClick: () -> Unit = {}, +) { + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + + var descriptionError: String? by rememberSaveable { mutableStateOf(null) } + var commandError: String? by rememberSaveable { mutableStateOf(null) } + val descriptionEmptyErrorString = stringResource(R.string.error_cant_be_empty) + val commandEmptyErrorString = stringResource(R.string.action_shell_command_command_empty_error) + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.action_shell_command_title)) }, + ) + }, + bottomBar = { + BottomAppBar( + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { + var hasError = false + + if (state.description.isBlank()) { + descriptionError = descriptionEmptyErrorString + hasError = true + } + + if (state.command.isBlank()) { + commandError = commandEmptyErrorString + hasError = true + } + + if (hasError) { + scope.launch { + scrollState.animateScrollTo(0) + } + } else { + onDoneClick() + } + }, + text = { Text(stringResource(R.string.pos_done)) }, + icon = { + Icon(Icons.Rounded.Check, stringResource(R.string.pos_done)) + }, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + ) + }, + actions = { + IconButton(onClick = onCancelClick) { + Icon(Icons.Rounded.Close, stringResource(R.string.neg_cancel)) + } + }, + ) + }, + ) { innerPadding -> + + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Column( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + val pagerState = rememberPagerState(pageCount = { 2 }, initialPage = 0) + + 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.action_shell_command_tab_configuration)) + }, + ) + Tab( + selected = pagerState.targetPage == 1, + onClick = { + scope.launch { + pagerState.animateScrollToPage(1) + } + }, + text = { Text(stringResource(R.string.action_shell_command_tab_output)) }, + ) + } + + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = pagerState, + contentPadding = PaddingValues(16.dp), + pageSpacing = 16.dp, + ) { pageIndex -> + when (pageIndex) { + 0 -> ShellCommandConfigurationContent( + modifier = Modifier.fillMaxSize(), + state = state, + descriptionError = descriptionError, + commandError = commandError, + onDescriptionChanged = { + descriptionError = null + onDescriptionChanged(it) + }, + onCommandChanged = { + commandError = null + onCommandChanged(it) + }, + onExecutionModeChanged = onExecutionModeChanged, + onTimeoutChanged = onTimeoutChanged, + onTestClick = { + if (state.command.isBlank()) { + commandError = commandEmptyErrorString + } else { + onTestClick() + scope.launch { + pagerState.animateScrollToPage(1) // Switch to output tab + } + } + }, + onSetupProModeClick = onSetupProModeClick, + ) + + 1 -> ShellCommandOutputContent( + modifier = Modifier.fillMaxSize(), + state = state, + onKillClick = onKillClick, + ) + } + } + } + } +} + +@Composable +private fun ShellCommandConfigurationContent( + modifier: Modifier = Modifier, + state: ShellCommandActionState, + descriptionError: String?, + commandError: String?, + onDescriptionChanged: (String) -> Unit, + onCommandChanged: (String) -> Unit, + onExecutionModeChanged: (ShellExecutionMode) -> Unit, + onTimeoutChanged: (Int) -> Unit, + onTestClick: () -> Unit, + onSetupProModeClick: () -> Unit, +) { + val keyboardController = LocalSoftwareKeyboardController.current + Column( + modifier = modifier + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.description, + onValueChange = onDescriptionChanged, + label = { Text(stringResource(R.string.hint_shell_command_description)) }, + singleLine = true, + isError = descriptionError != null, + supportingText = { + if (descriptionError != null) { + Text( + text = descriptionError, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.command, + onValueChange = onCommandChanged, + label = { Text(stringResource(R.string.action_shell_command_command_label)) }, + minLines = 3, + maxLines = 10, + isError = commandError != null, + supportingText = { + if (commandError != null) { + Text( + text = commandError, + color = MaterialTheme.colorScheme.error, + ) + } + }, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + SliderOptionText( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.hint_shell_command_timeout), + defaultValue = 10f, + value = state.timeoutSeconds.toFloat(), + valueText = { "${it.toInt()}s" }, + onValueChange = { onTimeoutChanged(it.toInt()) }, + valueRange = 5f..60f, + stepSize = 5, + ) + + Text( + text = stringResource(R.string.action_shell_command_execution_mode_label), + style = MaterialTheme.typography.titleMedium, + ) + + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + buttonStates = listOf( + ShellExecutionMode.STANDARD to + stringResource(R.string.action_shell_command_execution_mode_standard), + ShellExecutionMode.ROOT to + stringResource(R.string.action_shell_command_execution_mode_root), + ShellExecutionMode.ADB to + stringResource(R.string.action_shell_command_execution_mode_adb), + ), + selectedState = state.executionMode, + onStateSelected = onExecutionModeChanged, + ) + + if (state.executionMode == ShellExecutionMode.ADB && + state.proModeStatus != ProModeStatus.ENABLED + ) { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = onSetupProModeClick, + enabled = state.proModeStatus != ProModeStatus.UNSUPPORTED, + ) { + Text( + if (state.proModeStatus == ProModeStatus.UNSUPPORTED) { + stringResource(R.string.action_shell_command_setup_pro_mode_unsupported) + } else { + stringResource(R.string.action_shell_command_setup_pro_mode) + }, + ) + } + } + + Button( + modifier = Modifier.align(Alignment.End), + onClick = { + keyboardController?.hide() + onTestClick() + }, + enabled = !state.isRunning && + ( + state.executionMode != ShellExecutionMode.ADB || + ( + state.executionMode == ShellExecutionMode.ADB && + state.proModeStatus == ProModeStatus.ENABLED + ) + ), + ) { + Icon(Icons.Rounded.PlayArrow, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text( + if (state.isRunning) { + stringResource(R.string.action_shell_command_testing) + } else { + stringResource(R.string.action_shell_command_test_button) + }, + ) + } + } +} + +@Composable +private fun ShellCommandOutputContent( + modifier: Modifier = Modifier, + state: ShellCommandActionState, + onKillClick: () -> Unit, +) { + val context = LocalContext.current + + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (state.isRunning) { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = onKillClick, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon(Icons.Rounded.Close, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.pos_kill)) + } + } + + if (state.isRunning) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + ) + + if (state.executionMode == ShellExecutionMode.ADB) { + Text( + text = stringResource(R.string.action_shell_command_adb_streaming_warning), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + when (val result = state.testResult) { + null -> { + if (!state.isRunning) { + Text( + text = stringResource(R.string.action_shell_command_no_output), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is Success -> { + val shellResult = result.value + + if (shellResult.isSuccess()) { + Text( + text = stringResource(R.string.action_shell_command_output_label), + style = MaterialTheme.typography.titleMedium, + ) + } else if (shellResult.isError()) { + Text( + text = stringResource(R.string.action_shell_command_test_failed), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + } + + OutputTextField(text = shellResult.stdout, isError = shellResult.isError()) + + val exitCode = result.value.exitCode + + if (exitCode != null) { + Text( + text = stringResource( + R.string.action_shell_command_exit_code, + exitCode, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is KMError -> { + Text( + text = stringResource(R.string.action_shell_command_test_failed), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + + Text( + text = result.getFullMessage(context), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + + if (result is KMError.ShellCommandTimeout && result.stdout != null) { + OutputTextField(text = result.stdout!!, isError = true) + } + } + } + } +} + +@Composable +private fun OutputTextField(modifier: Modifier = Modifier, text: String, isError: Boolean) { + SelectionContainer(modifier = modifier) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = text, + onValueChange = {}, + readOnly = true, + minLines = 5, + maxLines = 15, + isError = isError, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreen() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "Hello world script", + command = "echo 'Hello World'", + executionMode = ShellExecutionMode.STANDARD, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreenEmpty() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "", + command = "", + executionMode = ShellExecutionMode.ROOT, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreenError() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "Read secret file", + command = "cat /root/secret.txt", + executionMode = ShellExecutionMode.ROOT, + testResult = SystemError.PermissionDenied(Permission.ROOT), + ), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreenShellError() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "", + command = "ls", + executionMode = ShellExecutionMode.ROOT, + testResult = Success( + ShellResult( + stdout = "ls: .: Permission denied", + exitCode = 1, + ), + ), + ), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreenTesting() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "Count to 10", + command = "for i in \$(seq 1 10); do echo \"Line \$i\"; sleep 1; done", + executionMode = ShellExecutionMode.STANDARD, + isRunning = true, + testResult = Success(ShellResult("Line 1\nLine 2\nLine 3\nLine 4\nLine 5", 0)), + ), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandActionScreenProModeUnsupported() { + KeyMapperTheme { + ShellCommandActionScreen( + state = ShellCommandActionState( + description = "ADB command example", + command = "echo 'Hello from ADB'", + executionMode = ShellExecutionMode.ADB, + proModeStatus = ProModeStatus.UNSUPPORTED, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewShellCommandOutputSuccess() { + KeyMapperTheme { + Surface { + ShellCommandOutputContent( + state = ShellCommandActionState( + description = "Hello world script", + command = "echo 'Hello World'", + executionMode = ShellExecutionMode.STANDARD, + testResult = Success(ShellResult("Hello World\nNew line\nNew new line", 0)), + ), + onKillClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewShellCommandOutputError() { + KeyMapperTheme { + Surface { + ShellCommandOutputContent( + state = ShellCommandActionState( + description = "Read secret file", + command = "cat /root/secret.txt", + executionMode = ShellExecutionMode.ROOT, + testResult = SystemError.PermissionDenied(Permission.ROOT), + ), + onKillClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewShellCommandOutpuTimeout() { + KeyMapperTheme { + Surface { + ShellCommandOutputContent( + state = ShellCommandActionState( + description = "Read secret file", + command = "cat /root/secret.txt", + executionMode = ShellExecutionMode.ROOT, + testResult = KMError.ShellCommandTimeout(1000L, "1\n2\n3"), + ), + onKillClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewShellCommandOutputShellError() { + KeyMapperTheme { + Surface { + ShellCommandOutputContent( + state = ShellCommandActionState( + description = "List files", + command = "ls", + executionMode = ShellExecutionMode.ROOT, + testResult = Success( + ShellResult( + stdout = "ls: .: Permission denied", + exitCode = 1, + ), + ), + ), + onKillClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewShellCommandOutputRunning() { + KeyMapperTheme { + Surface { + ShellCommandOutputContent( + state = ShellCommandActionState( + description = "Count to 10", + command = "for i in $(seq 1 10); do echo \"Line \$i\"; sleep 1; done", + executionMode = ShellExecutionMode.STANDARD, + isRunning = true, + testResult = Success( + ShellResult( + "Line 1\nLine 2\nLine 3\nLine 4\nLine 5", + 0, + ), + ), + ), + onKillClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewShellCommandOutputEmpty() { + KeyMapperTheme { + Surface { + ShellCommandOutputContent( + state = ShellCommandActionState( + description = "No output yet", + command = "echo 'Hello World'", + executionMode = ShellExecutionMode.STANDARD, + ), + onKillClick = {}, + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/SmsActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/SmsActionBottomSheet.kt new file mode 100644 index 0000000000..a4ea1ee42f --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/SmsActionBottomSheet.kt @@ -0,0 +1,420 @@ +package io.github.sds100.keymapper.base.actions + +import android.telephony.SmsManager +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +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.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +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.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.Success +import kotlinx.coroutines.launch + +sealed class SmsActionBottomSheetState { + abstract val number: String + abstract val message: String + + data class SendSms( + override val number: String, + override val message: String, + val testResult: State>?, + ) : SmsActionBottomSheetState() + + data class ComposeSms(override val number: String, override val message: String) : + SmsActionBottomSheetState() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SmsActionBottomSheet(delegate: CreateActionDelegate) { + rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (delegate.smsActionBottomSheetState != null) { + SmsActionBottomSheet( + sheetState = sheetState, + onDismissRequest = { + delegate.smsActionBottomSheetState = null + }, + state = delegate.smsActionBottomSheetState!!, + onNumberChanged = { + delegate.smsActionBottomSheetState = + when (val state = delegate.smsActionBottomSheetState) { + is SmsActionBottomSheetState.ComposeSms -> state.copy(number = it) + is SmsActionBottomSheetState.SendSms -> state.copy(number = it) + null -> null + } + }, + onMessageChanged = { + delegate.smsActionBottomSheetState = + when (val state = delegate.smsActionBottomSheetState) { + is SmsActionBottomSheetState.ComposeSms -> state.copy(message = it) + is SmsActionBottomSheetState.SendSms -> state.copy(message = it) + null -> null + } + }, + onTestClick = delegate::onTestSmsClick, + onDoneClick = delegate::onDoneSmsClick, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SmsActionBottomSheet( + sheetState: SheetState, + onDismissRequest: () -> Unit, + state: SmsActionBottomSheetState, + onTestClick: () -> Unit = {}, + onNumberChanged: (String) -> Unit = {}, + onMessageChanged: (String) -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + + val numberEmptyErrorString = stringResource(R.string.error_cant_be_empty) + val messageEmptyErrorString = stringResource(R.string.error_cant_be_empty) + + var numberError: String? by rememberSaveable { mutableStateOf(null) } + var messageError: String? by rememberSaveable { mutableStateOf(null) } + + val title = when (state) { + is SmsActionBottomSheetState.SendSms -> stringResource(R.string.action_send_sms) + is SmsActionBottomSheetState.ComposeSms -> stringResource(R.string.action_compose_sms) + } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + ) { + Column( + modifier = Modifier.verticalScroll(scrollState), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + textAlign = TextAlign.Center, + text = title, + style = MaterialTheme.typography.headlineMedium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + value = state.number, + label = { Text(stringResource(R.string.hint_create_sms_action_number)) }, + onValueChange = { + numberError = null + onNumberChanged(it) + }, + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + ), + isError = numberError != null, + supportingText = { + if (numberError != null) { + Text( + text = numberError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + value = state.message, + label = { Text(stringResource(R.string.hint_create_sms_action_message)) }, + onValueChange = { + messageError = null + onMessageChanged(it) + }, + minLines = 3, + maxLines = 5, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text, + ), + isError = messageError != null, + supportingText = { + if (messageError != null) { + Text( + text = messageError!!, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = stringResource(R.string.warning_sms_charges), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (state is SmsActionBottomSheetState.SendSms) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + when (state.testResult) { + is State.Data -> { + val result = state.testResult.data + + val resultText: String = when (result) { + is Success -> stringResource(R.string.test_sms_result_ok) + is KMError -> result.getFullMessage(LocalContext.current) + } + + val textColor = when (result) { + is Success -> LocalCustomColorsPalette.current.green + is KMError -> MaterialTheme.colorScheme.error + } + + Text( + modifier = Modifier.weight(1f), + text = resultText, + color = textColor, + style = MaterialTheme.typography.bodyMedium, + ) + } + + State.Loading -> { + CircularProgressIndicator() + } + + null -> {} + } + + Spacer(modifier = Modifier.width(16.dp)) + + OutlinedButton( + onClick = { + var hasError = false + + if (state.number.isBlank()) { + numberError = numberEmptyErrorString + hasError = true + } + + if (state.message.isBlank()) { + messageError = messageEmptyErrorString + hasError = true + } + + if (!hasError) { + onTestClick() + } + }, + ) { + Text(stringResource(R.string.button_test_sms)) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 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 = { + var hasError = false + + if (state.number.isBlank()) { + numberError = numberEmptyErrorString + hasError = true + } + + if (state.message.isBlank()) { + messageError = messageEmptyErrorString + hasError = true + } + + if (!hasError) { + onDoneClick() + } + }, + ) { + Text(stringResource(R.string.pos_done)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + SmsActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = SmsActionBottomSheetState.SendSms( + "+1 123456789", + "Message", + testResult = State.Loading, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewTestError() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + SmsActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = SmsActionBottomSheetState.SendSms( + "+1 123456789", + "Message", + testResult = State.Data(KMError.SendSmsError(SmsManager.RESULT_ERROR_NO_SERVICE)), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewTestSuccess() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + SmsActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = SmsActionBottomSheetState.SendSms( + "+1 123456789", + "Message", + testResult = State.Data(Success(Unit)), + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewEmpty() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + SmsActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = SmsActionBottomSheetState.SendSms( + "", + "", + testResult = null, + ), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/TestActionUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/TestActionUseCase.kt index 5459f60f53..2b60de0006 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/TestActionUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/TestActionUseCase.kt @@ -7,7 +7,8 @@ import javax.inject.Inject class TestActionUseCaseImpl @Inject constructor( private val serviceAdapter: AccessibilityServiceAdapter, ) : TestActionUseCase { - override suspend fun invoke(action: ActionData): KMResult<*> = serviceAdapter.send(TestActionEvent(action)) + override suspend fun invoke(action: ActionData): KMResult<*> = + serviceAdapter.send(TestActionEvent(action)) } interface TestActionUseCase { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/VolumeActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/VolumeActionBottomSheet.kt new file mode 100644 index 0000000000..c55738d03a --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/VolumeActionBottomSheet.kt @@ -0,0 +1,266 @@ +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.height +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.VolumeUp +import androidx.compose.material.icons.outlined.Visibility +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.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +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.VolumeStreamStrings +import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow +import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText +import io.github.sds100.keymapper.system.volume.VolumeStream +import kotlinx.coroutines.launch + +data class VolumeActionBottomSheetState( + val actionId: ActionId, + val volumeStream: VolumeStream?, + val showVolumeUi: Boolean, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VolumeActionBottomSheet(delegate: CreateActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (delegate.volumeActionState != null) { + val state = delegate.volumeActionState!! + val title = when (state.actionId) { + ActionId.VOLUME_UP -> stringResource(R.string.action_volume_up) + ActionId.VOLUME_DOWN -> stringResource(R.string.action_volume_down) + else -> "" + } + + VolumeActionBottomSheet( + sheetState = sheetState, + onDismissRequest = { + delegate.volumeActionState = null + }, + state = state, + title = title, + onSelectStream = { + delegate.volumeActionState = delegate.volumeActionState?.copy(volumeStream = it) + }, + onToggleShowVolumeUi = { + delegate.volumeActionState = delegate.volumeActionState?.copy(showVolumeUi = it) + }, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneConfigVolumeClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VolumeActionBottomSheet( + sheetState: SheetState, + onDismissRequest: () -> Unit, + state: VolumeActionBottomSheetState, + title: String, + onSelectStream: (VolumeStream?) -> Unit = {}, + onToggleShowVolumeUi: (Boolean) -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + ) { + Column( + modifier = Modifier.verticalScroll(scrollState), + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + textAlign = TextAlign.Center, + text = title, + style = MaterialTheme.typography.headlineMedium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.AutoMirrored.Outlined.VolumeUp, + text = stringResource(R.string.action_config_volume_stream), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Default stream option (null means use system default) + RadioButtonText( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(R.string.action_config_volume_stream_default), + isSelected = state.volumeStream == null, + onSelected = { onSelectStream(null) }, + ) + + // Individual stream options + VolumeStream.entries.forEach { stream -> + RadioButtonText( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(VolumeStreamStrings.getLabel(stream)), + isSelected = state.volumeStream == stream, + onSelected = { onSelectStream(stream) }, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Outlined.Visibility, + text = stringResource(R.string.action_config_volume_options), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + CheckBoxText( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(R.string.flag_show_volume_dialog), + isChecked = state.showVolumeUi, + onCheckedChange = onToggleShowVolumeUi, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 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 = onDoneClick, + ) { + Text(stringResource(R.string.pos_done)) + } + } + + Spacer(Modifier.height(16.dp)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewVolumeActionBottomSheet() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + var state by remember { + mutableStateOf( + VolumeActionBottomSheetState( + actionId = ActionId.VOLUME_UP, + volumeStream = VolumeStream.MUSIC, + showVolumeUi = true, + ), + ) + } + + VolumeActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = state, + title = stringResource(R.string.action_volume_up), + onSelectStream = { state = state.copy(volumeStream = it) }, + onToggleShowVolumeUi = { state = state.copy(showVolumeUi = it) }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewVolumeActionBottomSheetDefaultStream() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + var state by remember { + mutableStateOf( + VolumeActionBottomSheetState( + actionId = ActionId.VOLUME_DOWN, + volumeStream = null, + showVolumeUi = false, + ), + ) + } + + VolumeActionBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = state, + title = stringResource(R.string.action_volume_down), + onSelectStream = { state = state.copy(volumeStream = it) }, + onToggleShowVolumeUi = { state = state.copy(showVolumeUi = it) }, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ChooseKeyCodeViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ChooseKeyCodeViewModel.kt index 67987c742a..e20dc7051c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ChooseKeyCodeViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ChooseKeyCodeViewModel.kt @@ -8,7 +8,8 @@ import io.github.sds100.keymapper.base.utils.filterByQuery import io.github.sds100.keymapper.base.utils.ui.DefaultSimpleListItem import io.github.sds100.keymapper.base.utils.ui.SimpleListItemOld import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.system.inputevents.InputEventUtils +import io.github.sds100.keymapper.system.inputevents.KeyEventUtils +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -19,7 +20,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject @HiltViewModel class ChooseKeyCodeViewModel @Inject constructor() : ViewModel() { @@ -31,7 +31,7 @@ class ChooseKeyCodeViewModel @Inject constructor() : ViewModel() { private val allListItems = flow { withContext(Dispatchers.Default) { - InputEventUtils.getKeyCodes().sorted().map { keyCode -> + KeyEventUtils.getKeyCodes().sorted().map { keyCode -> DefaultSimpleListItem( id = keyCode.toString(), title = "$keyCode \t\t ${KeyEvent.keyCodeToString(keyCode)}", diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ConfigKeyEventActionFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ConfigKeyEventActionFragment.kt index 4f2b33a30b..d347b73f18 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ConfigKeyEventActionFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ConfigKeyEventActionFragment.kt @@ -82,7 +82,11 @@ class ConfigKeyEventActionFragment : Fragment() { ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> val insets = - insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime()) + insets.getInsets( + WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() or + WindowInsetsCompat.Type.ime(), + ) v.updatePadding(insets.left, insets.top, insets.right, insets.bottom) WindowInsetsCompat.CONSUMED } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ConfigKeyEventActionViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ConfigKeyEventActionViewModel.kt index 02fd7545a0..d167f813c0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ConfigKeyEventActionViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ConfigKeyEventActionViewModel.kt @@ -7,7 +7,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.ActionData -import io.github.sds100.keymapper.base.utils.InputEventStrings +import io.github.sds100.keymapper.base.utils.KeyCodeStrings import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider @@ -15,6 +15,8 @@ import io.github.sds100.keymapper.base.utils.navigation.NavigationProviderImpl import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.CheckBoxListItem import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.utils.InputDeviceInfo +import io.github.sds100.keymapper.common.utils.InputDeviceUtils import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success @@ -26,8 +28,7 @@ import io.github.sds100.keymapper.common.utils.minusFlag import io.github.sds100.keymapper.common.utils.success import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.common.utils.withFlag -import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import io.github.sds100.keymapper.system.devices.InputDeviceUtils +import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -37,7 +38,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class ConfigKeyEventActionViewModel @Inject constructor( @@ -105,7 +105,6 @@ class ConfigKeyEventActionViewModel @Inject constructor( keyEventState.value = KeyEventState( Success(action.keyCode), inputDevice, - useShell = action.useShell, metaState = action.metaState, ) } @@ -121,10 +120,6 @@ class ConfigKeyEventActionViewModel @Inject constructor( keyEventState.value = keyEventState.value.copy(keyCode = keyCodeState) } - fun setUseShell(checked: Boolean) { - keyEventState.value = keyEventState.value.copy(useShell = checked) - } - @SuppressLint("NullSafeMutableLiveData") fun chooseNoDevice() { keyEventState.value = keyEventState.value.copy(chosenDevice = null) @@ -157,7 +152,6 @@ class ConfigKeyEventActionViewModel @Inject constructor( ActionData.InputKeyEvent( keyCode = keyCode, metaState = keyEventState.value.metaState, - useShell = keyEventState.value.useShell, device = device, ), ) @@ -171,7 +165,6 @@ class ConfigKeyEventActionViewModel @Inject constructor( ): ConfigKeyEventUiState { val keyCode = state.keyCode val metaState = state.metaState - val useShell = state.useShell val chosenDevice = state.chosenDevice val keyCodeString = when (keyCode) { @@ -190,7 +183,7 @@ class ConfigKeyEventActionViewModel @Inject constructor( onError = { "" }, ) - val modifierListItems = InputEventStrings.MODIFIER_LABELS.map { (modifier, label) -> + val modifierListItems = KeyCodeStrings.MODIFIER_LABELS.map { (modifier, label) -> CheckBoxListItem( id = modifier.toString(), label = getString(label), @@ -226,9 +219,8 @@ class ConfigKeyEventActionViewModel @Inject constructor( keyCodeErrorMessage = keyCode.errorOrNull()?.getFullMessage(this), keyCodeLabel = keyCodeLabel, showKeyCodeLabel = keyCode.isSuccess, - isUseShellChecked = useShell, - isDevicePickerShown = !useShell, - isModifierListShown = !useShell, + isDevicePickerShown = true, + isModifierListShown = true, modifierListItems = modifierListItems, isDoneButtonEnabled = keyCode.isSuccess, deviceListItems = deviceListItems, @@ -239,7 +231,6 @@ class ConfigKeyEventActionViewModel @Inject constructor( private data class KeyEventState( val keyCode: KMResult = KMError.EmptyText, val chosenDevice: InputDeviceInfo? = null, - val useShell: Boolean = false, val metaState: Int = 0, ) } @@ -249,7 +240,6 @@ data class ConfigKeyEventUiState( val keyCodeErrorMessage: String?, val keyCodeLabel: String, val showKeyCodeLabel: Boolean, - val isUseShellChecked: Boolean, val isDevicePickerShown: Boolean, val isModifierListShown: Boolean, val modifierListItems: List, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ConfigKeyEventUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ConfigKeyEventUseCase.kt index a304220fab..6ed84cb173 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ConfigKeyEventUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/ConfigKeyEventUseCase.kt @@ -1,13 +1,13 @@ package io.github.sds100.keymapper.base.actions.keyevent +import io.github.sds100.keymapper.common.utils.InputDeviceInfo import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.system.devices.DevicesAdapter -import io.github.sds100.keymapper.system.devices.InputDeviceInfo +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import javax.inject.Inject class ConfigKeyEventUseCaseImpl @Inject constructor( private val preferenceRepository: PreferenceRepository, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt new file mode 100644 index 0000000000..f3dc4e3b01 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionBottomSheet.kt @@ -0,0 +1,372 @@ +package io.github.sds100.keymapper.base.actions.keyevent + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Keyboard +import androidx.compose.material.icons.rounded.Remove +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +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.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.base.utils.ProModeStatus +import io.github.sds100.keymapper.base.utils.ui.compose.AccessibilityServiceRequirementRow +import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.HeaderText +import io.github.sds100.keymapper.base.utils.ui.compose.InputMethodRequirementRow +import io.github.sds100.keymapper.base.utils.ui.compose.ProModeRequirementRow +import io.github.sds100.keymapper.base.utils.ui.compose.filledTonalButtonColorsError +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.utils.ui.compose.icons.ProModeIcon + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FixKeyEventActionBottomSheet( + modifier: Modifier = Modifier, + state: FixKeyEventActionState, + sheetState: SheetState, + onDismissRequest: () -> Unit = {}, + onSelectInputMethod: () -> Unit = {}, + onSelectProMode: () -> Unit = {}, + onEnableAccessibilityServiceClick: () -> Unit = {}, + onEnableProModeClick: () -> Unit = {}, + onEnableInputMethodClick: () -> Unit = {}, + onChooseInputMethodClick: () -> Unit = {}, + onDoneClick: () -> Unit = {}, + onAutoSwitchImeCheckedChange: (Boolean) -> Unit = {}, +) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismissRequest, + sheetState = sheetState, + // Hide drag handle because other bottom sheets don't have it + dragHandle = {}, + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + modifier = Modifier.align(Alignment.CenterHorizontally), + text = stringResource(R.string.fix_key_event_action_title), + style = MaterialTheme.typography.titleLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Column( + modifier = Modifier + .animateContentSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(stringResource(R.string.fix_key_event_action_text)) + + FixKeyEventActionOptionCard( + onClick = onSelectInputMethod, + selected = state is FixKeyEventActionState.InputMethod, + title = stringResource(R.string.fix_key_event_action_input_method_title), + icon = Icons.Rounded.Keyboard, + ) { + val annotatedText = buildAnnotatedString { + appendInlineContent("icon", "[icon]") + append(" ") + append(stringResource(R.string.fix_key_event_action_input_method_text)) + } + val inlineContent = mapOf( + Pair( + "icon", + InlineTextContent( + Placeholder( + width = MaterialTheme.typography.bodyLarge.fontSize, + height = MaterialTheme.typography.bodyLarge.fontSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + imageVector = Icons.Rounded.Remove, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ), + ) + Text( + annotatedText, + inlineContent = inlineContent, + style = MaterialTheme.typography.bodyMedium, + ) + } + + val isProModeUnsupported = state.proModeStatus == ProModeStatus.UNSUPPORTED + + FixKeyEventActionOptionCard( + onClick = onSelectProMode, + selected = state is FixKeyEventActionState.ProMode, + title = stringResource(R.string.pro_mode_app_bar_title), + icon = KeyMapperIcons.ProModeIcon, + enabled = !isProModeUnsupported, + ) { + if (isProModeUnsupported) { + Text( + stringResource(R.string.trigger_setup_pro_mode_unsupported), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + val annotatedText = buildAnnotatedString { + appendInlineContent("icon", "[icon]") + append(" ") + append(stringResource(R.string.fix_key_event_action_pro_mode_text_1)) + appendLine() + appendInlineContent("icon", "[icon]") + append(" ") + append(stringResource(R.string.fix_key_event_action_pro_mode_text_2)) + } + val inlineContent = mapOf( + Pair( + "icon", + InlineTextContent( + Placeholder( + width = MaterialTheme.typography.bodyLarge.fontSize, + height = MaterialTheme.typography.bodyLarge.fontSize, + placeholderVerticalAlign = + PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + tint = LocalCustomColorsPalette.current.green, + ) + }, + ), + ) + Text( + annotatedText, + inlineContent = inlineContent, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + Text( + stringResource(R.string.fix_key_event_action_change_in_settings_caption), + style = MaterialTheme.typography.labelMedium, + ) + } + + HeaderText(text = stringResource(R.string.fix_key_event_action_setup_title)) + + AccessibilityServiceRequirementRow( + modifier = Modifier.fillMaxWidth(), + isServiceEnabled = state.isAccessibilityServiceEnabled, + buttonColors = ButtonDefaults.filledTonalButtonColorsError(), + onClick = onEnableAccessibilityServiceClick, + ) + + when (state) { + is FixKeyEventActionState.InputMethod -> { + InputMethodRequirementRow( + modifier = Modifier.fillMaxWidth(), + isChosen = state.isChosen, + isEnabled = state.isEnabled, + enablingRequiresUserInput = state.enablingRequiresUserInput, + buttonColors = ButtonDefaults.filledTonalButtonColorsError(), + onEnableClick = onEnableInputMethodClick, + onChooseClick = onChooseInputMethodClick, + ) + + HeaderText(text = stringResource(R.string.fix_key_event_action_options_title)) + + CheckBoxText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.fix_key_event_action_auto_switch_ime_text), + isChecked = state.isAutoSwitchImeEnabled, + onCheckedChange = onAutoSwitchImeCheckedChange, + ) + } + + is FixKeyEventActionState.ProMode -> { + ProModeRequirementRow( + modifier = Modifier.fillMaxWidth(), + isVisible = true, + proModeStatus = state.proModeStatus, + buttonColors = ButtonDefaults.filledTonalButtonColorsError(), + onClick = onEnableProModeClick, + ) + } + } + + Button(modifier = Modifier.align(Alignment.End), onClick = onDoneClick) { + Text(stringResource(R.string.pos_done)) + } + } + } +} + +@Composable +private fun FixKeyEventActionOptionCard( + modifier: Modifier = Modifier, + onClick: () -> Unit, + selected: Boolean, + title: String, + icon: ImageVector, + enabled: Boolean = true, + content: @Composable ColumnScope.() -> Unit, +) { + val cardBorder = if (selected) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + CardDefaults.outlinedCardBorder() + } + + val cardElevation = if (selected) { + CardDefaults.outlinedCardElevation(defaultElevation = 1.dp) + } else { + CardDefaults.outlinedCardElevation() + } + + OutlinedCard( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + enabled = enabled, + border = cardBorder, + elevation = cardElevation, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 16.dp, end = 8.dp, bottom = 16.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(icon, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + content() + } + RadioButton( + modifier = Modifier.align(Alignment.CenterVertically), + selected = selected, + onClick = onClick, + enabled = enabled, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun InputMethodPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + FixKeyEventActionBottomSheet( + sheetState = sheetState, + state = FixKeyEventActionState.InputMethod( + isEnabled = true, + isChosen = true, + enablingRequiresUserInput = true, + isAccessibilityServiceEnabled = true, + isAutoSwitchImeEnabled = true, + proModeStatus = ProModeStatus.ENABLED, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun ProModePreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + FixKeyEventActionBottomSheet( + sheetState = sheetState, + state = FixKeyEventActionState.ProMode( + proModeStatus = ProModeStatus.DISABLED, + isAccessibilityServiceEnabled = true, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun ProModeUnsupportedPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + FixKeyEventActionBottomSheet( + sheetState = sheetState, + state = FixKeyEventActionState.InputMethod( + proModeStatus = ProModeStatus.UNSUPPORTED, + isEnabled = false, + isChosen = false, + enablingRequiresUserInput = true, + isAutoSwitchImeEnabled = false, + isAccessibilityServiceEnabled = true, + ), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt new file mode 100644 index 0000000000..48a8a16c5c --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionDelegate.kt @@ -0,0 +1,184 @@ +package io.github.sds100.keymapper.base.actions.keyevent + +import android.os.Build +import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate +import io.github.sds100.keymapper.base.system.accessibility.ControlAccessibilityServiceUseCase +import io.github.sds100.keymapper.base.trigger.SetupInputMethodUseCase +import io.github.sds100.keymapper.base.utils.ProModeStatus +import io.github.sds100.keymapper.base.utils.navigation.NavDestination +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate +import io.github.sds100.keymapper.base.utils.ui.DialogProvider +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.common.utils.onFailure +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.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState +import javax.inject.Inject +import javax.inject.Named +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import timber.log.Timber + +@ViewModelScoped +class FixKeyEventActionDelegateImpl @Inject constructor( + @Named("viewmodel") + val viewModelScope: CoroutineScope, + val controlAccessibilityServiceUseCase: ControlAccessibilityServiceUseCase, + val systemBridgeConnectionManager: SystemBridgeConnectionManager, + val setupInputMethodUseCase: SetupInputMethodUseCase, + val preferenceRepository: PreferenceRepository, + val setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, + resourceProvider: ResourceProvider, + dialogProvider: DialogProvider, + navigationProvider: NavigationProvider, +) : FixKeyEventActionDelegate, + ResourceProvider by resourceProvider, + DialogProvider by dialogProvider, + NavigationProvider by navigationProvider { + + private val proModeStatus: Flow = + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + systemBridgeConnectionManager.connectionState.map { state -> + when (state) { + is SystemBridgeConnectionState.Connected -> ProModeStatus.ENABLED + is SystemBridgeConnectionState.Disconnected -> ProModeStatus.DISABLED + } + } + } else { + flowOf(ProModeStatus.UNSUPPORTED) + } + + private val isProModeSelected: Flow = + preferenceRepository.get(Keys.keyEventActionsUseSystemBridge) + .map { it ?: PreferenceDefaults.KEY_EVENT_ACTIONS_USE_SYSTEM_BRIDGE } + + private val showBottomSheet: MutableStateFlow = MutableStateFlow(false) + + @OptIn(ExperimentalCoroutinesApi::class) + override val fixKeyEventActionState: StateFlow = + showBottomSheet.flatMapLatest { showBottomSheet -> + if (showBottomSheet) { + buildStateFlow() + } else { + flowOf(null) + } + }.stateIn(viewModelScope, SharingStarted.Lazily, null) + + @OptIn(ExperimentalCoroutinesApi::class) + private fun buildStateFlow(): Flow { + return isProModeSelected.flatMapLatest { isProModeSelected -> + if (isProModeSelected) { + combine( + proModeStatus, + controlAccessibilityServiceUseCase.serviceState, + ) { proModeStatus, serviceState -> + FixKeyEventActionState.ProMode( + proModeStatus = proModeStatus, + isAccessibilityServiceEnabled = + serviceState == AccessibilityServiceState.ENABLED, + ) + } + } else { + combine( + proModeStatus, + setupInputMethodUseCase.isEnabled, + setupInputMethodUseCase.isChosen, + controlAccessibilityServiceUseCase.serviceState, + preferenceRepository.get(Keys.changeImeOnInputFocus), + ) { proModeStatus, isEnabled, isChosen, serviceState, changeImeOnInputFocus -> + val enablingRequiresUserInput = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU + + FixKeyEventActionState.InputMethod( + isEnabled = isEnabled, + isChosen = isChosen, + enablingRequiresUserInput = enablingRequiresUserInput, + isAccessibilityServiceEnabled = + serviceState == AccessibilityServiceState.ENABLED, + proModeStatus = proModeStatus, + isAutoSwitchImeEnabled = changeImeOnInputFocus + ?: PreferenceDefaults.CHANGE_IME_ON_INPUT_FOCUS, + ) + } + } + } + } + + override fun showFixKeyEventActionBottomSheet() { + showBottomSheet.value = true + } + + override fun dismissFixKeyEventActionBottomSheet() { + showBottomSheet.value = false + } + + override fun onEnableAccessibilityServiceClick() { + viewModelScope.launch { + setupAccessibilityServiceDelegate.showEnableAccessibilityServiceDialog() + } + } + + override fun onEnableProModeForKeyEventActionsClick() { + viewModelScope.launch { + navigate("fix_key_event_action_pro_mode", NavDestination.ProMode) + } + } + + override fun onEnableImeClick() { + viewModelScope.launch { + setupInputMethodUseCase.enableInputMethod() + } + } + + override fun onChooseImeClick() { + viewModelScope.launch { + setupInputMethodUseCase.chooseInputMethod().onFailure { + Timber.e("Failed to choose input method when fixing key event action. Error: $it") + } + } + } + + override fun onSelectProMode() { + preferenceRepository.set(Keys.keyEventActionsUseSystemBridge, true) + } + + override fun onSelectInputMethod() { + preferenceRepository.set(Keys.keyEventActionsUseSystemBridge, false) + } + + override fun onAutoSwitchImeCheckedChange(checked: Boolean) { + viewModelScope.launch { + preferenceRepository.set(Keys.changeImeOnInputFocus, checked) + } + } +} + +interface FixKeyEventActionDelegate { + val fixKeyEventActionState: StateFlow + + fun showFixKeyEventActionBottomSheet() + fun dismissFixKeyEventActionBottomSheet() + fun onEnableAccessibilityServiceClick() + fun onEnableProModeForKeyEventActionsClick() + fun onEnableImeClick() + fun onChooseImeClick() + fun onSelectProMode() + fun onSelectInputMethod() + fun onAutoSwitchImeCheckedChange(checked: Boolean) +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionState.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionState.kt new file mode 100644 index 0000000000..5bacfc2db5 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/FixKeyEventActionState.kt @@ -0,0 +1,26 @@ +package io.github.sds100.keymapper.base.actions.keyevent + +import io.github.sds100.keymapper.base.utils.ProModeStatus + +sealed class FixKeyEventActionState { + abstract val isAccessibilityServiceEnabled: Boolean + abstract val proModeStatus: ProModeStatus + + data class InputMethod( + val isEnabled: Boolean, + val isChosen: Boolean, + /** + * Accessibility services can enable the input method on Android 13+ so only + * show one button that both enables and chooses the input method. + */ + val enablingRequiresUserInput: Boolean, + val isAutoSwitchImeEnabled: Boolean, + override val isAccessibilityServiceEnabled: Boolean, + override val proModeStatus: ProModeStatus, + ) : FixKeyEventActionState() + + data class ProMode( + override val isAccessibilityServiceEnabled: Boolean, + override val proModeStatus: ProModeStatus, + ) : FixKeyEventActionState() +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateFragment.kt index 8ed81bd516..ebcee5903c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateFragment.kt @@ -98,7 +98,11 @@ class PinchPickDisplayCoordinateFragment : Fragment() { ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> val insets = - insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime()) + insets.getInsets( + WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() or + WindowInsetsCompat.Type.ime(), + ) v.updatePadding(insets.left, insets.top, insets.right, insets.bottom) WindowInsetsCompat.CONSUMED } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt index 4450f70f65..6091f9beba 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt @@ -15,6 +15,8 @@ import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog import io.github.sds100.keymapper.common.utils.PinchScreenType +import javax.inject.Inject +import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -25,8 +27,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -import kotlin.math.roundToInt @HiltViewModel class PinchPickDisplayCoordinateViewModel @Inject constructor( @@ -98,7 +98,9 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( } if (count < 2) { - return@map resourceProvider.getString(R.string.error_pinch_screen_must_be_two_or_more_fingers) + return@map resourceProvider.getString( + R.string.error_pinch_screen_must_be_two_or_more_fingers, + ) } var maxFingerCount = 10 @@ -129,7 +131,9 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( } if (d <= 0) { - return@map resourceProvider.getString(R.string.error_pinch_screen_duration_must_be_more_than_zero) + return@map resourceProvider.getString( + R.string.error_pinch_screen_duration_must_be_more_than_zero, + ) } null @@ -145,7 +149,10 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( distance ?: return@combine false pinchType ?: return@combine false - x >= 0 && y >= 0 && distance > 0 && (pinchType == PinchScreenType.PINCH_IN || pinchType == PinchScreenType.PINCH_OUT) + x >= 0 && + y >= 0 && + distance > 0 && + (pinchType == PinchScreenType.PINCH_IN || pinchType == PinchScreenType.PINCH_OUT) }.stateIn(viewModelScope, SharingStarted.Lazily, false) val isDoneButtonEnabled: StateFlow = diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/ChooseSoundFileFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/ChooseSoundFileFragment.kt index ab1088467b..021115f4ad 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/ChooseSoundFileFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/ChooseSoundFileFragment.kt @@ -86,7 +86,11 @@ class ChooseSoundFileFragment : Fragment() { ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> val insets = - insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime()) + insets.getInsets( + WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() or + WindowInsetsCompat.Type.ime(), + ) v.updatePadding(insets.left, insets.top, insets.right, insets.bottom) WindowInsetsCompat.CONSUMED } @@ -101,7 +105,10 @@ class ChooseSoundFileFragment : Fragment() { viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.chooseSystemRingtone.collectLatest { - val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER) + val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply { + // Allow notification, alarms, and ringtones. + putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_ALL) + } chooseRingtoneLauncher.launch(intent) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/ChooseSoundFileUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/ChooseSoundFileUseCase.kt index 7f6566006d..bf684b2a6f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/ChooseSoundFileUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/ChooseSoundFileUseCase.kt @@ -4,8 +4,8 @@ import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.system.files.FileAdapter -import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject +import kotlinx.coroutines.flow.StateFlow class ChooseSoundFileUseCaseImpl @Inject constructor( private val fileAdapter: FileAdapter, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/ChooseSoundFileViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/ChooseSoundFileViewModel.kt index f858528742..e4faa7d148 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/ChooseSoundFileViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/ChooseSoundFileViewModel.kt @@ -14,6 +14,7 @@ import io.github.sds100.keymapper.base.utils.ui.showDialog import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.onSuccess import io.github.sds100.keymapper.common.utils.valueOrNull +import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -23,7 +24,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class ChooseSoundFileViewModel @Inject constructor( @@ -106,7 +106,9 @@ class ChooseSoundFileViewModel @Inject constructor( ) } }.onFailure { error -> - val toast = DialogModel.Toast(error.getFullMessage(this@ChooseSoundFileViewModel)) + val toast = DialogModel.Toast( + error.getFullMessage(this@ChooseSoundFileViewModel), + ) showDialog("failed_toast", toast) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/SoundsManager.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/SoundsManager.kt index a9ead62745..2533383fed 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/SoundsManager.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/sound/SoundsManager.kt @@ -8,14 +8,14 @@ import io.github.sds100.keymapper.common.utils.onSuccess import io.github.sds100.keymapper.common.utils.then import io.github.sds100.keymapper.system.files.FileAdapter import io.github.sds100.keymapper.system.files.IFile +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import timber.log.Timber -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton @Singleton class SoundsManagerImpl @Inject constructor( @@ -105,11 +105,12 @@ class SoundsManagerImpl @Inject constructor( .map { getSoundFileInfo(it.name!!) } } - private fun createSoundCopyFileName(originalSoundFile: IFile, uid: String): String = buildString { - append(originalSoundFile.baseName) - append("_$uid") - append(".${originalSoundFile.extension}") - } + private fun createSoundCopyFileName(originalSoundFile: IFile, uid: String): String = + buildString { + append(originalSoundFile.baseName) + append("_$uid") + append(".${originalSoundFile.extension}") + } } interface SoundsManager { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateFragment.kt index 888ae4826f..98757a26dc 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateFragment.kt @@ -89,7 +89,11 @@ class SwipePickDisplayCoordinateFragment : Fragment() { ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> val insets = - insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime()) + insets.getInsets( + WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() or + WindowInsetsCompat.Type.ime(), + ) v.updatePadding(insets.left, insets.top, insets.right, insets.bottom) WindowInsetsCompat.CONSUMED } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt index c5afe9ad3f..da694457b9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt @@ -12,6 +12,8 @@ import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog +import javax.inject.Inject +import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -22,8 +24,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -import kotlin.math.roundToInt enum class ScreenshotTouchType { START, @@ -89,7 +89,9 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( } if (count <= 0) { - return@map resourceProvider.getString(R.string.error_swipe_screen_fingercount_must_be_more_than_zero) + return@map resourceProvider.getString( + R.string.error_swipe_screen_fingercount_must_be_more_than_zero, + ) } var maxFingerCount = 10 @@ -120,7 +122,9 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( } if (d <= 0) { - return@map resourceProvider.getString(R.string.error_swipe_screen_duration_must_be_more_than_zero) + return@map resourceProvider.getString( + R.string.error_swipe_screen_duration_must_be_more_than_zero, + ) } null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickCoordinateImageView.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickCoordinateImageView.kt index fcbd125cf2..1cca565174 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickCoordinateImageView.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickCoordinateImageView.kt @@ -9,14 +9,11 @@ import android.view.MotionEvent import androidx.appcompat.widget.AppCompatImageView import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.utils.ui.color -import kotlinx.coroutines.flow.MutableStateFlow import kotlin.math.roundToInt +import kotlinx.coroutines.flow.MutableStateFlow -class PickCoordinateImageView( - context: Context, - attrs: AttributeSet?, - defStyleAttr: Int, -) : AppCompatImageView(context, attrs, defStyleAttr) { +class PickCoordinateImageView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : + AppCompatImageView(context, attrs, defStyleAttr) { constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0) constructor(context: Context) : this(context, null, 0) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateFragment.kt index 1de00aca0f..e8a8e88efb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateFragment.kt @@ -89,7 +89,11 @@ class PickDisplayCoordinateFragment : Fragment() { ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> val insets = - insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime()) + insets.getInsets( + WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() or + WindowInsetsCompat.Type.ime(), + ) v.updatePadding(insets.left, insets.top, insets.right, insets.bottom) WindowInsetsCompat.CONSUMED } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateViewModel.kt index 86afc6dc0f..723a201beb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateViewModel.kt @@ -10,6 +10,8 @@ import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog +import javax.inject.Inject +import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -20,8 +22,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -import kotlin.math.roundToInt @HiltViewModel class PickDisplayCoordinateViewModel @Inject constructor( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/ChooseUiElementScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/ChooseUiElementScreen.kt index 7da4fd9033..6a46ec1c02 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/ChooseUiElementScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/ChooseUiElementScreen.kt @@ -117,7 +117,9 @@ fun ChooseElementScreen( style = MaterialTheme.typography.titleLarge, ) - if (heightSizeClass == WindowHeightSizeClass.COMPACT || widthSizeClass >= WindowWidthSizeClass.EXPANDED) { + if (heightSizeClass == WindowHeightSizeClass.COMPACT || + widthSizeClass >= WindowWidthSizeClass.EXPANDED + ) { Row { InfoSection( modifier = Modifier @@ -179,7 +181,9 @@ private fun InfoSection( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = stringResource(R.string.action_interact_ui_element_choose_element_not_found_subtitle), + text = stringResource( + R.string.action_interact_ui_element_choose_element_not_found_subtitle, + ), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.titleSmall, ) @@ -189,7 +193,9 @@ private fun InfoSection( Text( modifier = Modifier.padding(horizontal = 16.dp), - text = stringResource(R.string.action_interact_ui_element_choose_element_not_found_text), + text = stringResource( + R.string.action_interact_ui_element_choose_element_not_found_text, + ), style = MaterialTheme.typography.bodyMedium, ) @@ -202,7 +208,9 @@ private fun InfoSection( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp), - text = stringResource(R.string.action_interact_ui_element_checkbox_additional_elements), + text = stringResource( + R.string.action_interact_ui_element_checkbox_additional_elements, + ), isChecked = state.data.showAdditionalElements, onCheckedChange = onAdditionalElementsCheckedChange, ) @@ -213,7 +221,13 @@ private fun InfoSection( modifier = Modifier.padding(horizontal = 16.dp), expanded = interactionTypeExpanded, onExpandedChange = { interactionTypeExpanded = it }, - label = { Text(stringResource(R.string.action_interact_ui_element_filter_interaction_type_dropdown)) }, + label = { + Text( + stringResource( + R.string.action_interact_ui_element_filter_interaction_type_dropdown, + ), + ) + }, values = state.data.interactionTypes, selectedValue = state.data.selectedInteractionType, onValueChanged = onSelectInteractionType, @@ -364,11 +378,7 @@ private fun UiElementListItem( } @Composable -private fun TextWithLeadingLabel( - modifier: Modifier = Modifier, - title: String, - text: String, -) { +private fun TextWithLeadingLabel(modifier: Modifier = Modifier, title: String, text: String) { val text = buildAnnotatedString { pushStyle( MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold).toSpanStyle(), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/InteractUiElementScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/InteractUiElementScreen.kt index 30c0d792c5..4658e5bef9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/InteractUiElementScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/InteractUiElementScreen.kt @@ -90,10 +90,7 @@ private const val DEST_SELECT_APP = "select_app" private const val DEST_SELECT_ELEMENT = "select_element" @Composable -fun InteractUiElementScreen( - modifier: Modifier = Modifier, - viewModel: InteractUiElementViewModel, -) { +fun InteractUiElementScreen(modifier: Modifier = Modifier, viewModel: InteractUiElementViewModel) { val navController = rememberNavController() val recordState by viewModel.recordState.collectAsStateWithLifecycle() @@ -117,10 +114,18 @@ fun InteractUiElementScreen( modifier = modifier, navController = navController, startDestination = DEST_LANDING, - enterTransition = { slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Left) }, - exitTransition = { slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right) }, - popEnterTransition = { slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right) }, - popExitTransition = { slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right) }, + enterTransition = { + slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Left) + }, + exitTransition = { + slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right) + }, + popEnterTransition = { + slideIntoContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right) + }, + popExitTransition = { + slideOutOfContainer(towards = AnimatedContentTransitionScope.SlideDirection.Right) + }, ) { composable(DEST_LANDING) { LandingScreen( @@ -247,7 +252,9 @@ private fun LandingScreen( style = MaterialTheme.typography.titleLarge, ) - if (heightSizeClass == WindowHeightSizeClass.COMPACT || widthSizeClass >= WindowWidthSizeClass.EXPANDED) { + if (heightSizeClass == WindowHeightSizeClass.COMPACT || + widthSizeClass >= WindowWidthSizeClass.EXPANDED + ) { Row { Column( modifier = Modifier @@ -259,7 +266,9 @@ private fun LandingScreen( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), - text = stringResource(R.string.action_interact_ui_element_description), + text = stringResource( + R.string.action_interact_ui_element_description, + ), style = MaterialTheme.typography.bodyMedium, ) @@ -358,7 +367,9 @@ private fun DisabledExtendedFloatingActionButton( horizontalArrangement = Arrangement.Start, ) { val contentColor = - MaterialTheme.colorScheme.contentColorFor(FloatingActionButtonDefaults.containerColor) + MaterialTheme.colorScheme.contentColorFor( + FloatingActionButtonDefaults.containerColor, + ) .copy(alpha = 0.5f) CompositionLocalProvider(LocalContentColor provides contentColor) { @@ -468,14 +479,14 @@ private fun InteractionCountBox( } @Composable -private fun RecordButton( - modifier: Modifier, - state: RecordUiElementState, - onClick: () -> Unit, -) { +private fun RecordButton(modifier: Modifier, state: RecordUiElementState, onClick: () -> Unit) { val text: String = when (state) { - is RecordUiElementState.Empty -> stringResource(R.string.action_interact_ui_element_start_recording) - is RecordUiElementState.Recorded -> stringResource(R.string.action_interact_ui_element_record_again) + is RecordUiElementState.Empty -> stringResource( + R.string.action_interact_ui_element_start_recording, + ) + is RecordUiElementState.Recorded -> stringResource( + R.string.action_interact_ui_element_record_again, + ) is RecordUiElementState.CountingDown -> stringResource( R.string.action_interact_ui_element_stop_recording, state.timeRemaining, @@ -635,7 +646,9 @@ private fun SelectedElementSection( Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(R.string.action_interact_ui_element_interaction_type_dropdown_caption), + text = stringResource( + R.string.action_interact_ui_element_interaction_type_dropdown_caption, + ), style = MaterialTheme.typography.bodyMedium, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/InteractUiElementUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/InteractUiElementUseCase.kt index e97be98810..78743dfea4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/InteractUiElementUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/InteractUiElementUseCase.kt @@ -11,6 +11,8 @@ import io.github.sds100.keymapper.data.entities.AccessibilityNodeEntity import io.github.sds100.keymapper.data.repositories.AccessibilityNodeRepository import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter import io.github.sds100.keymapper.system.apps.PackageManagerAdapter +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -20,8 +22,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update -import javax.inject.Inject -import javax.inject.Singleton @Singleton class InteractUiElementController @Inject constructor( @@ -49,7 +49,9 @@ class InteractUiElementController @Inject constructor( .launchIn(coroutineScope) } - override fun getInteractionsByPackage(packageName: String): Flow>> { + override fun getInteractionsByPackage( + packageName: String, + ): Flow>> { return nodeRepository.nodes.map { state -> state.mapData { nodes -> nodes.filter { it.packageName == packageName } @@ -61,9 +63,11 @@ class InteractUiElementController @Inject constructor( return nodeRepository.get(id) } - override fun getAppName(packageName: String): KMResult = packageManagerAdapter.getAppName(packageName) + override fun getAppName(packageName: String): KMResult = + packageManagerAdapter.getAppName(packageName) - override fun getAppIcon(packageName: String): KMResult = packageManagerAdapter.getAppIcon(packageName) + override fun getAppIcon(packageName: String): KMResult = + packageManagerAdapter.getAppIcon(packageName) override suspend fun startRecording(): KMResult<*> { nodeRepository.deleteAll() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/InteractUiElementViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/InteractUiElementViewModel.kt index 8782de377f..8563ddb334 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/InteractUiElementViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/uielement/InteractUiElementViewModel.kt @@ -7,15 +7,15 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.ActionData +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate import io.github.sds100.keymapper.base.system.accessibility.RecordAccessibilityNodeState import io.github.sds100.keymapper.base.utils.containsQuery import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider -import io.github.sds100.keymapper.base.utils.ui.ViewModelHelper import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemModel -import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.AccessibilityServiceError import io.github.sds100.keymapper.common.utils.NodeInteractionType import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.Success @@ -26,6 +26,8 @@ import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.then import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.data.entities.AccessibilityNodeEntity +import java.util.Locale +import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -41,16 +43,16 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import java.util.Locale -import javax.inject.Inject @HiltViewModel class InteractUiElementViewModel @Inject constructor( private val useCase: InteractUiElementUseCase, + setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, resourceProvider: ResourceProvider, dialogProvider: DialogProvider, navigationProvider: NavigationProvider, ) : ViewModel(), + SetupAccessibilityServiceDelegate by setupAccessibilityServiceDelegate, NavigationProvider by navigationProvider, DialogProvider by dialogProvider, ResourceProvider by resourceProvider { @@ -362,18 +364,8 @@ class InteractUiElementViewModel @Inject constructor( private suspend fun startRecording() { useCase.startRecording().onFailure { error -> - if (error == KMError.AccessibilityServiceDisabled) { - ViewModelHelper.handleAccessibilityServiceStoppedDialog( - this, - this, - startService = { useCase.startService() }, - ) - } else if (error == KMError.AccessibilityServiceCrashed) { - ViewModelHelper.handleAccessibilityServiceCrashedDialog( - this, - this, - restartService = { useCase.startService() }, - ) + if (error is AccessibilityServiceError) { + showFixAccessibilityServiceDialog(error) } } } @@ -405,7 +397,9 @@ class InteractUiElementViewModel @Inject constructor( ) } - private fun buildInteractionTypeFilterItems(interactionTypes: Set): List> { + private fun buildInteractionTypeFilterItems( + interactionTypes: Set, + ): List> { return buildList { // They should always be in the same order so iterate over the Enum entries. for (type in NodeInteractionType.entries) { @@ -423,13 +417,27 @@ class InteractUiElementViewModel @Inject constructor( private fun getInteractionTypeString(interactionType: NodeInteractionType): String { return when (interactionType) { - NodeInteractionType.CLICK -> getString(R.string.action_interact_ui_element_interaction_type_click) - NodeInteractionType.LONG_CLICK -> getString(R.string.action_interact_ui_element_interaction_type_long_click) - NodeInteractionType.FOCUS -> getString(R.string.action_interact_ui_element_interaction_type_focus) - NodeInteractionType.SCROLL_FORWARD -> getString(R.string.action_interact_ui_element_interaction_type_scroll_forward) - NodeInteractionType.SCROLL_BACKWARD -> getString(R.string.action_interact_ui_element_interaction_type_scroll_backward) - NodeInteractionType.EXPAND -> getString(R.string.action_interact_ui_element_interaction_type_expand) - NodeInteractionType.COLLAPSE -> getString(R.string.action_interact_ui_element_interaction_type_collapse) + NodeInteractionType.CLICK -> getString( + R.string.action_interact_ui_element_interaction_type_click, + ) + NodeInteractionType.LONG_CLICK -> getString( + R.string.action_interact_ui_element_interaction_type_long_click, + ) + NodeInteractionType.FOCUS -> getString( + R.string.action_interact_ui_element_interaction_type_focus, + ) + NodeInteractionType.SCROLL_FORWARD -> getString( + R.string.action_interact_ui_element_interaction_type_scroll_forward, + ) + NodeInteractionType.SCROLL_BACKWARD -> getString( + R.string.action_interact_ui_element_interaction_type_scroll_backward, + ) + NodeInteractionType.EXPAND -> getString( + R.string.action_interact_ui_element_interaction_type_expand, + ) + NodeInteractionType.COLLAPSE -> getString( + R.string.action_interact_ui_element_interaction_type_collapse, + ) } } } @@ -451,10 +459,8 @@ data class SelectedUiElementState( sealed class RecordUiElementState { data class Recorded(val interactionCount: Int) : RecordUiElementState() - data class CountingDown( - val timeRemaining: String, - val interactionCount: Int, - ) : RecordUiElementState() + data class CountingDown(val timeRemaining: String, val interactionCount: Int) : + RecordUiElementState() data object Empty : RecordUiElementState() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupContent.kt b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupContent.kt index da11d2b829..a01aa36184 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupContent.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupContent.kt @@ -57,7 +57,9 @@ data class BackupContent( const val NAME_FLOATING_BUTTONS = "floating_buttons" const val NAME_GROUPS = "groups" - @Deprecated("Device info used to be stored in a database table but they are now stored inside the triggers and actions.") + @Deprecated( + "Device info used to be stored in a database table but they are now stored inside the triggers and actions.", + ) const val NAME_DEVICE_INFO = "device_info" @Deprecated("Fingerprint maps were merged into key maps in version 3.0.0") diff --git a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt index 25427c4000..ce438072ed 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt @@ -25,7 +25,7 @@ import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.TreeNode import io.github.sds100.keymapper.common.utils.UuidGenerator -import io.github.sds100.keymapper.common.utils.breadFirstTraversal +import io.github.sds100.keymapper.common.utils.breadthFirstTraversal import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.then import io.github.sds100.keymapper.data.Keys @@ -59,6 +59,12 @@ import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.data.repositories.RepositoryUtils import io.github.sds100.keymapper.system.files.FileAdapter import io.github.sds100.keymapper.system.files.IFile +import java.io.IOException +import java.io.InputStream +import java.util.LinkedList +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -70,12 +76,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -import java.io.IOException -import java.io.InputStream -import java.util.LinkedList -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton @Singleton class BackupManagerImpl @Inject constructor( @@ -130,12 +130,13 @@ class BackupManagerImpl @Inject constructor( init { coroutineScope.launch { + val layouts = floatingLayoutRepository.layouts combine( backupAutomatically, preferenceRepository.get(Keys.automaticBackupLocation), keyMapRepository.keyMapList.filterIsInstance>>(), groupRepository.getAllGroups(), - floatingLayoutRepository.layouts.filterIsInstance>>(), + layouts.filterIsInstance>>(), ) { backupAutomatically, location, keyMaps, groups, floatingLayouts -> if (!backupAutomatically) { return@combine @@ -158,9 +159,7 @@ class BackupManagerImpl @Inject constructor( val keyMapsToBackup = allKeyMaps.data.filter { keyMapIds.contains(it.uid) } - backupAsync(output, keyMapsToBackup) - - Success(Unit) + backupAsync(output, keyMapsToBackup).then { Success(Unit) } } } @@ -177,9 +176,7 @@ class BackupManagerImpl @Inject constructor( .filterIsInstance>>() .first() - backupAsync(output, keyMaps.data, groups, layouts.data) - - Success(Unit) + backupAsync(output, keyMaps.data, groups, layouts.data).then { Success(Unit) } } } @@ -214,7 +211,9 @@ class BackupManagerImpl @Inject constructor( val backupDbVersion = rootElement.get(BackupContent.NAME_DB_VERSION).nullInt ?: 9 val backupAppVersion = rootElement.get(BackupContent.NAME_APP_VERSION).nullInt - if (backupAppVersion != null && backupAppVersion > buildConfigProvider.versionCode) { + if (backupAppVersion != null && + backupAppVersion > buildConfigProvider.versionCode + ) { return@withContext KMError.BackupVersionTooNew } @@ -222,7 +221,9 @@ class BackupManagerImpl @Inject constructor( return@withContext KMError.BackupVersionTooNew } - val keyMapListJsonArray by rootElement.byNullableArray(BackupContent.NAME_KEYMAP_LIST) + val keyMapListJsonArray by rootElement.byNullableArray( + BackupContent.NAME_KEYMAP_LIST, + ) val deviceInfoList by rootElement.byNullableArray(BackupContent.NAME_DEVICE_INFO) @@ -257,6 +258,9 @@ class BackupManagerImpl @Inject constructor( // Do nothing. Just added columns to the accessibility node table. JsonMigration(19, 20) { json -> json }, + + // Do nothing. Just added columns to floating button entity. + JsonMigration(20, 21) { json -> json }, ) if (keyMapListJsonArray != null) { @@ -282,8 +286,12 @@ class BackupManagerImpl @Inject constructor( JsonMigration(12, 13) { json -> json }, ) - if (rootElement.contains(BackupContent.NAME_FINGERPRINT_MAP_LIST) && backupDbVersion >= 12) { - rootElement.get(BackupContent.NAME_FINGERPRINT_MAP_LIST).asJsonArray.forEach { fingerprintMap -> + if (rootElement.contains(BackupContent.NAME_FINGERPRINT_MAP_LIST) && + backupDbVersion >= 12 + ) { + rootElement.get( + BackupContent.NAME_FINGERPRINT_MAP_LIST, + ).asJsonArray.forEach { fingerprintMap -> val migratedFingerprintMapJson = MigrationUtils.migrate( newFingerprintMapMigrations, inputVersion = backupDbVersion, @@ -352,18 +360,34 @@ class BackupManagerImpl @Inject constructor( FingerprintToKeyMapMigration.migrate(entity)?.let { migratedKeyMapList.add(it) } } - val defaultLongPressDelay by rootElement.byNullableInt(BackupContent.NAME_DEFAULT_LONG_PRESS_DELAY) - val defaultDoublePressDelay by rootElement.byNullableInt(BackupContent.NAME_DEFAULT_DOUBLE_PRESS_DELAY) - val defaultVibrationDuration by rootElement.byNullableInt(BackupContent.NAME_DEFAULT_VIBRATION_DURATION) - val defaultRepeatDelay by rootElement.byNullableInt(BackupContent.NAME_DEFAULT_REPEAT_DELAY) - val defaultRepeatRate by rootElement.byNullableInt(BackupContent.NAME_DEFAULT_REPEAT_RATE) - val defaultSequenceTriggerTimeout by rootElement.byNullableInt(BackupContent.NAME_DEFAULT_SEQUENCE_TRIGGER_TIMEOUT) + val defaultLongPressDelay by rootElement.byNullableInt( + BackupContent.NAME_DEFAULT_LONG_PRESS_DELAY, + ) + val defaultDoublePressDelay by rootElement.byNullableInt( + BackupContent.NAME_DEFAULT_DOUBLE_PRESS_DELAY, + ) + val defaultVibrationDuration by rootElement.byNullableInt( + BackupContent.NAME_DEFAULT_VIBRATION_DURATION, + ) + val defaultRepeatDelay by rootElement.byNullableInt( + BackupContent.NAME_DEFAULT_REPEAT_DELAY, + ) + val defaultRepeatRate by rootElement.byNullableInt( + BackupContent.NAME_DEFAULT_REPEAT_RATE, + ) + val defaultSequenceTriggerTimeout by rootElement.byNullableInt( + BackupContent.NAME_DEFAULT_SEQUENCE_TRIGGER_TIMEOUT, + ) - val floatingLayoutsJson by rootElement.byNullableArray(BackupContent.NAME_FLOATING_LAYOUTS) + val floatingLayoutsJson by rootElement.byNullableArray( + BackupContent.NAME_FLOATING_LAYOUTS, + ) val floatingLayouts: List? = floatingLayoutsJson?.map { json -> gson.fromJson(json) } - val floatingButtonsJson by rootElement.byNullableArray(BackupContent.NAME_FLOATING_BUTTONS) + val floatingButtonsJson by rootElement.byNullableArray( + BackupContent.NAME_FLOATING_BUTTONS, + ) val floatingButtons: List? = floatingButtonsJson?.map { json -> gson.fromJson(json) } @@ -465,16 +489,25 @@ class BackupManagerImpl @Inject constructor( .map { it.uid } .toSet() - val groupUids = backupContent.groups.map { it.uid }.toMutableSet() + val groupUids = + backupContent.groups.map { it.uid }.toMutableSet().plus(existingGroupUids) - groupUids.addAll(existingGroupUids) + // Set the parents to null of any groups that have a missing parent. + val backupGroups = backupContent.groups.map { group -> + if (groupUids.contains(group.parentUid)) { + group + } else { + group.copy(parentUid = null) + } + } - // Group parents must be restored first so an SqliteConstraintException - // is not thrown when restoring a child group. - val groupRestoreTrees = buildGroupTrees(backupContent.groups) + val groupRestoreTrees = buildGroupTrees(backupGroups) for (tree in groupRestoreTrees) { - tree.breadFirstTraversal { group -> + // Do breadth first traversal because group parents must be restored + // first so an SqliteConstraintException + // is not thrown when restoring a child group. + tree.breadthFirstTraversal { group -> restoreGroup(group, currentTime, groupUids, existingGroupUids) } } @@ -559,7 +592,7 @@ class BackupManagerImpl @Inject constructor( } catch (e: NoSuchElementException) { return KMError.CorruptJsonFile(e.message ?: "") } catch (e: Exception) { - Timber.e(e) + Timber.e("Restore error: $e") return KMError.Exception(e) } @@ -588,7 +621,11 @@ class BackupManagerImpl @Inject constructor( modifiedGroup, saveBlock = { renamedGroup -> // Do not rename the group with a (1) if it is the same UID. Just overwrite the name. - if (siblings.any { sibling -> sibling.uid != renamedGroup.uid && sibling.name == renamedGroup.name }) { + if (siblings.any { sibling -> + sibling.uid != renamedGroup.uid && + sibling.name == renamedGroup.name + } + ) { throw IllegalStateException("Non unique group name") } }, @@ -733,7 +770,7 @@ class BackupManagerImpl @Inject constructor( return@withContext fileAdapter.createZipFile(output, filesToBackup) .then { Success(output) } } catch (e: Exception) { - Timber.e(e) + Timber.e("Backup error: $e") return@withContext KMError.Exception(e) } @@ -747,7 +784,12 @@ class BackupManagerImpl @Inject constructor( return soundActions // Sound actions that are file-based rather than system ringtones will contain this Extra. - .filter { action -> action.extras.any { it.id == ActionEntity.EXTRA_SOUND_FILE_DESCRIPTION } } + .filter { action -> + action.extras.any { + it.id == + ActionEntity.EXTRA_SOUND_FILE_DESCRIPTION + } + } // The action data is the sound UID .map { it.data } .toSet() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupRestoreMappingsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupRestoreMappingsUseCase.kt index da5ff0cca0..d50b364790 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupRestoreMappingsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupRestoreMappingsUseCase.kt @@ -5,9 +5,9 @@ import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.then import io.github.sds100.keymapper.system.files.FileAdapter +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import timber.log.Timber -import javax.inject.Inject class BackupRestoreMappingsUseCaseImpl @Inject constructor( private val fileAdapter: FileAdapter, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/backup/RestoreKeyMapsActivity.kt b/base/src/main/java/io/github/sds100/keymapper/base/backup/RestoreKeyMapsActivity.kt index d15e1970b4..b51b0913de 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/backup/RestoreKeyMapsActivity.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/backup/RestoreKeyMapsActivity.kt @@ -54,13 +54,19 @@ class RestoreKeyMapsActivity : ComponentActivity() { ) is ImportExportState.Error -> stringResource(R.string.import_dialog_title_error) - ImportExportState.FinishedImport -> stringResource(R.string.import_dialog_title_success) - ImportExportState.Importing -> stringResource(R.string.import_dialog_title_importing) + ImportExportState.FinishedImport -> stringResource( + R.string.import_dialog_title_success, + ) + ImportExportState.Importing -> stringResource( + R.string.import_dialog_title_importing, + ) else -> "" } val text = when (state) { - is ImportExportState.ConfirmImport -> stringResource(R.string.home_importing_dialog_text) + is ImportExportState.ConfirmImport -> stringResource( + R.string.home_importing_dialog_text, + ) else -> null } @@ -79,9 +85,13 @@ class RestoreKeyMapsActivity : ComponentActivity() { startActivity(this) } }) { - Text(stringResource(R.string.import_dialog_button_launch_key_mapper)) + Text( + stringResource(R.string.import_dialog_button_launch_key_mapper), + ) } - } else if (state !is ImportExportState.Idle && state !is ImportExportState.ConfirmImport) { + } else if (state !is ImportExportState.Idle && + state !is ImportExportState.ConfirmImport + ) { TextButton(onClick = { finish() }) { Text(stringResource(R.string.pos_done)) } @@ -99,7 +109,9 @@ class RestoreKeyMapsActivity : ComponentActivity() { TextButton(onClick = { finish() }) { Text(stringResource(R.string.home_importing_dialog_dismiss)) } - } else if (state is ImportExportState.Idle || state is ImportExportState.ConfirmImport) { + } else if (state is ImportExportState.Idle || + state is ImportExportState.ConfirmImport + ) { TextButton(onClick = { finish() }) { Text(stringResource(R.string.home_importing_dialog_cancel)) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/backup/RestoreKeyMapsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/backup/RestoreKeyMapsViewModel.kt index 44cb892887..25367e374d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/backup/RestoreKeyMapsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/backup/RestoreKeyMapsViewModel.kt @@ -7,11 +7,11 @@ import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.onSuccess +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class RestoreKeyMapsViewModel @Inject constructor( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/compose/ComposeColors.kt b/base/src/main/java/io/github/sds100/keymapper/base/compose/ComposeColors.kt index 7746f70135..db894a6611 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/compose/ComposeColors.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/compose/ComposeColors.kt @@ -8,6 +8,7 @@ object ComposeColors { val onPrimaryLight = Color(0xFFFFFFFF) val primaryContainerLight = Color(0xFFD6E3FF) val onPrimaryContainerLight = Color(0xFF274777) + val primaryContainerDarkerLight = Color(0xFFC4D6FF) val secondaryLight = Color(0xFF565F71) val onSecondaryLight = Color(0xFFFFFFFF) val secondaryContainerLight = Color(0xFFDAE2F9) @@ -45,11 +46,26 @@ object ComposeColors { val onGreenLight = Color(0xFFFFFFFF) val greenContainerLight = Color(0xFFBCF0B4) val onGreenContainerLight = Color(0xFF235024) + val magiskTealLight = Color(0xFF008072) + val onMagiskTealLight = Color(0xFFFFFFFF) + val shizukuBlueLight = Color(0xFF4556B7) + val onShizukuBlueLight = Color(0xFFFFFFFF) + val orangeLight = Color(0xFF8B5000) + val onOrangeLight = Color(0xFFFFFFFF) + val orangeContainerLight = Color(0xFFFFA643) + val onOrangeContainerLight = Color(0xFF452500) + val amberLight = Color(0xFFFFCA28) + val onAmberLight = Color(0xFF000000) + val amberContainerLight = Color(0xFFFFF4CF) + val onAmberContainerLight = Color(0xFF000000) + val discordLight = Color(0xFF5865F2) + val onDiscordLight = Color(0xFFFFFFFF) val primaryDark = Color(0xFFAAC7FF) val onPrimaryDark = Color(0xFF0A305F) val primaryContainerDark = Color(0xFF274777) val onPrimaryContainerDark = Color(0xFFD6E3FF) + val primaryContainerDarkerDark = Color(0xFF2A3C5A) val secondaryDark = Color(0xFFBEC6DC) val onSecondaryDark = Color(0xFF283141) val secondaryContainerDark = Color(0xFF3E4759) @@ -87,4 +103,18 @@ object ComposeColors { val onGreenDark = Color(0xFF0A390F) val greenContainerDark = Color(0xFF235024) val onGreenContainerDark = Color(0xFFBCF0B4) + val magiskTealDark = Color(0xFF009B8C) + val onMagiskTealDark = Color(0xFFFFFFFF) + val shizukuBlueDark = Color(0xFFB7C4F4) + val onShizukuBlueDark = Color(0xFF0A305F) + val orangeDark = Color(0xFFFFCA97) + val onOrangeDark = Color(0xFF4A2800) + val orangeContainerDark = Color(0xFFF69300) + val onOrangeContainerDark = Color(0xFF331A00) + val amberDark = Color(0xFFFFECB3) + val onAmberDark = Color(0xFF000000) + val amberContainerDark = Color(0x33FFF4CF) + val onAmberContainerDark = Color(0xFFFFFFFF) + val discordDark = Color(0xFF5865F2) + val onDiscordDark = Color(0xFFFFFFFF) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/compose/ComposeCustomColors.kt b/base/src/main/java/io/github/sds100/keymapper/base/compose/ComposeCustomColors.kt index 49d7638df8..7f4eec55de 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/compose/ComposeCustomColors.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/compose/ComposeCustomColors.kt @@ -1,7 +1,29 @@ package io.github.sds100.keymapper.base.compose +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color +import io.github.sds100.keymapper.base.compose.ComposeColors.amberContainerDark +import io.github.sds100.keymapper.base.compose.ComposeColors.amberContainerLight +import io.github.sds100.keymapper.base.compose.ComposeColors.amberDark +import io.github.sds100.keymapper.base.compose.ComposeColors.amberLight +import io.github.sds100.keymapper.base.compose.ComposeColors.onAmberContainerDark +import io.github.sds100.keymapper.base.compose.ComposeColors.onAmberContainerLight +import io.github.sds100.keymapper.base.compose.ComposeColors.onAmberDark +import io.github.sds100.keymapper.base.compose.ComposeColors.onAmberLight +import io.github.sds100.keymapper.base.compose.ComposeColors.onOrangeContainerDark +import io.github.sds100.keymapper.base.compose.ComposeColors.onOrangeContainerLight +import io.github.sds100.keymapper.base.compose.ComposeColors.onOrangeDark +import io.github.sds100.keymapper.base.compose.ComposeColors.onOrangeLight +import io.github.sds100.keymapper.base.compose.ComposeColors.orangeContainerDark +import io.github.sds100.keymapper.base.compose.ComposeColors.orangeContainerLight +import io.github.sds100.keymapper.base.compose.ComposeColors.orangeDark +import io.github.sds100.keymapper.base.compose.ComposeColors.orangeLight +import io.github.sds100.keymapper.base.compose.ComposeColors.primaryContainerDarkerDark +import io.github.sds100.keymapper.base.compose.ComposeColors.primaryContainerDarkerLight /** * Stores the custom colors in a palette that changes @@ -17,6 +39,21 @@ data class ComposeCustomColors( val onGreen: Color = Color.Unspecified, val greenContainer: Color = Color.Unspecified, val onGreenContainer: Color = Color.Unspecified, + val magiskTeal: Color = Color.Unspecified, + val onMagiskTeal: Color = Color.Unspecified, + val shizukuBlue: Color = Color.Unspecified, + val onShizukuBlue: Color = Color.Unspecified, + val orange: Color = Color.Unspecified, + val onOrange: Color = Color.Unspecified, + val orangeContainer: Color = Color.Unspecified, + val onOrangeContainer: Color = Color.Unspecified, + val amber: Color = Color.Unspecified, + val onAmber: Color = Color.Unspecified, + val amberContainer: Color = Color.Unspecified, + val onAmberContainer: Color = Color.Unspecified, + val discord: Color = Color.Unspecified, + val onDiscord: Color = Color.Unspecified, + val primaryContainerDarker: Color = Color.Unspecified, ) { companion object { val LightPalette = ComposeCustomColors( @@ -26,6 +63,21 @@ data class ComposeCustomColors( onGreen = ComposeColors.onGreenLight, greenContainer = ComposeColors.greenContainerLight, onGreenContainer = ComposeColors.onGreenContainerLight, + magiskTeal = ComposeColors.magiskTealLight, + onMagiskTeal = ComposeColors.onMagiskTealLight, + shizukuBlue = ComposeColors.shizukuBlueLight, + onShizukuBlue = ComposeColors.onShizukuBlueLight, + orange = orangeLight, + onOrange = onOrangeLight, + orangeContainer = orangeContainerLight, + onOrangeContainer = onOrangeContainerLight, + amber = amberLight, + onAmber = onAmberLight, + amberContainer = amberContainerLight, + onAmberContainer = onAmberContainerLight, + discord = ComposeColors.discordLight, + onDiscord = ComposeColors.onDiscordLight, + primaryContainerDarker = primaryContainerDarkerLight, ) val DarkPalette = ComposeCustomColors( @@ -35,6 +87,37 @@ data class ComposeCustomColors( onGreen = ComposeColors.onGreenDark, greenContainer = ComposeColors.greenContainerDark, onGreenContainer = ComposeColors.onGreenContainerDark, + magiskTeal = ComposeColors.magiskTealDark, + onMagiskTeal = ComposeColors.onMagiskTealDark, + shizukuBlue = ComposeColors.shizukuBlueDark, + onShizukuBlue = ComposeColors.onShizukuBlueDark, + orange = orangeDark, + onOrange = onOrangeDark, + orangeContainer = orangeContainerDark, + onOrangeContainer = onOrangeContainerDark, + amber = amberDark, + onAmber = onAmberDark, + amberContainer = amberContainerDark, + onAmberContainer = onAmberContainerDark, + discord = ComposeColors.discordDark, + onDiscord = ComposeColors.onDiscordDark, + primaryContainerDarker = primaryContainerDarkerDark, ) } + + @Composable + @Stable + fun contentColorFor(color: Color): Color { + return when (color) { + red -> onRed + green -> onGreen + greenContainer -> onGreenContainer + magiskTeal -> onMagiskTeal + shizukuBlue -> onShizukuBlue + amber -> onAmber + amberContainer -> onAmberContainer + discord -> onDiscord + else -> MaterialTheme.colorScheme.contentColorFor(color) + } + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/compose/ComposeTheme.kt b/base/src/main/java/io/github/sds100/keymapper/base/compose/ComposeTheme.kt index b289192ec1..23ca891235 100755 --- a/base/src/main/java/io/github/sds100/keymapper/base/compose/ComposeTheme.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/compose/ComposeTheme.kt @@ -91,10 +91,7 @@ object ComposeTheme { val LocalCustomColorsPalette = staticCompositionLocalOf { ComposeCustomColors() } @Composable -fun KeyMapperTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - content: @Composable () -> Unit, -) { +fun KeyMapperTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { // val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val colorScheme = when { // dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintScreen.kt index bfaeaa36a9..d3a956cd9c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintScreen.kt @@ -54,10 +54,7 @@ import io.github.sds100.keymapper.common.utils.State import kotlinx.coroutines.flow.update @Composable -fun ChooseConstraintScreen( - modifier: Modifier = Modifier, - viewModel: ChooseConstraintViewModel, -) { +fun ChooseConstraintScreen(modifier: Modifier = Modifier, viewModel: ChooseConstraintViewModel) { val listItems by viewModel.listItems.collectAsStateWithLifecycle() val query by viewModel.searchQuery.collectAsStateWithLifecycle() @@ -103,7 +100,9 @@ private fun ChooseConstraintScreen( }) { Icon( Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.bottom_app_bar_back_content_description), + contentDescription = stringResource( + R.string.bottom_app_bar_back_content_description, + ), ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt index 6bf2901ee9..685535a791 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt @@ -20,6 +20,7 @@ import io.github.sds100.keymapper.base.utils.ui.showDialog import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.camera.CameraLens +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -31,7 +32,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.serialization.json.Json -import javax.inject.Inject @HiltViewModel class ChooseConstraintViewModel @Inject constructor( @@ -89,11 +89,14 @@ class ChooseConstraintViewModel @Inject constructor( ConstraintId.CHARGING, ConstraintId.DISCHARGING, + ConstraintId.HINGE_CLOSED, + ConstraintId.HINGE_OPEN, + ConstraintId.TIME, ) } - private val returnResult = MutableSharedFlow() + private val returnResult = MutableSharedFlow() private val allListItems: List by lazy { buildListItems() } @@ -105,20 +108,20 @@ class ChooseConstraintViewModel @Inject constructor( State.Data(filteredItems) }.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) - var timeConstraintState: Constraint.Time? by mutableStateOf(null) + var timeConstraintState: ConstraintData.Time? by mutableStateOf(null) init { viewModelScope.launch { - returnResult.collect { constraint -> - popBackStackWithResult(Json.encodeToString(constraint)) + returnResult.collect { constraintData -> + popBackStackWithResult(Json.encodeToString(constraintData)) } } } fun onDoneConfigTimeConstraintClick() { - timeConstraintState?.let { constraint -> + timeConstraintState?.let { constraintData -> viewModelScope.launch { - returnResult.emit(constraint) + returnResult.emit(constraintData) timeConstraintState = null } } @@ -137,90 +140,105 @@ class ChooseConstraintViewModel @Inject constructor( ConstraintId.APP_NOT_IN_FOREGROUND, ConstraintId.APP_PLAYING_MEDIA, ConstraintId.APP_NOT_PLAYING_MEDIA, - -> onSelectAppConstraint(constraintType) + -> onSelectAppConstraint(constraintType) - ConstraintId.MEDIA_PLAYING -> returnResult.emit(Constraint.MediaPlaying()) - ConstraintId.MEDIA_NOT_PLAYING -> returnResult.emit(Constraint.NoMediaPlaying()) + ConstraintId.MEDIA_PLAYING -> returnResult.emit(ConstraintData.MediaPlaying) + ConstraintId.MEDIA_NOT_PLAYING -> returnResult.emit(ConstraintData.NoMediaPlaying) ConstraintId.BT_DEVICE_CONNECTED, ConstraintId.BT_DEVICE_DISCONNECTED, - -> onSelectBluetoothConstraint( - constraintType, - ) + -> onSelectBluetoothConstraint( + constraintType, + ) - ConstraintId.SCREEN_ON -> onSelectScreenOnConstraint() - ConstraintId.SCREEN_OFF -> onSelectScreenOffConstraint() + ConstraintId.SCREEN_ON -> returnResult.emit(ConstraintData.ScreenOn) + + ConstraintId.SCREEN_OFF -> returnResult.emit(ConstraintData.ScreenOff) ConstraintId.ORIENTATION_PORTRAIT -> - returnResult.emit(Constraint.OrientationPortrait()) + returnResult.emit(ConstraintData.OrientationPortrait) ConstraintId.ORIENTATION_LANDSCAPE -> - returnResult.emit(Constraint.OrientationLandscape()) + returnResult.emit(ConstraintData.OrientationLandscape) ConstraintId.ORIENTATION_0 -> - returnResult.emit(Constraint.OrientationCustom(orientation = Orientation.ORIENTATION_0)) + returnResult.emit( + ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_0), + ) ConstraintId.ORIENTATION_90 -> - returnResult.emit(Constraint.OrientationCustom(orientation = Orientation.ORIENTATION_90)) + returnResult.emit( + ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_90), + ) ConstraintId.ORIENTATION_180 -> - returnResult.emit(Constraint.OrientationCustom(orientation = Orientation.ORIENTATION_180)) + returnResult.emit( + ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_180), + ) ConstraintId.ORIENTATION_270 -> - returnResult.emit(Constraint.OrientationCustom(orientation = Orientation.ORIENTATION_270)) + returnResult.emit( + ConstraintData.OrientationCustom(orientation = Orientation.ORIENTATION_270), + ) ConstraintId.FLASHLIGHT_ON -> { val lens = chooseFlashlightLens() ?: return@launch - returnResult.emit(Constraint.FlashlightOn(lens = lens)) + returnResult.emit(ConstraintData.FlashlightOn(lens = lens)) } ConstraintId.FLASHLIGHT_OFF -> { val lens = chooseFlashlightLens() ?: return@launch - returnResult.emit(Constraint.FlashlightOff(lens = lens)) + returnResult.emit(ConstraintData.FlashlightOff(lens = lens)) } - ConstraintId.WIFI_ON -> returnResult.emit(Constraint.WifiOn()) - ConstraintId.WIFI_OFF -> returnResult.emit(Constraint.WifiOff()) + ConstraintId.WIFI_ON -> returnResult.emit(ConstraintData.WifiOn) + ConstraintId.WIFI_OFF -> returnResult.emit(ConstraintData.WifiOff) ConstraintId.WIFI_CONNECTED, ConstraintId.WIFI_DISCONNECTED, - -> onSelectWifiConnectedConstraint( - constraintType, - ) + -> onSelectWifiConnectedConstraint( + constraintType, + ) ConstraintId.IME_CHOSEN, ConstraintId.IME_NOT_CHOSEN, - -> onSelectImeChosenConstraint(constraintType) + -> onSelectImeChosenConstraint(constraintType) ConstraintId.DEVICE_IS_LOCKED -> - returnResult.emit(Constraint.DeviceIsLocked()) + returnResult.emit(ConstraintData.DeviceIsLocked) ConstraintId.DEVICE_IS_UNLOCKED -> - returnResult.emit(Constraint.DeviceIsUnlocked()) + returnResult.emit(ConstraintData.DeviceIsUnlocked) ConstraintId.IN_PHONE_CALL -> - returnResult.emit(Constraint.InPhoneCall()) + returnResult.emit(ConstraintData.InPhoneCall) ConstraintId.NOT_IN_PHONE_CALL -> - returnResult.emit(Constraint.NotInPhoneCall()) + returnResult.emit(ConstraintData.NotInPhoneCall) ConstraintId.PHONE_RINGING -> - returnResult.emit(Constraint.PhoneRinging()) + returnResult.emit(ConstraintData.PhoneRinging) ConstraintId.CHARGING -> - returnResult.emit(Constraint.Charging()) + returnResult.emit(ConstraintData.Charging) ConstraintId.DISCHARGING -> - returnResult.emit(Constraint.Discharging()) + returnResult.emit(ConstraintData.Discharging) + + ConstraintId.HINGE_CLOSED -> + returnResult.emit(ConstraintData.HingeClosed) + + ConstraintId.HINGE_OPEN -> + returnResult.emit(ConstraintData.HingeOpen) ConstraintId.LOCK_SCREEN_SHOWING -> - returnResult.emit(Constraint.LockScreenShowing()) + returnResult.emit(ConstraintData.LockScreenShowing) ConstraintId.LOCK_SCREEN_NOT_SHOWING -> - returnResult.emit(Constraint.LockScreenNotShowing()) + returnResult.emit(ConstraintData.LockScreenNotShowing) ConstraintId.TIME -> { - timeConstraintState = Constraint.Time( + timeConstraintState = ConstraintData.Time( startHour = 0, startMinute = 0, endHour = 0, @@ -271,53 +289,40 @@ class ChooseConstraintViewModel @Inject constructor( } private suspend fun onSelectWifiConnectedConstraint(type: ConstraintId) { - val knownSSIDs = useCase.getKnownWiFiSSIDs() + val knownSSIDs: List = useCase.getKnownWiFiSSIDs() val chosenSSID: String? - if (knownSSIDs == null) { - val savedWifiSSIDs = useCase.getSavedWifiSSIDs().first() + val savedWifiSSIDs: List = useCase.getSavedWifiSSIDs().first() - val dialog = DialogModel.Text( - hint = getString(R.string.hint_wifi_ssid), - allowEmpty = true, - message = getString(R.string.constraint_wifi_message_cant_list_networks), - autoCompleteEntries = savedWifiSSIDs, - ) + val ssidEntries = buildList { + addAll(savedWifiSSIDs) + addAll(knownSSIDs) + }.distinct() - val ssidText = showDialog("type_ssid", dialog) ?: return + val dialog = DialogModel.Text( + hint = getString(R.string.hint_wifi_ssid), + allowEmpty = true, + message = getString(R.string.constraint_wifi_message_cant_list_networks), + autoCompleteEntries = ssidEntries, + ) - if (ssidText.isBlank()) { - chosenSSID = null - } else { - chosenSSID = ssidText + val ssidText = showDialog("type_ssid", dialog) ?: return - useCase.saveWifiSSID(chosenSSID) - } + if (ssidText.isBlank()) { + chosenSSID = null } else { - val anySSIDItem = - "any" to getString(R.string.constraint_wifi_pick_network_any) - - val ssidItems = knownSSIDs.map { "ssid_$it" to it } - - val items = listOf(anySSIDItem).plus(ssidItems) + chosenSSID = ssidText - val chosenItem = - showDialog("choose_ssid", DialogModel.SingleChoice(items)) ?: return - - if (chosenItem == anySSIDItem.first) { - chosenSSID = null - } else { - chosenSSID = items.single { it.first == chosenItem }.second - } + useCase.saveWifiSSID(chosenSSID) } when (type) { ConstraintId.WIFI_CONNECTED -> - returnResult.emit(Constraint.WifiConnected(ssid = chosenSSID)) + returnResult.emit(ConstraintData.WifiConnected(ssid = chosenSSID)) ConstraintId.WIFI_DISCONNECTED -> - returnResult.emit(Constraint.WifiDisconnected(ssid = chosenSSID)) + returnResult.emit(ConstraintData.WifiDisconnected(ssid = chosenSSID)) else -> Unit } @@ -335,7 +340,7 @@ class ChooseConstraintViewModel @Inject constructor( when (type) { ConstraintId.IME_CHOSEN -> returnResult.emit( - Constraint.ImeChosen( + ConstraintData.ImeChosen( imeId = imeInfo.id, imeLabel = imeInfo.label, ), @@ -343,7 +348,7 @@ class ChooseConstraintViewModel @Inject constructor( ConstraintId.IME_NOT_CHOSEN -> returnResult.emit( - Constraint.ImeNotChosen( + ConstraintData.ImeNotChosen( imeId = imeInfo.id, imeLabel = imeInfo.label, ), @@ -353,28 +358,6 @@ class ChooseConstraintViewModel @Inject constructor( } } - private suspend fun onSelectScreenOnConstraint() { - val response = showDialog( - "screen_on_constraint_limitation", - DialogModel.Ok(getString(R.string.dialog_message_screen_constraints_limitation)), - ) - - response ?: return - - returnResult.emit(Constraint.ScreenOn()) - } - - private suspend fun onSelectScreenOffConstraint() { - val response = showDialog( - "screen_on_constraint_limitation", - DialogModel.Ok(getString(R.string.dialog_message_screen_constraints_limitation)), - ) - - response ?: return - - returnResult.emit(Constraint.ScreenOff()) - } - private suspend fun onSelectBluetoothConstraint(type: ConstraintId) { val response = showDialog( "bluetooth_device_constraint_limitation", @@ -388,21 +371,23 @@ class ChooseConstraintViewModel @Inject constructor( NavDestination.ChooseBluetoothDevice, ) ?: return - val constraint = when (type) { - ConstraintId.BT_DEVICE_CONNECTED -> Constraint.BtDeviceConnected( + val constraintData = when (type) { + ConstraintId.BT_DEVICE_CONNECTED -> ConstraintData.BtDeviceConnected( bluetoothAddress = device.address, deviceName = device.name, ) - ConstraintId.BT_DEVICE_DISCONNECTED -> Constraint.BtDeviceDisconnected( + ConstraintId.BT_DEVICE_DISCONNECTED -> ConstraintData.BtDeviceDisconnected( bluetoothAddress = device.address, deviceName = device.name, ) - else -> throw IllegalArgumentException("Don't know how to create $type constraint after choosing app") + else -> throw IllegalArgumentException( + "Don't know how to create $type constraint after choosing app", + ) } - returnResult.emit(constraint) + returnResult.emit(constraintData) } private suspend fun onSelectAppConstraint(type: ConstraintId) { @@ -413,26 +398,28 @@ class ChooseConstraintViewModel @Inject constructor( ) ?: return - val constraint = when (type) { - ConstraintId.APP_IN_FOREGROUND -> Constraint.AppInForeground( + val constraintData = when (type) { + ConstraintId.APP_IN_FOREGROUND -> ConstraintData.AppInForeground( packageName = packageName, ) - ConstraintId.APP_NOT_IN_FOREGROUND -> Constraint.AppNotInForeground( + ConstraintId.APP_NOT_IN_FOREGROUND -> ConstraintData.AppNotInForeground( packageName = packageName, ) - ConstraintId.APP_PLAYING_MEDIA -> Constraint.AppPlayingMedia( + ConstraintId.APP_PLAYING_MEDIA -> ConstraintData.AppPlayingMedia( packageName = packageName, ) - ConstraintId.APP_NOT_PLAYING_MEDIA -> Constraint.AppNotPlayingMedia( + ConstraintId.APP_NOT_PLAYING_MEDIA -> ConstraintData.AppNotPlayingMedia( packageName = packageName, ) - else -> throw IllegalArgumentException("Don't know how to create $type constraint after choosing app") + else -> throw IllegalArgumentException( + "Don't know how to create $type constraint after choosing app", + ) } - returnResult.emit(constraint) + returnResult.emit(constraintData) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsUseCase.kt new file mode 100644 index 0000000000..fc2f113302 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsUseCase.kt @@ -0,0 +1,121 @@ +package io.github.sds100.keymapper.base.constraints + +import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState +import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import java.util.LinkedList +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json + +@ViewModelScoped +class ConfigConstraintsUseCaseImpl @Inject constructor( + private val state: ConfigKeyMapState, + private val preferenceRepository: PreferenceRepository, +) : ConfigConstraintsUseCase { + + override val keyMap: StateFlow> = state.keyMap + + /** + * The most recently used is first. + */ + override val recentlyUsedConstraints: Flow> = + combine( + preferenceRepository.get(Keys.recentlyUsedConstraints).map(::getConstraintShortcuts), + keyMap.filterIsInstance>(), + ) { shortcutData, keyMap -> + + // Do not include constraints that the key map already contains. + shortcutData + .filter { constraintData -> + !keyMap.data.constraintState.constraints.any { it.data == constraintData } + } + .take(5) + } + + override fun addConstraint(constraintData: ConstraintData): Boolean { + var containsConstraint = false + val newConstraint = Constraint(data = constraintData) + + updateConstraintState { oldState -> + containsConstraint = oldState.constraints.any { it.data == constraintData } + + if (containsConstraint) { + oldState + } else { + oldState.copy(constraints = oldState.constraints.plus(newConstraint)) + } + } + + preferenceRepository.update( + Keys.recentlyUsedConstraints, + { old -> + val oldDataList = getConstraintShortcuts(old) + + val newDataList = LinkedList(oldDataList) + .apply { addFirst(constraintData) } + .distinct() + + Json.encodeToString(newDataList) + }, + ) + + return !containsConstraint + } + + override fun removeConstraint(id: String) { + updateConstraintState { oldState -> + val newList = oldState.constraints.toMutableSet().apply { + removeAll { it.uid == id } + } + oldState.copy(constraints = newList) + } + } + + override fun setAndMode() { + updateConstraintState { oldState -> + oldState.copy(mode = ConstraintMode.AND) + } + } + + override fun setOrMode() { + updateConstraintState { oldState -> + oldState.copy(mode = ConstraintMode.OR) + } + } + + private fun updateConstraintState(block: (ConstraintState) -> ConstraintState) { + state.update { keyMap -> + keyMap.copy(constraintState = block(keyMap.constraintState)) + } + } + + private fun getConstraintShortcuts(json: String?): List { + if (json == null) { + return emptyList() + } + + try { + return Json.decodeFromString>(json).distinct() + } catch (_: Exception) { + return emptyList() + } + } +} + +interface ConfigConstraintsUseCase { + val keyMap: StateFlow> + + val recentlyUsedConstraints: Flow> + fun addConstraint(constraintData: ConstraintData): Boolean + fun removeConstraint(id: String) + fun setAndMode() + fun setOrMode() +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt index a2d941bbbd..46ed17f0dd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt @@ -3,7 +3,9 @@ package io.github.sds100.keymapper.base.constraints import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapUseCase +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sds100.keymapper.base.keymaps.ShortcutModel import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.isFixable @@ -20,7 +22,7 @@ import io.github.sds100.keymapper.common.utils.dataOrNull import io.github.sds100.keymapper.common.utils.mapData import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.permissions.Permission -import kotlinx.coroutines.CoroutineScope +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -34,14 +36,15 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -class ConfigConstraintsViewModel( - private val coroutineScope: CoroutineScope, - private val config: ConfigKeyMapUseCase, +@HiltViewModel +class ConfigConstraintsViewModel @Inject constructor( + private val config: ConfigConstraintsUseCase, private val displayConstraint: DisplayConstraintUseCase, resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, -) : ResourceProvider by resourceProvider, +) : ViewModel(), + ResourceProvider by resourceProvider, DialogProvider by dialogProvider, NavigationProvider by navigationProvider { @@ -51,14 +54,16 @@ class ConfigConstraintsViewModel( MutableStateFlow(State.Loading) val state = _state.asStateFlow() - private val shortcuts: StateFlow>> = - config.recentlyUsedConstraints.map { actions -> - actions.map(::buildShortcut).toSet() - }.stateIn(coroutineScope, SharingStarted.Lazily, emptySet()) + private val shortcuts: StateFlow>> = + config.recentlyUsedConstraints.map { constraintDataList -> + constraintDataList.map { constraintData -> + buildShortcutFromData(constraintData) + }.toSet() + }.stateIn(viewModelScope, SharingStarted.Lazily, emptySet()) private val constraintErrorSnapshot: StateFlow = displayConstraint.constraintErrorSnapshot.stateIn( - coroutineScope, + viewModelScope, SharingStarted.Lazily, null, ) @@ -74,12 +79,12 @@ class ConfigConstraintsViewModel( _state.value = keyMapState.mapData { keyMap -> buildState(keyMap.constraintState, shortcuts, errorSnapshot) } - }.launchIn(coroutineScope) + }.launchIn(viewModelScope) } - fun onClickShortcut(constraint: Constraint) { - coroutineScope.launch { - config.addConstraint(constraint) + fun onClickShortcut(constraintData: ConstraintData) { + viewModelScope.launch { + config.addConstraint(constraintData) } } @@ -93,7 +98,7 @@ class ConfigConstraintsViewModel( } fun onFixError(constraintUid: String) { - coroutineScope.launch { + viewModelScope.launch { val constraint = config.keyMap .firstOrNull() ?.dataOrNull() @@ -106,11 +111,13 @@ class ConfigConstraintsViewModel( ?: return@launch if (error == SystemError.PermissionDenied(Permission.ACCESS_NOTIFICATION_POLICY)) { - coroutineScope.launch { + viewModelScope.launch { ViewModelHelper.showDialogExplainingDndAccessBeingUnavailable( resourceProvider = this@ConfigConstraintsViewModel, dialogProvider = this@ConfigConstraintsViewModel, - neverShowDndTriggerErrorAgain = { displayConstraint.neverShowDndTriggerError() }, + neverShowDndTriggerErrorAgain = { + displayConstraint.neverShowDndTriggerError() + }, fixError = { displayConstraint.fixError(error) }, ) } @@ -127,8 +134,8 @@ class ConfigConstraintsViewModel( } fun addConstraint() { - coroutineScope.launch { - val constraint = + viewModelScope.launch { + val constraint: ConstraintData = navigate("add_constraint", NavDestination.ChooseConstraint) ?: return@launch @@ -140,17 +147,20 @@ class ConfigConstraintsViewModel( } } - private fun buildShortcut(constraint: Constraint): ShortcutModel { + private fun buildShortcutFromData( + constraintData: ConstraintData, + ): ShortcutModel { + val constraint = Constraint(data = constraintData) return ShortcutModel( icon = uiHelper.getIcon(constraint), text = uiHelper.getTitle(constraint), - data = constraint, + data = constraintData, ) } private fun buildState( state: ConstraintState, - shortcuts: Set>, + shortcuts: Set>, errorSnapshot: ConstraintErrorSnapshot, ): ConfigConstraintsState { if (state.constraints.isEmpty()) { @@ -165,7 +175,9 @@ class ConfigConstraintsViewModel( ConstraintListItemModel( id = constraint.uid, icon = icon, - constraintModeLink = if (state.constraints.size > 1 && index < state.constraints.size - 1) { + constraintModeLink = if (state.constraints.size > 1 && + index < state.constraints.size - 1 + ) { state.mode } else { null @@ -185,13 +197,12 @@ class ConfigConstraintsViewModel( } sealed class ConfigConstraintsState { - data class Empty( - val shortcuts: Set> = emptySet(), - ) : ConfigConstraintsState() + data class Empty(val shortcuts: Set> = emptySet()) : + ConfigConstraintsState() data class Loaded( val constraintList: List, val selectedMode: ConstraintMode, - val shortcuts: Set> = emptySet(), + val shortcuts: Set> = emptySet(), ) : ConfigConstraintsState() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt index d949ee114b..ca20bad507 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt @@ -7,100 +7,78 @@ import io.github.sds100.keymapper.data.entities.ConstraintEntity import io.github.sds100.keymapper.data.entities.EntityExtra import io.github.sds100.keymapper.data.entities.getData import io.github.sds100.keymapper.system.camera.CameraLens -import kotlinx.serialization.Serializable import java.time.LocalTime import java.util.UUID +import kotlinx.serialization.Serializable @Serializable -sealed class Constraint { - abstract val uid: String +sealed class ConstraintData { abstract val id: ConstraintId @Serializable - data class AppInForeground( - override val uid: String = UUID.randomUUID().toString(), - val packageName: String, - ) : Constraint() { + data class AppInForeground(val packageName: String) : ConstraintData() { override val id: ConstraintId = ConstraintId.APP_IN_FOREGROUND } @Serializable - data class AppNotInForeground( - override val uid: String = UUID.randomUUID().toString(), - val packageName: String, - ) : Constraint() { + data class AppNotInForeground(val packageName: String) : ConstraintData() { override val id: ConstraintId = ConstraintId.APP_NOT_IN_FOREGROUND } @Serializable - data class AppPlayingMedia( - override val uid: String = UUID.randomUUID().toString(), - val packageName: String, - ) : Constraint() { + data class AppPlayingMedia(val packageName: String) : ConstraintData() { override val id: ConstraintId = ConstraintId.APP_PLAYING_MEDIA } @Serializable - data class AppNotPlayingMedia( - override val uid: String = UUID.randomUUID().toString(), - val packageName: String, - ) : Constraint() { + data class AppNotPlayingMedia(val packageName: String) : ConstraintData() { override val id: ConstraintId = ConstraintId.APP_NOT_PLAYING_MEDIA } @Serializable - data class MediaPlaying(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object MediaPlaying : ConstraintData() { override val id: ConstraintId = ConstraintId.MEDIA_PLAYING } @Serializable - data class NoMediaPlaying(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object NoMediaPlaying : ConstraintData() { override val id: ConstraintId = ConstraintId.MEDIA_NOT_PLAYING } @Serializable - data class BtDeviceConnected( - override val uid: String = UUID.randomUUID().toString(), - val bluetoothAddress: String, - val deviceName: String, - ) : Constraint() { + data class BtDeviceConnected(val bluetoothAddress: String, val deviceName: String) : + ConstraintData() { override val id: ConstraintId = ConstraintId.BT_DEVICE_CONNECTED } @Serializable - data class BtDeviceDisconnected( - override val uid: String = UUID.randomUUID().toString(), - val bluetoothAddress: String, - val deviceName: String, - ) : Constraint() { + data class BtDeviceDisconnected(val bluetoothAddress: String, val deviceName: String) : + ConstraintData() { override val id: ConstraintId = ConstraintId.BT_DEVICE_DISCONNECTED } @Serializable - data class ScreenOn(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object ScreenOn : ConstraintData() { override val id: ConstraintId = ConstraintId.SCREEN_ON } @Serializable - data class ScreenOff(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object ScreenOff : ConstraintData() { override val id: ConstraintId = ConstraintId.SCREEN_OFF } @Serializable - data class OrientationPortrait(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object OrientationPortrait : ConstraintData() { override val id: ConstraintId = ConstraintId.ORIENTATION_PORTRAIT } @Serializable - data class OrientationLandscape(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object OrientationLandscape : ConstraintData() { override val id: ConstraintId = ConstraintId.ORIENTATION_LANDSCAPE } @Serializable - data class OrientationCustom( - override val uid: String = UUID.randomUUID().toString(), - val orientation: Orientation, - ) : Constraint() { + data class OrientationCustom(val orientation: Orientation) : ConstraintData() { override val id: ConstraintId = when (orientation) { Orientation.ORIENTATION_0 -> ConstraintId.ORIENTATION_0 Orientation.ORIENTATION_90 -> ConstraintId.ORIENTATION_90 @@ -110,118 +88,107 @@ sealed class Constraint { } @Serializable - data class FlashlightOn( - override val uid: String = UUID.randomUUID().toString(), - val lens: CameraLens, - ) : Constraint() { + data class FlashlightOn(val lens: CameraLens) : ConstraintData() { override val id: ConstraintId = ConstraintId.FLASHLIGHT_ON } @Serializable - data class FlashlightOff( - override val uid: String = UUID.randomUUID().toString(), - val lens: CameraLens, - ) : Constraint() { + data class FlashlightOff(val lens: CameraLens) : ConstraintData() { override val id: ConstraintId = ConstraintId.FLASHLIGHT_OFF } @Serializable - data class WifiOn(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object WifiOn : ConstraintData() { override val id: ConstraintId = ConstraintId.WIFI_ON } @Serializable - data class WifiOff(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object WifiOff : ConstraintData() { override val id: ConstraintId = ConstraintId.WIFI_OFF } @Serializable - data class WifiConnected( - override val uid: String = UUID.randomUUID().toString(), - val ssid: String?, - ) : Constraint() { + data class WifiConnected(val ssid: String?) : ConstraintData() { override val id: ConstraintId = ConstraintId.WIFI_CONNECTED } @Serializable - data class WifiDisconnected( - override val uid: String = UUID.randomUUID().toString(), - val ssid: String?, - ) : Constraint() { + data class WifiDisconnected(val ssid: String?) : ConstraintData() { override val id: ConstraintId = ConstraintId.WIFI_DISCONNECTED } @Serializable - data class ImeChosen( - override val uid: String = UUID.randomUUID().toString(), - val imeId: String, - val imeLabel: String, - ) : Constraint() { + data class ImeChosen(val imeId: String, val imeLabel: String) : ConstraintData() { override val id: ConstraintId = ConstraintId.IME_CHOSEN } @Serializable - data class ImeNotChosen( - override val uid: String = UUID.randomUUID().toString(), - val imeId: String, - val imeLabel: String, - ) : Constraint() { + data class ImeNotChosen(val imeId: String, val imeLabel: String) : ConstraintData() { override val id: ConstraintId = ConstraintId.IME_NOT_CHOSEN } @Serializable - data class DeviceIsLocked(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object DeviceIsLocked : ConstraintData() { override val id: ConstraintId = ConstraintId.DEVICE_IS_LOCKED } @Serializable - data class DeviceIsUnlocked(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object DeviceIsUnlocked : ConstraintData() { override val id: ConstraintId = ConstraintId.DEVICE_IS_UNLOCKED } @Serializable - data class LockScreenShowing(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object LockScreenShowing : ConstraintData() { override val id: ConstraintId = ConstraintId.LOCK_SCREEN_SHOWING } @Serializable - data class LockScreenNotShowing(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object LockScreenNotShowing : ConstraintData() { override val id: ConstraintId = ConstraintId.LOCK_SCREEN_NOT_SHOWING } @Serializable - data class InPhoneCall(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object InPhoneCall : ConstraintData() { override val id: ConstraintId = ConstraintId.IN_PHONE_CALL } @Serializable - data class NotInPhoneCall(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object NotInPhoneCall : ConstraintData() { override val id: ConstraintId = ConstraintId.NOT_IN_PHONE_CALL } @Serializable - data class PhoneRinging(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object PhoneRinging : ConstraintData() { override val id: ConstraintId = ConstraintId.PHONE_RINGING } @Serializable - data class Charging(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object Charging : ConstraintData() { override val id: ConstraintId = ConstraintId.CHARGING } @Serializable - data class Discharging(override val uid: String = UUID.randomUUID().toString()) : Constraint() { + data object Discharging : ConstraintData() { override val id: ConstraintId = ConstraintId.DISCHARGING } + @Serializable + data object HingeClosed : ConstraintData() { + override val id: ConstraintId = ConstraintId.HINGE_CLOSED + } + + @Serializable + data object HingeOpen : ConstraintData() { + override val id: ConstraintId = ConstraintId.HINGE_OPEN + } + @Serializable data class Time( - override val uid: String = UUID.randomUUID().toString(), val startHour: Int, val startMinute: Int, val endHour: Int, val endMinute: Int, - ) : Constraint() { + ) : ConstraintData() { override val id: ConstraintId = ConstraintId.TIME val startTime: LocalTime by lazy { LocalTime.of(startHour, startMinute) } @@ -229,6 +196,11 @@ sealed class Constraint { } } +@Serializable +data class Constraint(val uid: String = UUID.randomUUID().toString(), val data: ConstraintData) { + val id: ConstraintId get() = data.id +} + object ConstraintModeEntityMapper { fun fromEntity(entity: Int): ConstraintMode = when (entity) { ConstraintEntity.MODE_AND -> ConstraintMode.AND @@ -250,11 +222,14 @@ object ConstraintEntityMapper { ) fun fromEntity(entity: ConstraintEntity): Constraint { - fun getPackageName(): String = entity.extras.getData(ConstraintEntity.EXTRA_PACKAGE_NAME).valueOrNull()!! + fun getPackageName(): String = + entity.extras.getData(ConstraintEntity.EXTRA_PACKAGE_NAME).valueOrNull()!! - fun getBluetoothAddress(): String = entity.extras.getData(ConstraintEntity.EXTRA_BT_ADDRESS).valueOrNull()!! + fun getBluetoothAddress(): String = + entity.extras.getData(ConstraintEntity.EXTRA_BT_ADDRESS).valueOrNull()!! - fun getBluetoothDeviceName(): String = entity.extras.getData(ConstraintEntity.EXTRA_BT_NAME).valueOrNull()!! + fun getBluetoothDeviceName(): String = + entity.extras.getData(ConstraintEntity.EXTRA_BT_NAME).valueOrNull()!! fun getCameraLens(): CameraLens { val extraValue = @@ -280,111 +255,99 @@ object ConstraintEntityMapper { return extraValue } - return when (entity.type) { - ConstraintEntity.APP_FOREGROUND -> Constraint.AppInForeground( - uid = entity.uid, + val constraintData = when (entity.type) { + ConstraintEntity.APP_FOREGROUND -> ConstraintData.AppInForeground( getPackageName(), ) - ConstraintEntity.APP_NOT_FOREGROUND -> Constraint.AppNotInForeground( - uid = entity.uid, + ConstraintEntity.APP_NOT_FOREGROUND -> ConstraintData.AppNotInForeground( getPackageName(), ) - ConstraintEntity.APP_PLAYING_MEDIA -> Constraint.AppPlayingMedia( - uid = entity.uid, + ConstraintEntity.APP_PLAYING_MEDIA -> ConstraintData.AppPlayingMedia( getPackageName(), ) - ConstraintEntity.APP_NOT_PLAYING_MEDIA -> Constraint.AppNotPlayingMedia( - uid = entity.uid, + ConstraintEntity.APP_NOT_PLAYING_MEDIA -> ConstraintData.AppNotPlayingMedia( getPackageName(), ) - ConstraintEntity.MEDIA_PLAYING -> Constraint.MediaPlaying(uid = entity.uid) - ConstraintEntity.NO_MEDIA_PLAYING -> Constraint.NoMediaPlaying(uid = entity.uid) + ConstraintEntity.MEDIA_PLAYING -> ConstraintData.MediaPlaying + ConstraintEntity.NO_MEDIA_PLAYING -> ConstraintData.NoMediaPlaying ConstraintEntity.BT_DEVICE_CONNECTED -> - Constraint.BtDeviceConnected( - uid = entity.uid, + ConstraintData.BtDeviceConnected( getBluetoothAddress(), getBluetoothDeviceName(), ) ConstraintEntity.BT_DEVICE_DISCONNECTED -> - Constraint.BtDeviceDisconnected( - uid = entity.uid, + ConstraintData.BtDeviceDisconnected( getBluetoothAddress(), getBluetoothDeviceName(), ) - ConstraintEntity.ORIENTATION_0 -> Constraint.OrientationCustom( - uid = entity.uid, + ConstraintEntity.ORIENTATION_0 -> ConstraintData.OrientationCustom( Orientation.ORIENTATION_0, ) - ConstraintEntity.ORIENTATION_90 -> Constraint.OrientationCustom( - uid = entity.uid, + ConstraintEntity.ORIENTATION_90 -> ConstraintData.OrientationCustom( Orientation.ORIENTATION_90, ) - ConstraintEntity.ORIENTATION_180 -> Constraint.OrientationCustom( - uid = entity.uid, + ConstraintEntity.ORIENTATION_180 -> ConstraintData.OrientationCustom( Orientation.ORIENTATION_180, ) - ConstraintEntity.ORIENTATION_270 -> Constraint.OrientationCustom( - uid = entity.uid, + ConstraintEntity.ORIENTATION_270 -> ConstraintData.OrientationCustom( Orientation.ORIENTATION_270, ) - ConstraintEntity.ORIENTATION_PORTRAIT -> Constraint.OrientationPortrait(uid = entity.uid) - ConstraintEntity.ORIENTATION_LANDSCAPE -> Constraint.OrientationLandscape(uid = entity.uid) + ConstraintEntity.ORIENTATION_PORTRAIT -> ConstraintData.OrientationPortrait + ConstraintEntity.ORIENTATION_LANDSCAPE -> ConstraintData.OrientationLandscape - ConstraintEntity.SCREEN_OFF -> Constraint.ScreenOff(uid = entity.uid) - ConstraintEntity.SCREEN_ON -> Constraint.ScreenOn(uid = entity.uid) + ConstraintEntity.SCREEN_OFF -> ConstraintData.ScreenOff + ConstraintEntity.SCREEN_ON -> ConstraintData.ScreenOn - ConstraintEntity.FLASHLIGHT_ON -> Constraint.FlashlightOn( - uid = entity.uid, + ConstraintEntity.FLASHLIGHT_ON -> ConstraintData.FlashlightOn( getCameraLens(), ) - ConstraintEntity.FLASHLIGHT_OFF -> Constraint.FlashlightOff( - uid = entity.uid, + ConstraintEntity.FLASHLIGHT_OFF -> ConstraintData.FlashlightOff( getCameraLens(), ) - ConstraintEntity.WIFI_ON -> Constraint.WifiOn(uid = entity.uid) - ConstraintEntity.WIFI_OFF -> Constraint.WifiOff(uid = entity.uid) - ConstraintEntity.WIFI_CONNECTED -> Constraint.WifiConnected(uid = entity.uid, getSsid()) - ConstraintEntity.WIFI_DISCONNECTED -> Constraint.WifiDisconnected( - uid = entity.uid, + ConstraintEntity.WIFI_ON -> ConstraintData.WifiOn + ConstraintEntity.WIFI_OFF -> ConstraintData.WifiOff + ConstraintEntity.WIFI_CONNECTED -> ConstraintData.WifiConnected(getSsid()) + ConstraintEntity.WIFI_DISCONNECTED -> ConstraintData.WifiDisconnected( getSsid(), ) - ConstraintEntity.IME_CHOSEN -> Constraint.ImeChosen( - uid = entity.uid, + ConstraintEntity.IME_CHOSEN -> ConstraintData.ImeChosen( getImeId(), getImeLabel(), ) - ConstraintEntity.IME_NOT_CHOSEN -> Constraint.ImeNotChosen( - uid = entity.uid, + ConstraintEntity.IME_NOT_CHOSEN -> ConstraintData.ImeNotChosen( getImeId(), getImeLabel(), ) - ConstraintEntity.DEVICE_IS_UNLOCKED -> Constraint.DeviceIsUnlocked(uid = entity.uid) - ConstraintEntity.DEVICE_IS_LOCKED -> Constraint.DeviceIsLocked(uid = entity.uid) - ConstraintEntity.LOCK_SCREEN_SHOWING -> Constraint.LockScreenShowing(uid = entity.uid) - ConstraintEntity.LOCK_SCREEN_NOT_SHOWING -> Constraint.LockScreenNotShowing(uid = entity.uid) + ConstraintEntity.DEVICE_IS_UNLOCKED -> ConstraintData.DeviceIsUnlocked + ConstraintEntity.DEVICE_IS_LOCKED -> ConstraintData.DeviceIsLocked + ConstraintEntity.LOCK_SCREEN_SHOWING -> ConstraintData.LockScreenShowing + ConstraintEntity.LOCK_SCREEN_NOT_SHOWING -> ConstraintData.LockScreenNotShowing + + ConstraintEntity.PHONE_RINGING -> ConstraintData.PhoneRinging + ConstraintEntity.IN_PHONE_CALL -> ConstraintData.InPhoneCall + ConstraintEntity.NOT_IN_PHONE_CALL -> ConstraintData.NotInPhoneCall - ConstraintEntity.PHONE_RINGING -> Constraint.PhoneRinging(uid = entity.uid) - ConstraintEntity.IN_PHONE_CALL -> Constraint.InPhoneCall(uid = entity.uid) - ConstraintEntity.NOT_IN_PHONE_CALL -> Constraint.NotInPhoneCall(uid = entity.uid) + ConstraintEntity.CHARGING -> ConstraintData.Charging + ConstraintEntity.DISCHARGING -> ConstraintData.Discharging - ConstraintEntity.CHARGING -> Constraint.Charging(uid = entity.uid) - ConstraintEntity.DISCHARGING -> Constraint.Discharging(uid = entity.uid) + ConstraintEntity.HINGE_CLOSED -> ConstraintData.HingeClosed + ConstraintEntity.HINGE_OPEN -> ConstraintData.HingeOpen ConstraintEntity.TIME -> { val startTime = @@ -399,8 +362,7 @@ object ConstraintEntityMapper { val endHour = endTime[0].toInt() val endMin = endTime[1].toInt() - Constraint.Time( - uid = entity.uid, + ConstraintData.Time( startHour = startHour, startMinute = startMin, endHour = endHour, @@ -408,84 +370,91 @@ object ConstraintEntityMapper { ) } - else -> throw Exception("don't know how to convert constraint entity with type ${entity.type}") + else -> throw Exception( + "don't know how to convert constraint entity with type ${entity.type}", + ) } + + return Constraint( + uid = entity.uid, + data = constraintData, + ) } - fun toEntity(constraint: Constraint): ConstraintEntity = when (constraint) { - is Constraint.AppInForeground -> ConstraintEntity( + fun toEntity(constraint: Constraint): ConstraintEntity = when (constraint.data) { + is ConstraintData.AppInForeground -> ConstraintEntity( uid = constraint.uid, type = ConstraintEntity.APP_FOREGROUND, extras = listOf( EntityExtra( ConstraintEntity.EXTRA_PACKAGE_NAME, - constraint.packageName, + constraint.data.packageName, ), ), ) - is Constraint.AppNotInForeground -> ConstraintEntity( + is ConstraintData.AppNotInForeground -> ConstraintEntity( uid = constraint.uid, type = ConstraintEntity.APP_NOT_FOREGROUND, extras = listOf( EntityExtra( ConstraintEntity.EXTRA_PACKAGE_NAME, - constraint.packageName, + constraint.data.packageName, ), ), ) - is Constraint.AppPlayingMedia -> ConstraintEntity( + is ConstraintData.AppPlayingMedia -> ConstraintEntity( uid = constraint.uid, type = ConstraintEntity.APP_PLAYING_MEDIA, extras = listOf( EntityExtra( ConstraintEntity.EXTRA_PACKAGE_NAME, - constraint.packageName, + constraint.data.packageName, ), ), ) - is Constraint.AppNotPlayingMedia -> ConstraintEntity( + is ConstraintData.AppNotPlayingMedia -> ConstraintEntity( uid = constraint.uid, type = ConstraintEntity.APP_NOT_PLAYING_MEDIA, extras = listOf( EntityExtra( ConstraintEntity.EXTRA_PACKAGE_NAME, - constraint.packageName, + constraint.data.packageName, ), ), ) - is Constraint.MediaPlaying -> ConstraintEntity( + is ConstraintData.MediaPlaying -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.MEDIA_PLAYING, ) - is Constraint.NoMediaPlaying -> ConstraintEntity( + is ConstraintData.NoMediaPlaying -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.NO_MEDIA_PLAYING, ) - is Constraint.BtDeviceConnected -> ConstraintEntity( + is ConstraintData.BtDeviceConnected -> ConstraintEntity( uid = constraint.uid, type = ConstraintEntity.BT_DEVICE_CONNECTED, extras = listOf( - EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, constraint.bluetoothAddress), - EntityExtra(ConstraintEntity.EXTRA_BT_NAME, constraint.deviceName), + EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, constraint.data.bluetoothAddress), + EntityExtra(ConstraintEntity.EXTRA_BT_NAME, constraint.data.deviceName), ), ) - is Constraint.BtDeviceDisconnected -> ConstraintEntity( + is ConstraintData.BtDeviceDisconnected -> ConstraintEntity( uid = constraint.uid, type = ConstraintEntity.BT_DEVICE_DISCONNECTED, extras = listOf( - EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, constraint.bluetoothAddress), - EntityExtra(ConstraintEntity.EXTRA_BT_NAME, constraint.deviceName), + EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, constraint.data.bluetoothAddress), + EntityExtra(ConstraintEntity.EXTRA_BT_NAME, constraint.data.deviceName), ), ) - is Constraint.OrientationCustom -> when (constraint.orientation) { + is ConstraintData.OrientationCustom -> when (constraint.data.orientation) { Orientation.ORIENTATION_0 -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.ORIENTATION_0, @@ -507,43 +476,49 @@ object ConstraintEntityMapper { ) } - is Constraint.OrientationLandscape -> ConstraintEntity( + is ConstraintData.OrientationLandscape -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.ORIENTATION_LANDSCAPE, ) - is Constraint.OrientationPortrait -> ConstraintEntity( + is ConstraintData.OrientationPortrait -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.ORIENTATION_PORTRAIT, ) - is Constraint.ScreenOff -> ConstraintEntity( + is ConstraintData.ScreenOff -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.SCREEN_OFF, ) - is Constraint.ScreenOn -> ConstraintEntity( + is ConstraintData.ScreenOn -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.SCREEN_ON, ) - is Constraint.FlashlightOff -> ConstraintEntity( + is ConstraintData.FlashlightOff -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.FLASHLIGHT_OFF, - EntityExtra(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, LENS_MAP[constraint.lens]!!), + EntityExtra( + ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, + LENS_MAP[constraint.data.lens]!!, + ), ) - is Constraint.FlashlightOn -> ConstraintEntity( + is ConstraintData.FlashlightOn -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.FLASHLIGHT_ON, - EntityExtra(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, LENS_MAP[constraint.lens]!!), + EntityExtra( + ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, + LENS_MAP[constraint.data.lens]!!, + ), ) - is Constraint.WifiConnected -> { + is ConstraintData.WifiConnected -> { val extras = mutableListOf() - if (constraint.ssid != null) { - extras.add(EntityExtra(ConstraintEntity.EXTRA_SSID, constraint.ssid)) + if (constraint.data.ssid != null) { + extras.add(EntityExtra(ConstraintEntity.EXTRA_SSID, constraint.data.ssid)) } ConstraintEntity( @@ -553,11 +528,11 @@ object ConstraintEntityMapper { ) } - is Constraint.WifiDisconnected -> { + is ConstraintData.WifiDisconnected -> { val extras = mutableListOf() - if (constraint.ssid != null) { - extras.add(EntityExtra(ConstraintEntity.EXTRA_SSID, constraint.ssid)) + if (constraint.data.ssid != null) { + extras.add(EntityExtra(ConstraintEntity.EXTRA_SSID, constraint.data.ssid)) } ConstraintEntity( @@ -567,89 +542,99 @@ object ConstraintEntityMapper { ) } - is Constraint.WifiOff -> ConstraintEntity( + is ConstraintData.WifiOff -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.WIFI_OFF, ) - is Constraint.WifiOn -> ConstraintEntity( + is ConstraintData.WifiOn -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.WIFI_ON, ) - is Constraint.ImeChosen -> { + is ConstraintData.ImeChosen -> { ConstraintEntity( uid = constraint.uid, ConstraintEntity.IME_CHOSEN, - EntityExtra(ConstraintEntity.EXTRA_IME_ID, constraint.imeId), - EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, constraint.imeLabel), + EntityExtra(ConstraintEntity.EXTRA_IME_ID, constraint.data.imeId), + EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, constraint.data.imeLabel), ) } - is Constraint.ImeNotChosen -> { + is ConstraintData.ImeNotChosen -> { ConstraintEntity( uid = constraint.uid, ConstraintEntity.IME_NOT_CHOSEN, - EntityExtra(ConstraintEntity.EXTRA_IME_ID, constraint.imeId), - EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, constraint.imeLabel), + EntityExtra(ConstraintEntity.EXTRA_IME_ID, constraint.data.imeId), + EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, constraint.data.imeLabel), ) } - is Constraint.DeviceIsLocked -> ConstraintEntity( + is ConstraintData.DeviceIsLocked -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.DEVICE_IS_LOCKED, ) - is Constraint.DeviceIsUnlocked -> ConstraintEntity( + is ConstraintData.DeviceIsUnlocked -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.DEVICE_IS_UNLOCKED, ) - is Constraint.LockScreenShowing -> ConstraintEntity( + is ConstraintData.LockScreenShowing -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.LOCK_SCREEN_SHOWING, ) - is Constraint.LockScreenNotShowing -> ConstraintEntity( + is ConstraintData.LockScreenNotShowing -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.LOCK_SCREEN_NOT_SHOWING, ) - is Constraint.InPhoneCall -> ConstraintEntity( + is ConstraintData.InPhoneCall -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.IN_PHONE_CALL, ) - is Constraint.NotInPhoneCall -> ConstraintEntity( + is ConstraintData.NotInPhoneCall -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.NOT_IN_PHONE_CALL, ) - is Constraint.PhoneRinging -> ConstraintEntity( + is ConstraintData.PhoneRinging -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.PHONE_RINGING, ) - is Constraint.Charging -> ConstraintEntity( + is ConstraintData.Charging -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.CHARGING, ) - is Constraint.Discharging -> ConstraintEntity( + is ConstraintData.Discharging -> ConstraintEntity( uid = constraint.uid, ConstraintEntity.DISCHARGING, ) - is Constraint.Time -> ConstraintEntity( + is ConstraintData.HingeClosed -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.HINGE_CLOSED, + ) + + is ConstraintData.HingeOpen -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.HINGE_OPEN, + ) + + is ConstraintData.Time -> ConstraintEntity( uid = constraint.uid, type = ConstraintEntity.TIME, EntityExtra( ConstraintEntity.EXTRA_START_TIME, - "${constraint.startHour}:${constraint.startMinute}", + "${constraint.data.startHour}:${constraint.data.startMinute}", ), EntityExtra( ConstraintEntity.EXTRA_END_TIME, - "${constraint.endHour}:${constraint.endMinute}", + "${constraint.data.endHour}:${constraint.data.endMinute}", ), ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt index 916eec0ae6..2036fe2d97 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt @@ -15,4 +15,5 @@ enum class ConstraintDependency { LOCK_SCREEN_SHOWING, PHONE_STATE, CHARGING_STATE, + HINGE_STATE, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt index 6820372ab5..67eecff0af 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt @@ -36,35 +36,35 @@ class LazyConstraintErrorSnapshot( } override fun getError(constraint: Constraint): KMError? { - when (constraint) { - is Constraint.AppInForeground -> return getAppError(constraint.packageName) - is Constraint.AppNotInForeground -> return getAppError(constraint.packageName) + when (constraint.data) { + is ConstraintData.AppInForeground -> return getAppError(constraint.data.packageName) + is ConstraintData.AppNotInForeground -> return getAppError(constraint.data.packageName) - is Constraint.AppPlayingMedia -> { + is ConstraintData.AppPlayingMedia -> { if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) { return SystemError.PermissionDenied(Permission.NOTIFICATION_LISTENER) } - return getAppError(constraint.packageName) + return getAppError(constraint.data.packageName) } - is Constraint.AppNotPlayingMedia -> { + is ConstraintData.AppNotPlayingMedia -> { if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) { return SystemError.PermissionDenied(Permission.NOTIFICATION_LISTENER) } - return getAppError(constraint.packageName) + return getAppError(constraint.data.packageName) } - Constraint.MediaPlaying, Constraint.NoMediaPlaying -> { + ConstraintData.MediaPlaying, ConstraintData.NoMediaPlaying -> { if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) { return SystemError.PermissionDenied(Permission.NOTIFICATION_LISTENER) } } - is Constraint.BtDeviceConnected, - is Constraint.BtDeviceDisconnected, - -> { + is ConstraintData.BtDeviceConnected, + is ConstraintData.BtDeviceDisconnected, + -> { if (!systemFeatureAdapter.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) { return KMError.SystemFeatureNotSupported(PackageManager.FEATURE_BLUETOOTH) } @@ -74,53 +74,56 @@ class LazyConstraintErrorSnapshot( } } - is Constraint.OrientationCustom, - Constraint.OrientationLandscape, - Constraint.OrientationPortrait, - -> + is ConstraintData.OrientationCustom, + ConstraintData.OrientationLandscape, + ConstraintData.OrientationPortrait, + -> if (!isPermissionGranted(Permission.WRITE_SETTINGS)) { return SystemError.PermissionDenied(Permission.WRITE_SETTINGS) } - is Constraint.FlashlightOn -> { + is ConstraintData.FlashlightOn -> { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) } - if (!flashLenses.contains(constraint.lens)) { - return when (constraint.lens) { + if (!flashLenses.contains(constraint.data.lens)) { + return when (constraint.data.lens) { CameraLens.FRONT -> KMError.FrontFlashNotFound CameraLens.BACK -> KMError.BackFlashNotFound } } } - is Constraint.FlashlightOff -> { + is ConstraintData.FlashlightOff -> { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) } - if (!flashLenses.contains(constraint.lens)) { - return when (constraint.lens) { + if (!flashLenses.contains(constraint.data.lens)) { + return when (constraint.data.lens) { CameraLens.FRONT -> KMError.FrontFlashNotFound CameraLens.BACK -> KMError.BackFlashNotFound } } } - is Constraint.WifiConnected, is Constraint.WifiDisconnected -> { + is ConstraintData.WifiConnected, is ConstraintData.WifiDisconnected -> { if (!isPermissionGranted(Permission.ACCESS_FINE_LOCATION)) { return SystemError.PermissionDenied(Permission.ACCESS_FINE_LOCATION) } } - is Constraint.ImeChosen -> { - if (inputMethods.none { it.id == constraint.imeId }) { - return KMError.InputMethodNotFound(constraint.imeLabel) + is ConstraintData.ImeChosen -> { + if (inputMethods.none { it.id == constraint.data.imeId }) { + return KMError.InputMethodNotFound(constraint.data.imeLabel) } } - is Constraint.InPhoneCall, is Constraint.PhoneRinging, is Constraint.NotInPhoneCall -> { + is ConstraintData.InPhoneCall, + is ConstraintData.PhoneRinging, + is ConstraintData.NotInPhoneCall, + -> { if (!isPermissionGranted(Permission.READ_PHONE_STATE)) { return SystemError.PermissionDenied(Permission.READ_PHONE_STATE) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt index 6ff99f2b54..57cf603867 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt @@ -49,5 +49,8 @@ enum class ConstraintId { CHARGING, DISCHARGING, + HINGE_CLOSED, + HINGE_OPEN, + TIME, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt index 51d88a1ade..ab1115f82b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt @@ -116,7 +116,9 @@ fun ConstraintListItem( IconButton(onClick = onRemoveClick) { Icon( imageVector = Icons.Rounded.Clear, - contentDescription = stringResource(R.string.constraint_list_item_remove), + contentDescription = stringResource( + R.string.constraint_list_item_remove, + ), tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(24.dp), ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt index e5803e9b0a..6c33b1a456 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt @@ -9,6 +9,10 @@ import io.github.sds100.keymapper.system.bluetooth.BluetoothDeviceInfo import io.github.sds100.keymapper.system.camera.CameraAdapter import io.github.sds100.keymapper.system.devices.DevicesAdapter import io.github.sds100.keymapper.system.display.DisplayAdapter +import io.github.sds100.keymapper.system.foldable.FoldableAdapter +import io.github.sds100.keymapper.system.foldable.HingeState +import io.github.sds100.keymapper.system.foldable.isClosed +import io.github.sds100.keymapper.system.foldable.isOpen import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.lock.LockScreenAdapter import io.github.sds100.keymapper.system.media.MediaAdapter @@ -16,7 +20,6 @@ import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.phone.CallState import io.github.sds100.keymapper.system.phone.PhoneAdapter import io.github.sds100.keymapper.system.power.PowerAdapter -import timber.log.Timber import java.time.LocalTime /** @@ -33,33 +36,32 @@ class LazyConstraintSnapshot( lockScreenAdapter: LockScreenAdapter, phoneAdapter: PhoneAdapter, powerAdapter: PowerAdapter, + private val foldableAdapter: FoldableAdapter, ) : ConstraintSnapshot { private val appInForeground: String? by lazy { accessibilityService.rootNode?.packageName } - private val connectedBluetoothDevices: Set by lazy { devicesAdapter.connectedBluetoothDevices.value } + private val connectedBluetoothDevices: Set by lazy { + devicesAdapter.connectedBluetoothDevices.value + } private val orientation: Orientation by lazy { displayAdapter.cachedOrientation } private val isScreenOn: Boolean by lazy { displayAdapter.isScreenOn.firstBlocking() } - private val appsPlayingMedia: List by lazy { mediaAdapter.getActiveMediaSessionPackages() } + private val appsPlayingMedia: List by lazy { + mediaAdapter.getActiveMediaSessionPackages() + } private val audioVolumeStreams: Set by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - mediaAdapter.getActiveAudioVolumeStreams() - } else { - emptySet() - } + mediaAdapter.getActiveAudioVolumeStreams() } private val isWifiEnabled: Boolean by lazy { networkAdapter.isWifiEnabled() } - private val connectedWifiSSID: String? by lazy { networkAdapter.connectedWifiSSID } + private val connectedWifiSSID: String? by lazy { + networkAdapter.connectedWifiSSIDFlow.firstBlocking() + } private val chosenImeId: String? by lazy { inputMethodAdapter.chosenIme.value?.id } private val callState: CallState by lazy { phoneAdapter.getCallState() } private val isCharging: Boolean by lazy { powerAdapter.isCharging.value } private val isLocked: Boolean by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - lockScreenAdapter.isLocked() - } else { - false - } + lockScreenAdapter.isLocked() } private val isLockscreenShowing: Boolean by lazy { @@ -69,105 +71,130 @@ class LazyConstraintSnapshot( private val localTime = LocalTime.now() private fun isMediaPlaying(): Boolean { - return audioVolumeStreams.contains(AudioManager.STREAM_MUSIC) || appsPlayingMedia.isNotEmpty() + return audioVolumeStreams.contains(AudioManager.STREAM_MUSIC) || + appsPlayingMedia.isNotEmpty() } override fun isSatisfied(constraint: Constraint): Boolean { - val isSatisfied = when (constraint) { - is Constraint.AppInForeground -> appInForeground == constraint.packageName - is Constraint.AppNotInForeground -> appInForeground != constraint.packageName - is Constraint.AppPlayingMedia -> { - if (appsPlayingMedia.contains(constraint.packageName)) { + val isSatisfied = when (constraint.data) { + is ConstraintData.AppInForeground -> appInForeground == constraint.data.packageName + is ConstraintData.AppNotInForeground -> appInForeground != constraint.data.packageName + is ConstraintData.AppPlayingMedia -> { + if (appsPlayingMedia.contains(constraint.data.packageName)) { return true - } else if (appInForeground == constraint.packageName && isMediaPlaying()) { + } else if (appInForeground == constraint.data.packageName && isMediaPlaying()) { return true } else { return false } } - is Constraint.AppNotPlayingMedia -> - appsPlayingMedia.none { it == constraint.packageName } && - !(appInForeground == constraint.packageName && isMediaPlaying()) + is ConstraintData.AppNotPlayingMedia -> + appsPlayingMedia.none { it == constraint.data.packageName } && + !(appInForeground == constraint.data.packageName && isMediaPlaying()) - is Constraint.MediaPlaying -> isMediaPlaying() + is ConstraintData.MediaPlaying -> isMediaPlaying() - is Constraint.NoMediaPlaying -> !isMediaPlaying() + is ConstraintData.NoMediaPlaying -> !isMediaPlaying() - is Constraint.BtDeviceConnected -> { - connectedBluetoothDevices.any { it.address == constraint.bluetoothAddress } + is ConstraintData.BtDeviceConnected -> { + connectedBluetoothDevices.any { it.address == constraint.data.bluetoothAddress } } - is Constraint.BtDeviceDisconnected -> { - connectedBluetoothDevices.none { it.address == constraint.bluetoothAddress } + is ConstraintData.BtDeviceDisconnected -> { + connectedBluetoothDevices.none { it.address == constraint.data.bluetoothAddress } } - is Constraint.OrientationCustom -> orientation == constraint.orientation - is Constraint.OrientationLandscape -> - orientation == Orientation.ORIENTATION_90 || orientation == Orientation.ORIENTATION_270 - - is Constraint.OrientationPortrait -> - orientation == Orientation.ORIENTATION_0 || orientation == Orientation.ORIENTATION_180 - - is Constraint.ScreenOff -> !isScreenOn - is Constraint.ScreenOn -> isScreenOn - is Constraint.FlashlightOff -> !cameraAdapter.isFlashlightOn(constraint.lens) - is Constraint.FlashlightOn -> cameraAdapter.isFlashlightOn(constraint.lens) - is Constraint.WifiConnected -> { - if (constraint.ssid == null) { + is ConstraintData.OrientationCustom -> orientation == constraint.data.orientation + is ConstraintData.OrientationLandscape -> + orientation == Orientation.ORIENTATION_90 || + orientation == Orientation.ORIENTATION_270 + + is ConstraintData.OrientationPortrait -> + orientation == Orientation.ORIENTATION_0 || + orientation == Orientation.ORIENTATION_180 + + is ConstraintData.ScreenOff -> !isScreenOn + is ConstraintData.ScreenOn -> isScreenOn + is ConstraintData.FlashlightOff -> !cameraAdapter.isFlashlightOn(constraint.data.lens) + is ConstraintData.FlashlightOn -> cameraAdapter.isFlashlightOn(constraint.data.lens) + is ConstraintData.WifiConnected -> { + if (constraint.data.ssid == null) { // connected to any network connectedWifiSSID != null } else { - connectedWifiSSID == constraint.ssid + connectedWifiSSID == constraint.data.ssid } } - is Constraint.WifiDisconnected -> - if (constraint.ssid == null) { + is ConstraintData.WifiDisconnected -> + if (constraint.data.ssid == null) { // connected to no network connectedWifiSSID == null } else { - connectedWifiSSID != constraint.ssid + connectedWifiSSID != constraint.data.ssid } - is Constraint.WifiOff -> !isWifiEnabled - is Constraint.WifiOn -> isWifiEnabled - is Constraint.ImeChosen -> chosenImeId == constraint.imeId - is Constraint.ImeNotChosen -> chosenImeId != constraint.imeId - is Constraint.DeviceIsLocked -> isLocked - is Constraint.DeviceIsUnlocked -> !isLocked - is Constraint.InPhoneCall -> + is ConstraintData.WifiOff -> !isWifiEnabled + is ConstraintData.WifiOn -> isWifiEnabled + is ConstraintData.ImeChosen -> chosenImeId == constraint.data.imeId + is ConstraintData.ImeNotChosen -> chosenImeId != constraint.data.imeId + is ConstraintData.DeviceIsLocked -> isLocked + is ConstraintData.DeviceIsUnlocked -> !isLocked + is ConstraintData.InPhoneCall -> callState == CallState.IN_PHONE_CALL || audioVolumeStreams.contains(AudioManager.STREAM_VOICE_CALL) - is Constraint.NotInPhoneCall -> + is ConstraintData.NotInPhoneCall -> callState == CallState.NONE && !audioVolumeStreams.contains(AudioManager.STREAM_VOICE_CALL) - is Constraint.PhoneRinging -> + is ConstraintData.PhoneRinging -> callState == CallState.RINGING || audioVolumeStreams.contains(AudioManager.STREAM_RING) - is Constraint.Charging -> isCharging - is Constraint.Discharging -> !isCharging + is ConstraintData.Charging -> isCharging + is ConstraintData.Discharging -> !isCharging - // The keyguard manager still reports the lock screen as showing if you are in - // an another activity like the camera app while the phone is locked. - is Constraint.LockScreenShowing -> isLockscreenShowing && appInForeground == "com.android.systemui" - is Constraint.LockScreenNotShowing -> !isLockscreenShowing || appInForeground != "com.android.systemui" + is ConstraintData.HingeClosed -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + when (val state = foldableAdapter.hingeState.value) { + is HingeState.Available -> state.isClosed() + is HingeState.Unavailable -> false + } + } else { + false + } + } - is Constraint.Time -> - if (constraint.startTime.isAfter(constraint.endTime)) { - localTime.isAfter(constraint.startTime) || localTime.isBefore(constraint.endTime) + is ConstraintData.HingeOpen -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + when (val state = foldableAdapter.hingeState.value) { + is HingeState.Available -> state.isOpen() + is HingeState.Unavailable -> false + } } else { - localTime.isAfter(constraint.startTime) && localTime.isBefore(constraint.endTime) + false } - } + } - if (isSatisfied) { - Timber.d("Constraint satisfied: $constraint") - } else { - Timber.d("Constraint not satisfied: $constraint") + // The keyguard manager still reports the lock screen as showing if you are in + // an another activity like the camera app while the phone is locked. + is ConstraintData.LockScreenShowing -> + isLockscreenShowing && + appInForeground == "com.android.systemui" + is ConstraintData.LockScreenNotShowing -> + !isLockscreenShowing || + appInForeground != "com.android.systemui" + + is ConstraintData.Time -> + if (constraint.data.startTime.isAfter(constraint.data.endTime)) { + localTime.isAfter(constraint.data.startTime) || + localTime.isBefore(constraint.data.endTime) + } else { + localTime.isAfter(constraint.data.startTime) && + localTime.isBefore(constraint.data.endTime) + } } return isSatisfied diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt index e1db1b1f0e..28f1a97154 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt @@ -20,27 +20,27 @@ class ConstraintUiHelper( private val timeFormatter by lazy { TimeUtils.localeDateFormatter(FormatStyle.SHORT) } - fun getTitle(constraint: Constraint): String = when (constraint) { - is Constraint.AppInForeground -> - getAppName(constraint.packageName).handle( + fun getTitle(constraint: Constraint): String = when (constraint.data) { + is ConstraintData.AppInForeground -> + getAppName(constraint.data.packageName).handle( onSuccess = { getString(R.string.constraint_app_foreground_description, it) }, onError = { getString(R.string.constraint_choose_app_foreground) }, ) - is Constraint.AppNotInForeground -> - getAppName(constraint.packageName).handle( + is ConstraintData.AppNotInForeground -> + getAppName(constraint.data.packageName).handle( onSuccess = { getString(R.string.constraint_app_not_foreground_description, it) }, onError = { getString(R.string.constraint_choose_app_not_foreground) }, ) - is Constraint.AppPlayingMedia -> - getAppName(constraint.packageName).handle( + is ConstraintData.AppPlayingMedia -> + getAppName(constraint.data.packageName).handle( onSuccess = { getString(R.string.constraint_app_playing_media_description, it) }, onError = { getString(R.string.constraint_choose_app_playing_media) }, ) - is Constraint.AppNotPlayingMedia -> - getAppName(constraint.packageName).handle( + is ConstraintData.AppNotPlayingMedia -> + getAppName(constraint.data.packageName).handle( onSuccess = { getString( R.string.constraint_app_not_playing_media_description, @@ -50,23 +50,23 @@ class ConstraintUiHelper( onError = { getString(R.string.constraint_choose_app_playing_media) }, ) - is Constraint.MediaPlaying -> getString(R.string.constraint_choose_media_playing) - is Constraint.NoMediaPlaying -> getString(R.string.constraint_choose_media_not_playing) + is ConstraintData.MediaPlaying -> getString(R.string.constraint_choose_media_playing) + is ConstraintData.NoMediaPlaying -> getString(R.string.constraint_choose_media_not_playing) - is Constraint.BtDeviceConnected -> + is ConstraintData.BtDeviceConnected -> getString( R.string.constraint_bt_device_connected_description, - constraint.deviceName, + constraint.data.deviceName, ) - is Constraint.BtDeviceDisconnected -> + is ConstraintData.BtDeviceDisconnected -> getString( R.string.constraint_bt_device_disconnected_description, - constraint.deviceName, + constraint.data.deviceName, ) - is Constraint.OrientationCustom -> { - val resId = when (constraint.orientation) { + is ConstraintData.OrientationCustom -> { + val resId = when (constraint.data.orientation) { Orientation.ORIENTATION_0 -> R.string.constraint_choose_orientation_0 Orientation.ORIENTATION_90 -> R.string.constraint_choose_orientation_90 Orientation.ORIENTATION_180 -> R.string.constraint_choose_orientation_180 @@ -76,101 +76,106 @@ class ConstraintUiHelper( getString(resId) } - is Constraint.OrientationLandscape -> + is ConstraintData.OrientationLandscape -> getString(R.string.constraint_choose_orientation_landscape) - is Constraint.OrientationPortrait -> + is ConstraintData.OrientationPortrait -> getString(R.string.constraint_choose_orientation_portrait) - is Constraint.ScreenOff -> + is ConstraintData.ScreenOff -> getString(R.string.constraint_screen_off_description) - is Constraint.ScreenOn -> + is ConstraintData.ScreenOn -> getString(R.string.constraint_screen_on_description) - is Constraint.FlashlightOff -> if (constraint.lens == CameraLens.FRONT) { + is ConstraintData.FlashlightOff -> if (constraint.data.lens == CameraLens.FRONT) { getString(R.string.constraint_front_flashlight_off_description) } else { getString(R.string.constraint_flashlight_off_description) } - is Constraint.FlashlightOn -> if (constraint.lens == CameraLens.FRONT) { + is ConstraintData.FlashlightOn -> if (constraint.data.lens == CameraLens.FRONT) { getString(R.string.constraint_front_flashlight_on_description) } else { getString(R.string.constraint_flashlight_on_description) } - is Constraint.WifiConnected -> { - if (constraint.ssid == null) { + is ConstraintData.WifiConnected -> { + if (constraint.data.ssid == null) { getString(R.string.constraint_wifi_connected_any_description) } else { - getString(R.string.constraint_wifi_connected_description, constraint.ssid) + getString(R.string.constraint_wifi_connected_description, constraint.data.ssid) } } - is Constraint.WifiDisconnected -> { - if (constraint.ssid == null) { + is ConstraintData.WifiDisconnected -> { + if (constraint.data.ssid == null) { getString(R.string.constraint_wifi_disconnected_any_description) } else { - getString(R.string.constraint_wifi_disconnected_description, constraint.ssid) + getString(R.string.constraint_wifi_disconnected_description, constraint.data.ssid) } } - is Constraint.WifiOff -> getString(R.string.constraint_wifi_off) - is Constraint.WifiOn -> getString(R.string.constraint_wifi_on) + is ConstraintData.WifiOff -> getString(R.string.constraint_wifi_off) + is ConstraintData.WifiOn -> getString(R.string.constraint_wifi_on) - is Constraint.ImeChosen -> { - val label = getInputMethodLabel(constraint.imeId).valueIfFailure { - constraint.imeLabel + is ConstraintData.ImeChosen -> { + val label = getInputMethodLabel(constraint.data.imeId).valueIfFailure { + constraint.data.imeLabel } getString(R.string.constraint_ime_chosen_description, label) } - is Constraint.ImeNotChosen -> { - val label = getInputMethodLabel(constraint.imeId).valueIfFailure { - constraint.imeLabel + is ConstraintData.ImeNotChosen -> { + val label = getInputMethodLabel(constraint.data.imeId).valueIfFailure { + constraint.data.imeLabel } getString(R.string.constraint_ime_not_chosen_description, label) } - is Constraint.DeviceIsLocked -> getString(R.string.constraint_device_is_locked) - is Constraint.DeviceIsUnlocked -> getString(R.string.constraint_device_is_unlocked) - is Constraint.InPhoneCall -> getString(R.string.constraint_in_phone_call) - is Constraint.NotInPhoneCall -> getString(R.string.constraint_not_in_phone_call) - is Constraint.PhoneRinging -> getString(R.string.constraint_phone_ringing) - is Constraint.Charging -> getString(R.string.constraint_charging) - is Constraint.Discharging -> getString(R.string.constraint_discharging) - is Constraint.LockScreenShowing -> getString(R.string.constraint_lock_screen_showing) - is Constraint.LockScreenNotShowing -> getString(R.string.constraint_lock_screen_not_showing) - is Constraint.Time -> getString( + is ConstraintData.DeviceIsLocked -> getString(R.string.constraint_device_is_locked) + is ConstraintData.DeviceIsUnlocked -> getString(R.string.constraint_device_is_unlocked) + is ConstraintData.InPhoneCall -> getString(R.string.constraint_in_phone_call) + is ConstraintData.NotInPhoneCall -> getString(R.string.constraint_not_in_phone_call) + is ConstraintData.PhoneRinging -> getString(R.string.constraint_phone_ringing) + is ConstraintData.Charging -> getString(R.string.constraint_charging) + is ConstraintData.Discharging -> getString(R.string.constraint_discharging) + is ConstraintData.HingeClosed -> getString(R.string.constraint_hinge_closed_description) + is ConstraintData.HingeOpen -> getString(R.string.constraint_hinge_open_description) + is ConstraintData.LockScreenShowing -> getString(R.string.constraint_lock_screen_showing) + is ConstraintData.LockScreenNotShowing -> getString( + R.string.constraint_lock_screen_not_showing, + ) + is ConstraintData.Time -> getString( R.string.constraint_time_formatted, arrayOf( - timeFormatter.format(constraint.startTime), - timeFormatter.format(constraint.endTime), + timeFormatter.format(constraint.data.startTime), + timeFormatter.format(constraint.data.endTime), ), ) } - fun getIcon(constraint: Constraint): ComposeIconInfo = when (constraint) { - is Constraint.AppInForeground -> getAppIconInfo(constraint.packageName) + fun getIcon(constraint: Constraint): ComposeIconInfo = when (constraint.data) { + is ConstraintData.AppInForeground -> getAppIconInfo(constraint.data.packageName) ?: ComposeIconInfo.Vector(Icons.Rounded.Android) - is Constraint.AppNotInForeground -> getAppIconInfo(constraint.packageName) + is ConstraintData.AppNotInForeground -> getAppIconInfo(constraint.data.packageName) ?: ComposeIconInfo.Vector(Icons.Rounded.Android) - is Constraint.AppPlayingMedia -> getAppIconInfo(constraint.packageName) + is ConstraintData.AppPlayingMedia -> getAppIconInfo(constraint.data.packageName) ?: ComposeIconInfo.Vector(Icons.Rounded.Android) - is Constraint.AppNotPlayingMedia -> getAppIconInfo(constraint.packageName) + is ConstraintData.AppNotPlayingMedia -> getAppIconInfo(constraint.data.packageName) ?: ComposeIconInfo.Vector(Icons.Rounded.Android) else -> ConstraintUtils.getIcon(constraint.id) } - private fun getAppIconInfo(packageName: String): ComposeIconInfo? = getAppIcon(packageName).handle( - onSuccess = { ComposeIconInfo.Drawable(it) }, - onError = { null }, - ) + private fun getAppIconInfo(packageName: String): ComposeIconInfo? = + getAppIcon(packageName).handle( + onSuccess = { ComposeIconInfo.Drawable(it) }, + onError = { null }, + ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt index 8ea3d1f352..ffad00f16c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt @@ -34,24 +34,32 @@ object ConstraintUtils { ConstraintId.APP_NOT_IN_FOREGROUND, ConstraintId.APP_PLAYING_MEDIA, ConstraintId.APP_NOT_PLAYING_MEDIA, - -> ComposeIconInfo.Vector(Icons.Rounded.Android) + -> ComposeIconInfo.Vector(Icons.Rounded.Android) ConstraintId.MEDIA_PLAYING -> ComposeIconInfo.Vector(Icons.Outlined.PlayArrow) ConstraintId.MEDIA_NOT_PLAYING -> ComposeIconInfo.Vector(Icons.Outlined.StopCircle) - ConstraintId.BT_DEVICE_CONNECTED -> ComposeIconInfo.Vector(Icons.Outlined.BluetoothConnected) - ConstraintId.BT_DEVICE_DISCONNECTED -> ComposeIconInfo.Vector(Icons.Outlined.BluetoothDisabled) + ConstraintId.BT_DEVICE_CONNECTED -> ComposeIconInfo.Vector( + Icons.Outlined.BluetoothConnected, + ) + ConstraintId.BT_DEVICE_DISCONNECTED -> ComposeIconInfo.Vector( + Icons.Outlined.BluetoothDisabled, + ) ConstraintId.ORIENTATION_0, ConstraintId.ORIENTATION_180, - -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait) + -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait) ConstraintId.ORIENTATION_90, ConstraintId.ORIENTATION_270, - -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentLandscape) + -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentLandscape) - ConstraintId.ORIENTATION_LANDSCAPE -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentLandscape) - ConstraintId.ORIENTATION_PORTRAIT -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait) + ConstraintId.ORIENTATION_LANDSCAPE -> ComposeIconInfo.Vector( + Icons.Outlined.StayCurrentLandscape, + ) + ConstraintId.ORIENTATION_PORTRAIT -> ComposeIconInfo.Vector( + Icons.Outlined.StayCurrentPortrait, + ) ConstraintId.SCREEN_OFF -> ComposeIconInfo.Vector(Icons.Outlined.MobileOff) ConstraintId.SCREEN_ON -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait) @@ -60,13 +68,15 @@ object ConstraintUtils { ConstraintId.FLASHLIGHT_ON -> ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn) ConstraintId.WIFI_CONNECTED -> ComposeIconInfo.Vector(Icons.Outlined.Wifi) - ConstraintId.WIFI_DISCONNECTED -> ComposeIconInfo.Vector(Icons.Outlined.SignalWifiStatusbarNull) + ConstraintId.WIFI_DISCONNECTED -> ComposeIconInfo.Vector( + Icons.Outlined.SignalWifiStatusbarNull, + ) ConstraintId.WIFI_OFF -> ComposeIconInfo.Vector(Icons.Outlined.WifiOff) ConstraintId.WIFI_ON -> ComposeIconInfo.Vector(Icons.Outlined.Wifi) ConstraintId.IME_CHOSEN, ConstraintId.IME_NOT_CHOSEN, - -> ComposeIconInfo.Vector(Icons.Outlined.Keyboard) + -> ComposeIconInfo.Vector(Icons.Outlined.Keyboard) ConstraintId.DEVICE_IS_LOCKED -> ComposeIconInfo.Vector(Icons.Outlined.Lock) ConstraintId.DEVICE_IS_UNLOCKED -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen) @@ -77,7 +87,13 @@ object ConstraintUtils { ConstraintId.CHARGING -> ComposeIconInfo.Vector(Icons.Outlined.BatteryChargingFull) ConstraintId.DISCHARGING -> ComposeIconInfo.Vector(Icons.Outlined.Battery2Bar) - ConstraintId.LOCK_SCREEN_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.ScreenLockPortrait) + + ConstraintId.HINGE_CLOSED -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait) + ConstraintId.HINGE_OPEN -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentLandscape) + + ConstraintId.LOCK_SCREEN_SHOWING -> ComposeIconInfo.Vector( + Icons.Outlined.ScreenLockPortrait, + ) ConstraintId.LOCK_SCREEN_NOT_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen) ConstraintId.TIME -> ComposeIconInfo.Vector(Icons.Outlined.Timer) } @@ -90,7 +106,8 @@ object ConstraintUtils { ConstraintId.MEDIA_NOT_PLAYING -> R.string.constraint_choose_media_not_playing ConstraintId.MEDIA_PLAYING -> R.string.constraint_choose_media_playing ConstraintId.BT_DEVICE_CONNECTED -> R.string.constraint_choose_bluetooth_device_connected - ConstraintId.BT_DEVICE_DISCONNECTED -> R.string.constraint_choose_bluetooth_device_disconnected + ConstraintId.BT_DEVICE_DISCONNECTED -> + R.string.constraint_choose_bluetooth_device_disconnected ConstraintId.SCREEN_ON -> R.string.constraint_choose_screen_on_description ConstraintId.SCREEN_OFF -> R.string.constraint_choose_screen_off_description ConstraintId.ORIENTATION_PORTRAIT -> R.string.constraint_choose_orientation_portrait @@ -114,6 +131,8 @@ object ConstraintUtils { ConstraintId.PHONE_RINGING -> R.string.constraint_phone_ringing ConstraintId.CHARGING -> R.string.constraint_charging ConstraintId.DISCHARGING -> R.string.constraint_discharging + ConstraintId.HINGE_CLOSED -> R.string.constraint_hinge_closed + ConstraintId.HINGE_OPEN -> R.string.constraint_hinge_open ConstraintId.LOCK_SCREEN_SHOWING -> R.string.constraint_lock_screen_showing ConstraintId.LOCK_SCREEN_NOT_SHOWING -> R.string.constraint_lock_screen_not_showing ConstraintId.TIME -> R.string.constraint_time diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt index fa5e20ad28..dfe69d2467 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt @@ -82,7 +82,7 @@ private fun ConstraintsScreen( onAddClick: () -> Unit = {}, onRemoveClick: (String) -> Unit = {}, onFixErrorClick: (String) -> Unit = {}, - onClickShortcut: (Constraint) -> Unit = {}, + onClickShortcut: (ConstraintData) -> Unit = {}, onSelectMode: (ConstraintMode) -> Unit = {}, ) { var showDeleteDialog by rememberSaveable { mutableStateOf(false) } @@ -126,7 +126,9 @@ private fun ConstraintsScreen( modifier = Modifier .padding(32.dp) .fillMaxWidth(), - text = stringResource(R.string.constraints_recyclerview_placeholder), + text = stringResource( + R.string.constraints_recyclerview_placeholder, + ), textAlign = TextAlign.Center, ) @@ -247,10 +249,10 @@ private fun Loading(modifier: Modifier = Modifier) { private fun ConstraintList( modifier: Modifier = Modifier, constraintList: List, - shortcuts: Set>, + shortcuts: Set>, onRemoveClick: (String) -> Unit, onFixErrorClick: (String) -> Unit, - onClickShortcut: (Constraint) -> Unit, + onClickShortcut: (ConstraintData) -> Unit, ) { val lazyListState = rememberLazyListState() @@ -307,7 +309,7 @@ private fun EmptyPreview() { ShortcutModel( icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn), text = "Flashlight is on", - data = Constraint.FlashlightOn(lens = CameraLens.BACK), + data = ConstraintData.FlashlightOn(lens = CameraLens.BACK), ), ), ), @@ -336,7 +338,9 @@ private fun LoadedPreview() { ), ConstraintListItemModel( id = "2", - icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + icon = ComposeIconInfo.Drawable( + ctx.drawable(R.mipmap.ic_launcher_round), + ), constraintModeLink = null, text = "Key Mapper in foreground", error = null, @@ -347,7 +351,7 @@ private fun LoadedPreview() { ShortcutModel( icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn), text = "Flashlight is on", - data = Constraint.FlashlightOn(lens = CameraLens.BACK), + data = ConstraintData.FlashlightOn(lens = CameraLens.BACK), ), ), selectedMode = ConstraintMode.AND, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintUseCase.kt index 9b8dcfb652..93b1d72b9d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintUseCase.kt @@ -10,10 +10,10 @@ import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.network.NetworkAdapter +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import javax.inject.Inject class CreateConstraintUseCaseImpl @Inject constructor( private val networkAdapter: NetworkAdapter, @@ -25,29 +25,24 @@ class CreateConstraintUseCaseImpl @Inject constructor( override fun isSupported(constraint: ConstraintId): KMError? { when (constraint) { ConstraintId.FLASHLIGHT_ON, ConstraintId.FLASHLIGHT_OFF -> { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) - } - if (cameraAdapter.getFlashInfo(CameraLens.BACK) == null && cameraAdapter.getFlashInfo(CameraLens.FRONT) == null ) { return KMError.SystemFeatureNotSupported(PackageManager.FEATURE_CAMERA_FLASH) } } - - ConstraintId.DEVICE_IS_LOCKED, ConstraintId.DEVICE_IS_UNLOCKED -> - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { - return KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.LOLLIPOP_MR1) + ConstraintId.HINGE_CLOSED, ConstraintId.HINGE_OPEN -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return KMError.SdkVersionTooLow(Build.VERSION_CODES.R) } - + } else -> Unit } return null } - override fun getKnownWiFiSSIDs(): List? = networkAdapter.getKnownWifiSSIDs() + override fun getKnownWiFiSSIDs(): List = networkAdapter.getKnownWifiSSIDs() override fun getEnabledInputMethods(): List = inputMethodAdapter.inputMethods.value @@ -72,8 +67,9 @@ class CreateConstraintUseCaseImpl @Inject constructor( ) } - override fun getSavedWifiSSIDs(): Flow> = preferenceRepository.get(Keys.savedWifiSSIDs) - .map { it?.toList() ?: emptyList() } + override fun getSavedWifiSSIDs(): Flow> = + preferenceRepository.get(Keys.savedWifiSSIDs) + .map { it?.toList() ?: emptyList() } override fun getFlashlightLenses(): Set { return CameraLens.entries.filter { cameraAdapter.getFlashInfo(it) != null }.toSet() @@ -82,7 +78,7 @@ class CreateConstraintUseCaseImpl @Inject constructor( interface CreateConstraintUseCase { fun isSupported(constraint: ConstraintId): KMError? - fun getKnownWiFiSSIDs(): List? + fun getKnownWiFiSSIDs(): List fun getEnabledInputMethods(): List suspend fun saveWifiSSID(ssid: String) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt index 89888bba02..86d2c63ca7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt @@ -1,6 +1,5 @@ package io.github.sds100.keymapper.base.constraints -import android.os.Build import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -9,6 +8,7 @@ import io.github.sds100.keymapper.system.camera.CameraAdapter import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.devices.DevicesAdapter import io.github.sds100.keymapper.system.display.DisplayAdapter +import io.github.sds100.keymapper.system.foldable.FoldableAdapter import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.lock.LockScreenAdapter import io.github.sds100.keymapper.system.media.MediaAdapter @@ -16,7 +16,6 @@ import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.phone.PhoneAdapter import io.github.sds100.keymapper.system.power.PowerAdapter import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -32,13 +31,12 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( private val lockScreenAdapter: LockScreenAdapter, private val phoneAdapter: PhoneAdapter, private val powerAdapter: PowerAdapter, + private val foldableAdapter: FoldableAdapter, ) : DetectConstraintsUseCase { @AssistedFactory interface Factory { - fun create( - accessibilityService: IAccessibilityService, - ): DetectConstraintsUseCaseImpl + fun create(accessibilityService: IAccessibilityService): DetectConstraintsUseCaseImpl } override fun getSnapshot(): ConstraintSnapshot = LazyConstraintSnapshot( @@ -52,33 +50,36 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( lockScreenAdapter, phoneAdapter, powerAdapter, + foldableAdapter, ) override fun onDependencyChanged(dependency: ConstraintDependency): Flow { return when (dependency) { - ConstraintDependency.FOREGROUND_APP -> accessibilityService.activeWindowPackage.map { dependency } + ConstraintDependency.FOREGROUND_APP -> accessibilityService.activeWindowPackage.map { + dependency + } ConstraintDependency.APP_PLAYING_MEDIA, ConstraintDependency.MEDIA_PLAYING -> merge( mediaAdapter.getActiveMediaSessionPackagesFlow(), mediaAdapter.getActiveAudioVolumeStreamsFlow(), ).map { dependency } - ConstraintDependency.CONNECTED_BT_DEVICES -> devicesAdapter.connectedBluetoothDevices.map { dependency } + ConstraintDependency.CONNECTED_BT_DEVICES -> + devicesAdapter.connectedBluetoothDevices.map { dependency } ConstraintDependency.SCREEN_STATE -> displayAdapter.isScreenOn.map { dependency } - ConstraintDependency.DISPLAY_ORIENTATION -> displayAdapter.orientation.map { dependency } + ConstraintDependency.DISPLAY_ORIENTATION -> + displayAdapter.orientation.map { dependency } ConstraintDependency.FLASHLIGHT_STATE -> merge( cameraAdapter.isFlashlightOnFlow(CameraLens.FRONT), cameraAdapter.isFlashlightOnFlow(CameraLens.BACK), ).map { dependency } - ConstraintDependency.WIFI_SSID -> networkAdapter.connectedWifiSSIDFlow.map { dependency } + ConstraintDependency.WIFI_SSID -> + networkAdapter.connectedWifiSSIDFlow.map { dependency } ConstraintDependency.WIFI_STATE -> networkAdapter.isWifiEnabledFlow().map { dependency } ConstraintDependency.CHOSEN_IME -> inputMethodAdapter.chosenIme.map { dependency } - ConstraintDependency.DEVICE_LOCKED_STATE -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + ConstraintDependency.DEVICE_LOCKED_STATE -> lockScreenAdapter.isLockedFlow().map { dependency } - } else { - emptyFlow() - } ConstraintDependency.LOCK_SCREEN_SHOWING -> merge( @@ -88,6 +89,7 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( ConstraintDependency.PHONE_STATE -> phoneAdapter.callStateFlow.map { dependency } ConstraintDependency.CHARGING_STATE -> powerAdapter.isCharging.map { dependency } + ConstraintDependency.HINGE_STATE -> foldableAdapter.hingeState.map { dependency } } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/GetConstraintErrorUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/GetConstraintErrorUseCase.kt index c1a6cbcd03..92356db248 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/GetConstraintErrorUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/GetConstraintErrorUseCase.kt @@ -5,14 +5,14 @@ import io.github.sds100.keymapper.system.camera.CameraAdapter import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge -import javax.inject.Inject -import javax.inject.Singleton @Singleton class GetConstraintErrorUseCaseImpl @Inject constructor( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/TimeConstraintBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/TimeConstraintBottomSheet.kt index f99472093b..b8b1ac7370 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/TimeConstraintBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/TimeConstraintBottomSheet.kt @@ -45,8 +45,8 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.common.utils.TimeUtils -import kotlinx.coroutines.launch import java.time.format.FormatStyle +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -88,7 +88,7 @@ fun TimeConstraintBottomSheet(viewModel: ChooseConstraintViewModel) { private fun TimeConstraintBottomSheet( sheetState: SheetState, onDismissRequest: () -> Unit, - state: Constraint.Time, + state: ConstraintData.Time, onSelectStartTime: (Int, Int) -> Unit = { _, _ -> }, onSelectEndTime: (Int, Int) -> Unit = { _, _ -> }, onDoneClick: () -> Unit = {}, @@ -173,7 +173,9 @@ private fun TimeConstraintBottomSheet( ) { Icon( imageVector = Icons.Outlined.Edit, - contentDescription = stringResource(R.string.constraint_time_bottom_sheet_edit_start_time), + contentDescription = stringResource( + R.string.constraint_time_bottom_sheet_edit_start_time, + ), ) } } @@ -204,7 +206,9 @@ private fun TimeConstraintBottomSheet( ) { Icon( imageVector = Icons.Outlined.Edit, - contentDescription = stringResource(R.string.constraint_time_bottom_sheet_edit_end_time), + contentDescription = stringResource( + R.string.constraint_time_bottom_sheet_edit_end_time, + ), ) } } @@ -245,11 +249,7 @@ private fun TimeConstraintBottomSheet( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun TimePickerDialog( - state: TimePickerState, - onDismiss: () -> Unit, - onConfirm: () -> Unit, -) { +private fun TimePickerDialog(state: TimePickerState, onDismiss: () -> Unit, onConfirm: () -> Unit) { AlertDialog( onDismissRequest = onDismiss, dismissButton = { @@ -282,7 +282,7 @@ private fun Preview() { TimeConstraintBottomSheet( sheetState = sheetState, onDismissRequest = {}, - state = Constraint.Time( + state = ConstraintData.Time( startHour = 0, startMinute = 0, endHour = 23, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/DetectKeyMapModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapModel.kt similarity index 80% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/DetectKeyMapModel.kt rename to base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapModel.kt index 127f2021dd..321bce0664 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/DetectKeyMapModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapModel.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.base.keymaps.detection +package io.github.sds100.keymapper.base.detection import io.github.sds100.keymapper.base.constraints.ConstraintState import io.github.sds100.keymapper.base.keymaps.KeyMap diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/DetectKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt similarity index 74% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/DetectKeyMapsUseCase.kt rename to base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt index fb3fc72e1f..97de63d1bd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/DetectKeyMapsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.base.keymaps.detection +package io.github.sds100.keymapper.base.detection import android.accessibilityservice.AccessibilityService import android.os.SystemClock @@ -11,14 +11,14 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.constraints.ConstraintState import io.github.sds100.keymapper.base.groups.Group import io.github.sds100.keymapper.base.groups.GroupEntityMapper +import io.github.sds100.keymapper.base.input.InjectKeyEventModel +import io.github.sds100.keymapper.base.input.InputEventHub import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.keymaps.KeyMapEntityMapper import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityService -import io.github.sds100.keymapper.base.system.inputmethod.ImeInputEventInjector import io.github.sds100.keymapper.base.system.navigation.OpenMenuHelper import io.github.sds100.keymapper.base.trigger.FingerprintTriggerKey import io.github.sds100.keymapper.base.utils.ui.ResourceProvider -import io.github.sds100.keymapper.common.utils.InputEventType import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.dataOrNull import io.github.sds100.keymapper.data.Keys @@ -27,13 +27,7 @@ import io.github.sds100.keymapper.data.repositories.FloatingButtonRepository import io.github.sds100.keymapper.data.repositories.GroupRepository import io.github.sds100.keymapper.data.repositories.KeyMapRepository import io.github.sds100.keymapper.data.repositories.PreferenceRepository -import io.github.sds100.keymapper.system.display.DisplayAdapter -import io.github.sds100.keymapper.system.inputmethod.InputKeyModel -import io.github.sds100.keymapper.system.permissions.Permission -import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.popup.ToastAdapter -import io.github.sds100.keymapper.system.root.SuAdapter -import io.github.sds100.keymapper.system.shizuku.ShizukuInputEventInjector import io.github.sds100.keymapper.system.vibrator.VibratorAdapter import io.github.sds100.keymapper.system.volume.VolumeAdapter import kotlinx.coroutines.CoroutineScope @@ -42,28 +36,22 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import timber.log.Timber class DetectKeyMapsUseCaseImpl @AssistedInject constructor( - @Assisted - private val imeInputEventInjector: ImeInputEventInjector, @Assisted private val accessibilityService: IAccessibilityService, private val keyMapRepository: KeyMapRepository, private val floatingButtonRepository: FloatingButtonRepository, private val groupRepository: GroupRepository, private val preferenceRepository: PreferenceRepository, - private val suAdapter: SuAdapter, - private val displayAdapter: DisplayAdapter, private val volumeAdapter: VolumeAdapter, private val toastAdapter: ToastAdapter, - private val permissionAdapter: PermissionAdapter, private val resourceProvider: ResourceProvider, private val vibrator: VibratorAdapter, @Assisted private val coroutineScope: CoroutineScope, + private val inputEventHub: InputEventHub, ) : DetectKeyMapsUseCase { @AssistedFactory @@ -71,7 +59,6 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor( fun create( accessibilityService: IAccessibilityService, coroutineScope: CoroutineScope, - imeInputEventInjector: ImeInputEventInjector, ): DetectKeyMapsUseCaseImpl } @@ -139,7 +126,8 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor( override val requestFingerprintGestureDetection: Flow = allKeyMapList.map { models -> models.any { model -> - model.keyMap.isEnabled && model.keyMap.trigger.keys.any { it is FingerprintTriggerKey } + model.keyMap.isEnabled && + model.keyMap.trigger.keys.any { it is FingerprintTriggerKey } } } @@ -148,14 +136,6 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor( keyMapList.filter { it.keyMap.trigger.triggerFromOtherApps }.map { it.keyMap } }.flowOn(Dispatchers.Default) - override val detectScreenOffTriggers: Flow = - combine( - allKeyMapList, - suAdapter.isGranted, - ) { keyMapList, isRootPermissionGranted -> - keyMapList.any { it.keyMap.trigger.screenOffTrigger } && isRootPermissionGranted - }.flowOn(Dispatchers.Default) - override val defaultLongPressDelay: Flow = preferenceRepository.get(Keys.defaultLongPressDelay) .map { it ?: PreferenceDefaults.LONG_PRESS_DELAY } @@ -174,14 +154,9 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor( override val currentTime: Long get() = SystemClock.elapsedRealtime() - private val shizukuInputEventInjector = ShizukuInputEventInjector() - private val openMenuHelper = OpenMenuHelper( - suAdapter, accessibilityService, - shizukuInputEventInjector, - permissionAdapter, - coroutineScope, + inputEventHub, ) override val forceVibrate: Flow = @@ -200,60 +175,77 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor( vibrator.vibrate(duration) } - override fun imitateButtonPress( + override fun imitateKeyEvent( keyCode: Int, metaState: Int, deviceId: Int, - inputEventType: InputEventType, + action: Int, scanCode: Int, source: Int, ) { - val model = InputKeyModel( - keyCode, - inputEventType, - metaState, - deviceId, - scanCode, + val model = InjectKeyEventModel( + keyCode = keyCode, + action = action, + metaState = metaState, + deviceId = deviceId, + scanCode = scanCode, source = source, ) - if (permissionAdapter.isGranted(Permission.SHIZUKU)) { - Timber.d("Imitate button press ${KeyEvent.keyCodeToString(keyCode)} with Shizuku, key code: $keyCode, device id: $deviceId, meta state: $metaState, scan code: $scanCode") - - coroutineScope.launch { - shizukuInputEventInjector.inputKeyEvent(model) - } + if (inputEventHub.isSystemBridgeConnected()) { + Timber.d( + "Imitate button press ${KeyEvent.keyCodeToString( + keyCode, + )} with system bridge, key code: $keyCode, device id: $deviceId, meta state: $metaState, scan code: $scanCode", + ) + inputEventHub.injectKeyEventAsync(model) } else { - Timber.d("Imitate button press ${KeyEvent.keyCodeToString(keyCode)}, key code: $keyCode, device id: $deviceId, meta state: $metaState, scan code: $scanCode") + Timber.d( + "Imitate button press ${KeyEvent.keyCodeToString( + keyCode, + )}, key code: $keyCode, device id: $deviceId, meta state: $metaState, scan code: $scanCode", + ) when (keyCode) { KeyEvent.KEYCODE_VOLUME_UP -> volumeAdapter.raiseVolume(showVolumeUi = true) KeyEvent.KEYCODE_VOLUME_DOWN -> volumeAdapter.lowerVolume(showVolumeUi = true) - KeyEvent.KEYCODE_BACK -> accessibilityService.doGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) - KeyEvent.KEYCODE_HOME -> accessibilityService.doGlobalAction(AccessibilityService.GLOBAL_ACTION_HOME) + KeyEvent.KEYCODE_BACK -> accessibilityService.doGlobalAction( + AccessibilityService.GLOBAL_ACTION_BACK, + ) + KeyEvent.KEYCODE_HOME -> accessibilityService.doGlobalAction( + AccessibilityService.GLOBAL_ACTION_HOME, + ) KeyEvent.KEYCODE_APP_SWITCH -> accessibilityService.doGlobalAction( AccessibilityService.GLOBAL_ACTION_POWER_DIALOG, ) KeyEvent.KEYCODE_MENU -> openMenuHelper.openMenu() - else -> runBlocking { - imeInputEventInjector.inputKeyEvent(model) - } + else -> inputEventHub.injectKeyEventAsync(model) } } } - override val isScreenOn: Flow = displayAdapter.isScreenOn + override fun imitateEvdevEvent(devicePath: String, type: Int, code: Int, value: Int) { + if (inputEventHub.isSystemBridgeConnected()) { + Timber.d( + "Imitate evdev event, device path: $devicePath, type: $type, code: $code, value: $value", + ) + inputEventHub.injectEvdevEvent(devicePath, type, code, value) + } else { + Timber.w( + "Cannot imitate evdev event without system bridge connected. Device path: $devicePath, type: $type, code: $code, value: $value", + ) + } + } } interface DetectKeyMapsUseCase { val allKeyMapList: Flow> val requestFingerprintGestureDetection: Flow val keyMapsToTriggerFromOtherApps: Flow> - val detectScreenOffTriggers: Flow val defaultLongPressDelay: Flow val defaultDoublePressDelay: Flow @@ -267,14 +259,14 @@ interface DetectKeyMapsUseCase { val currentTime: Long - fun imitateButtonPress( + fun imitateKeyEvent( keyCode: Int, metaState: Int = 0, deviceId: Int = 0, - inputEventType: InputEventType = InputEventType.DOWN_UP, + action: Int, scanCode: Int = 0, source: Int = InputDevice.SOURCE_UNKNOWN, ) - val isScreenOn: Flow + fun imitateEvdevEvent(devicePath: String, type: Int, code: Int, value: Int) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/DpadMotionEventTracker.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/DpadMotionEventTracker.kt similarity index 86% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/DpadMotionEventTracker.kt rename to base/src/main/java/io/github/sds100/keymapper/base/detection/DpadMotionEventTracker.kt index 0da1229d11..0f51a065a9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/DpadMotionEventTracker.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/DpadMotionEventTracker.kt @@ -1,11 +1,11 @@ -package io.github.sds100.keymapper.base.keymaps.detection +package io.github.sds100.keymapper.base.detection import android.view.InputDevice import android.view.KeyEvent -import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import io.github.sds100.keymapper.system.inputevents.InputEventUtils -import io.github.sds100.keymapper.system.inputevents.MyKeyEvent -import io.github.sds100.keymapper.system.inputevents.MyMotionEvent +import io.github.sds100.keymapper.common.utils.InputDeviceInfo +import io.github.sds100.keymapper.system.inputevents.KMGamePadEvent +import io.github.sds100.keymapper.system.inputevents.KMKeyEvent +import io.github.sds100.keymapper.system.inputevents.KeyEventUtils /** * See https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input#dpad @@ -32,10 +32,10 @@ class DpadMotionEventTracker { * * @return whether to consume the key event. */ - fun onKeyEvent(event: MyKeyEvent): Boolean { + fun onKeyEvent(event: KMKeyEvent): Boolean { val device = event.device ?: return false - if (!InputEventUtils.isDpadKeyCode(event.keyCode)) { + if (!KeyEventUtils.isDpadKeyCode(event.keyCode)) { return false } @@ -63,7 +63,7 @@ class DpadMotionEventTracker { * * @return An array of key events. Empty if no DPAD buttons changed. */ - fun convertMotionEvent(event: MyMotionEvent): List { + fun convertMotionEvent(event: KMGamePadEvent): List { val oldState = dpadState[event.device.getDescriptor()] ?: 0 val newState = eventToDpadState(event) val diff = oldState xor newState @@ -101,7 +101,7 @@ class DpadMotionEventTracker { } return keyCodes.map { - MyKeyEvent( + KMKeyEvent( it, action, metaState = event.metaState, @@ -109,6 +109,7 @@ class DpadMotionEventTracker { device = event.device, repeatCount = 0, source = InputDevice.SOURCE_DPAD, + eventTime = event.eventTime, ) } } @@ -121,7 +122,7 @@ class DpadMotionEventTracker { return this?.descriptor ?: "" } - private fun eventToDpadState(event: MyMotionEvent): Int { + private fun eventToDpadState(event: KMGamePadEvent): Int { var state = 0 if (event.axisHatX == -1.0f) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/KeyMapController.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt similarity index 81% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/KeyMapController.kt rename to base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt index b6325a601a..7429079ace 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/KeyMapController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.base.keymaps.detection +package io.github.sds100.keymapper.base.detection import android.view.KeyEvent import androidx.collection.SparseArrayCompat @@ -15,31 +15,34 @@ import io.github.sds100.keymapper.base.keymaps.ClickType import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType import io.github.sds100.keymapper.base.trigger.AssistantTriggerKey import io.github.sds100.keymapper.base.trigger.AssistantTriggerType +import io.github.sds100.keymapper.base.trigger.EvdevTriggerKey import io.github.sds100.keymapper.base.trigger.FingerprintTriggerKey import io.github.sds100.keymapper.base.trigger.FloatingButtonKey import io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey -import io.github.sds100.keymapper.base.trigger.KeyEventDetectionSource +import io.github.sds100.keymapper.base.trigger.KeyEventTriggerDevice +import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey import io.github.sds100.keymapper.base.trigger.Trigger import io.github.sds100.keymapper.base.trigger.TriggerKey -import io.github.sds100.keymapper.base.trigger.TriggerKeyDevice import io.github.sds100.keymapper.base.trigger.TriggerMode -import io.github.sds100.keymapper.common.utils.InputEventType +import io.github.sds100.keymapper.base.trigger.detectWithScancode +import io.github.sds100.keymapper.common.models.EvdevDeviceInfo import io.github.sds100.keymapper.common.utils.minusFlag import io.github.sds100.keymapper.common.utils.withFlag import io.github.sds100.keymapper.data.PreferenceDefaults -import io.github.sds100.keymapper.system.inputevents.InputEventUtils -import io.github.sds100.keymapper.system.inputevents.MyKeyEvent -import io.github.sds100.keymapper.system.inputevents.MyMotionEvent +import io.github.sds100.keymapper.system.inputevents.KMEvdevEvent +import io.github.sds100.keymapper.system.inputevents.KMGamePadEvent +import io.github.sds100.keymapper.system.inputevents.KMInputEvent +import io.github.sds100.keymapper.system.inputevents.KMKeyEvent +import io.github.sds100.keymapper.system.inputevents.KeyEventUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -class KeyMapController( +class KeyMapAlgorithm( private val coroutineScope: CoroutineScope, private val useCase: DetectKeyMapsUseCase, private val performActionsUseCase: PerformActionsUseCase, @@ -65,7 +68,192 @@ class KeyMapController( trigger.mode is TriggerMode.Parallel } - private fun loadKeyMaps(value: List) { + private var detectKeyMaps: Boolean = false + private var detectInternalEvents: Boolean = false + private var detectExternalEvents: Boolean = false + private var detectSequenceLongPresses: Boolean = false + private var detectSequenceDoublePresses: Boolean = false + + /** + * All sequence events that have the long press click type. + */ + private var longPressSequenceTriggerKeys: Array = arrayOf() + + /** + * All double press keys and the index of their corresponding trigger. first is the event and second is + * the trigger index. + */ + private var doublePressTriggerKeys: Array = arrayOf() + + /** + * order matches with [doublePressTriggerKeys] + */ + private var doublePressEventStates: IntArray = intArrayOf() + + /** + * The user has an amount of time to double press a key before it is registered as a double press. + * The order matches with [doublePressTriggerKeys]. This array stores the time when the corresponding trigger will + * timeout. If the key isn't waiting to timeout, the value is -1. + */ + private var doublePressTimeoutTimes = longArrayOf() + + private var actionMap: SparseArrayCompat = SparseArrayCompat() + var triggers: Array = emptyArray() + private set + + /** + * The events to detect for each sequence trigger. + */ + private var sequenceTriggers: IntArray = intArrayOf() + + /** + * Sequence triggers timeout after the first key has been pressed. + * This map stores the time when the corresponding trigger will timeout. If the trigger in + * isn't waiting to timeout, the value is -1. + * The index of a trigger matches with the index in [triggers] + */ + private var sequenceTriggersTimeoutTimes: MutableMap = mutableMapOf() + + /** + * The indexes of triggers that overlap after the first element with each trigger in [sequenceTriggers] + */ + private var sequenceTriggersOverlappingSequenceTriggers: Array = arrayOf() + + private var sequenceTriggersOverlappingParallelTriggers: Array = arrayOf() + + /** + * An array of the index of the last matched event in each trigger. + */ + private var lastMatchedEventIndices: IntArray = intArrayOf() + + /** + * An array of the constraints for every trigger + */ + private var triggerConstraints: Array> = arrayOf() + + /** + * The events to detect for each parallel trigger. + */ + private var parallelTriggers: IntArray = intArrayOf() + + /** + * The actions to perform when each trigger is detected. The order matches with + * [triggers]. + */ + private var triggerActions: Array = arrayOf() + + /** + * Stores whether each event in each parallel trigger need to be released after being held down. + * The index of a trigger matches with the index in [triggers] + */ + private var parallelTriggerEventsAwaitingRelease: Array = emptyArray() + + /** + * Whether each parallel trigger is awaiting to be released after performing an action. + * This is only set to true if the trigger has been successfully triggered and *all* the keys + * have not been released. + * The index of a trigger matches with the index in [triggers] + */ + private var parallelTriggersAwaitingReleaseAfterBeingTriggered: BooleanArray = booleanArrayOf() + + private var parallelTriggerModifierKeyIndices: Array> = arrayOf() + + /** + * The indexes of triggers that overlap after the first element with each trigger in [parallelTriggers] + */ + private var parallelTriggersOverlappingParallelTriggers = arrayOf() + + private var modifierKeyEventActions: Boolean = false + private var notModifierKeyEventActions: Boolean = false + private var keyCodesToImitateUpAction: MutableSet = mutableSetOf() + private var metaStateFromActions: Int = 0 + private var metaStateFromKeyEvent: Int = 0 + + private val eventDownTimeMap: MutableMap = mutableMapOf() + + /** + * This solves issue #1386. This stores the jobs that will wait until the sequence trigger + * times out and check whether the overlapping sequence trigger was indeed triggered. + */ + private val performActionsAfterSequenceTriggerTimeout: MutableMap = mutableMapOf() + + /** + * The indexes of parallel triggers that didn't have their actions performed because there is a matching trigger but + * for a long-press. These actions should only be performed if the long-press fails, otherwise when the user + * holds down the trigger keys for the long-press trigger, actions from both triggers will be performed. + */ + private val performActionsOnFailedLongPress: MutableSet = mutableSetOf() + + /** + * The indexes of parallel triggers that didn't have their actions performed because there is a matching trigger but + * for a double-press. These actions should only be performed if the double-press fails, otherwise each time the user + * presses the keys for the double press, actions from both triggers will be performed. + */ + private val performActionsOnFailedDoublePress: MutableSet = mutableSetOf() + + /** + * Maps jobs to perform an action after a long press to their corresponding parallel trigger index + */ + private val parallelTriggerLongPressJobs: SparseArrayCompat = SparseArrayCompat() + + /** + * Keys that are detected through an input method will potentially send multiple DOWN key events + * with incremented repeatCounts, such as DPAD buttons. These repeated DOWN key events must + * all be consumed and ignored because the UP key event is only sent once at the end. The action + * must not be executed for each repeat. The user may potentially have many hundreds + * of trigger keys so to reduce latency this set caches which keys + * will be affected by this behavior. + * + * NOTE: This only contains the trigger keys that are flagged to consume the key event. + */ + private var triggerKeysThatSendRepeatedKeyEvents: Set = emptySet() + + private var parallelTriggerActionPerformers: Map = + emptyMap() + private var sequenceTriggerActionPerformers: Map = + emptyMap() + + private val currentTime: Long + get() = useCase.currentTime + + private val defaultVibrateDuration: StateFlow = + useCase.defaultVibrateDuration.stateIn( + coroutineScope, + SharingStarted.Eagerly, + PreferenceDefaults.VIBRATION_DURATION.toLong(), + ) + + private val defaultSequenceTriggerTimeout: StateFlow = + useCase.defaultSequenceTriggerTimeout.stateIn( + coroutineScope, + SharingStarted.Eagerly, + PreferenceDefaults.SEQUENCE_TRIGGER_TIMEOUT.toLong(), + ) + + private val defaultLongPressDelay: StateFlow = + useCase.defaultLongPressDelay.stateIn( + coroutineScope, + SharingStarted.Eagerly, + PreferenceDefaults.LONG_PRESS_DELAY.toLong(), + ) + + private val defaultDoublePressDelay: StateFlow = + useCase.defaultDoublePressDelay.stateIn( + coroutineScope, + SharingStarted.Eagerly, + PreferenceDefaults.DOUBLE_PRESS_DELAY.toLong(), + ) + + private val forceVibrate: StateFlow = + useCase.forceVibrate.stateIn( + coroutineScope, + SharingStarted.Eagerly, + PreferenceDefaults.FORCE_VIBRATE, + ) + + private val dpadMotionEventTracker: DpadMotionEventTracker = DpadMotionEventTracker() + + fun loadKeyMaps(value: List) { actionMap.clear() // If there are no keymaps with actions then keys don't need to be detected. @@ -102,7 +290,7 @@ class KeyMapController( val parallelTriggerActionPerformers = mutableMapOf() val parallelTriggerModifierKeyIndices = mutableListOf>() - val triggerKeysThatSendRepeatedKeyEvents = mutableSetOf() + val triggerKeysThatSendRepeatedKeyEvents = mutableSetOf() // Only process key maps that can be triggered val validKeyMaps = value.filter { @@ -112,8 +300,8 @@ class KeyMapController( for ((triggerIndex, model) in validKeyMaps.withIndex()) { val keyMap = model.keyMap // TRIGGER STUFF - keyMap.trigger.keys.forEachIndexed { keyIndex, key -> - if (key is KeyCodeTriggerKey && key.detectionSource == KeyEventDetectionSource.INPUT_METHOD && key.consumeEvent) { + for ((keyIndex, key) in keyMap.trigger.keys.withIndex()) { + if (key is KeyEventTriggerKey && key.requiresIme && key.consumeEvent) { triggerKeysThatSendRepeatedKeyEvents.add(key) } @@ -133,21 +321,26 @@ class KeyMapController( } when (key) { - is KeyCodeTriggerKey -> when (key.device) { - TriggerKeyDevice.Internal -> { + is KeyEventTriggerKey -> when (key.device) { + KeyEventTriggerDevice.Internal -> { detectInternalEvents = true } - TriggerKeyDevice.Any -> { + KeyEventTriggerDevice.Any -> { detectInternalEvents = true detectExternalEvents = true } - is TriggerKeyDevice.External -> { + is KeyEventTriggerDevice.External -> { detectExternalEvents = true } } + is EvdevTriggerKey -> { + detectInternalEvents = true + detectExternalEvents = true + } + else -> {} } } @@ -218,7 +411,9 @@ class KeyMapController( if (otherIndex == 0) continue@otherTriggerLoop // make sure the overlap retains the order of the trigger - if (lastMatchedIndex != null && lastMatchedIndex != otherIndex - 1) { + if (lastMatchedIndex != null && + lastMatchedIndex != otherIndex - 1 + ) { continue@otherTriggerLoop } @@ -255,7 +450,9 @@ class KeyMapController( for ((otherKeyIndex, otherKey) in otherTrigger.keys.withIndex()) { if (key.matchesWithOtherKey(otherKey)) { // make sure the overlap retains the order of the trigger - if (lastMatchedIndex != null && lastMatchedIndex != otherKeyIndex - 1) { + if (lastMatchedIndex != null && + lastMatchedIndex != otherKeyIndex - 1 + ) { continue@otherTriggerLoop } @@ -269,7 +466,9 @@ class KeyMapController( } // if there were no matching keys in the other trigger then skip this trigger - if (lastMatchedIndex == null && otherKeyIndex == otherTrigger.keys.lastIndex) { + if (lastMatchedIndex == null && + otherKeyIndex == otherTrigger.keys.lastIndex + ) { continue@otherTriggerLoop } } @@ -302,7 +501,9 @@ class KeyMapController( for ((otherKeyIndex, otherKey) in otherTrigger.keys.withIndex()) { if (otherKey.matchesWithOtherKey(key)) { // make sure the overlap retains the order of the trigger - if (lastMatchedIndex != null && lastMatchedIndex != otherKeyIndex - 1) { + if (lastMatchedIndex != null && + lastMatchedIndex != otherKeyIndex - 1 + ) { continue@otherTriggerLoop } @@ -316,7 +517,9 @@ class KeyMapController( } // if there were no matching keys in the other trigger then skip this trigger - if (lastMatchedIndex == null && otherKeyIndex == otherTrigger.keys.lastIndex) { + if (lastMatchedIndex == null && + otherKeyIndex == otherTrigger.keys.lastIndex + ) { continue@otherTriggerLoop } } @@ -327,15 +530,13 @@ class KeyMapController( for (triggerIndex in parallelTriggers) { val trigger = triggers[triggerIndex] - trigger.keys.forEachIndexed { keyIndex, key -> + for ((keyIndex, key) in trigger.keys.withIndex()) { if (key is KeyCodeTriggerKey && isModifierKey(key.keyCode)) { parallelTriggerModifierKeyIndices.add(triggerIndex to keyIndex) } } } - reset() - this.triggers = triggers.toTypedArray() this.triggerActions = triggerActions.toTypedArray() this.triggerConstraints = triggerConstraints.toTypedArray() @@ -376,200 +577,7 @@ class KeyMapController( } } - private var detectKeyMaps: Boolean = false - private var detectInternalEvents: Boolean = false - private var detectExternalEvents: Boolean = false - private var detectSequenceLongPresses: Boolean = false - private var detectSequenceDoublePresses: Boolean = false - - /** - * All sequence events that have the long press click type. - */ - private var longPressSequenceTriggerKeys: Array = arrayOf() - - /** - * All double press keys and the index of their corresponding trigger. first is the event and second is - * the trigger index. - */ - private var doublePressTriggerKeys: Array = arrayOf() - - /** - * order matches with [doublePressTriggerKeys] - */ - private var doublePressEventStates: IntArray = intArrayOf() - - /** - * The user has an amount of time to double press a key before it is registered as a double press. - * The order matches with [doublePressTriggerKeys]. This array stores the time when the corresponding trigger will - * timeout. If the key isn't waiting to timeout, the value is -1. - */ - private var doublePressTimeoutTimes = longArrayOf() - - private var actionMap: SparseArrayCompat = SparseArrayCompat() - private var triggers: Array = emptyArray() - - /** - * The events to detect for each sequence trigger. - */ - private var sequenceTriggers: IntArray = intArrayOf() - - /** - * Sequence triggers timeout after the first key has been pressed. - * This map stores the time when the corresponding trigger will timeout. If the trigger in - * isn't waiting to timeout, the value is -1. - * The index of a trigger matches with the index in [triggers] - */ - private var sequenceTriggersTimeoutTimes: MutableMap = mutableMapOf() - - /** - * The indexes of triggers that overlap after the first element with each trigger in [sequenceTriggers] - */ - private var sequenceTriggersOverlappingSequenceTriggers: Array = arrayOf() - - private var sequenceTriggersOverlappingParallelTriggers: Array = arrayOf() - - /** - * An array of the index of the last matched event in each trigger. - */ - private var lastMatchedEventIndices: IntArray = intArrayOf() - - /** - * An array of the constraints for every trigger - */ - private var triggerConstraints: Array> = arrayOf() - - /** - * The events to detect for each parallel trigger. - */ - private var parallelTriggers: IntArray = intArrayOf() - - /** - * The actions to perform when each trigger is detected. The order matches with - * [triggers]. - */ - private var triggerActions: Array = arrayOf() - - /** - * Stores whether each event in each parallel trigger need to be released after being held down. - * The index of a trigger matches with the index in [triggers] - */ - private var parallelTriggerEventsAwaitingRelease: Array = emptyArray() - - /** - * Whether each parallel trigger is awaiting to be released after performing an action. - * This is only set to true if the trigger has been successfully triggered and *all* the keys - * have not been released. - * The index of a trigger matches with the index in [triggers] - */ - private var parallelTriggersAwaitingReleaseAfterBeingTriggered: BooleanArray = booleanArrayOf() - - private var parallelTriggerModifierKeyIndices: Array> = arrayOf() - - /** - * The indexes of triggers that overlap after the first element with each trigger in [parallelTriggers] - */ - private var parallelTriggersOverlappingParallelTriggers = arrayOf() - - private var modifierKeyEventActions: Boolean = false - private var notModifierKeyEventActions: Boolean = false - private var keyCodesToImitateUpAction: MutableSet = mutableSetOf() - private var metaStateFromActions: Int = 0 - private var metaStateFromKeyEvent: Int = 0 - - private val eventDownTimeMap: MutableMap = mutableMapOf() - - /** - * This solves issue #1386. This stores the jobs that will wait until the sequence trigger - * times out and check whether the overlapping sequence trigger was indeed triggered. - */ - private val performActionsAfterSequenceTriggerTimeout: MutableMap = mutableMapOf() - - /** - * The indexes of parallel triggers that didn't have their actions performed because there is a matching trigger but - * for a long-press. These actions should only be performed if the long-press fails, otherwise when the user - * holds down the trigger keys for the long-press trigger, actions from both triggers will be performed. - */ - private val performActionsOnFailedLongPress: MutableSet = mutableSetOf() - - /** - * The indexes of parallel triggers that didn't have their actions performed because there is a matching trigger but - * for a double-press. These actions should only be performed if the double-press fails, otherwise each time the user - * presses the keys for the double press, actions from both triggers will be performed. - */ - private val performActionsOnFailedDoublePress: MutableSet = mutableSetOf() - - /** - * Maps jobs to perform an action after a long press to their corresponding parallel trigger index - */ - private val parallelTriggerLongPressJobs: SparseArrayCompat = SparseArrayCompat() - - /** - * Keys that are detected through an input method will potentially send multiple DOWN key events - * with incremented repeatCounts, such as DPAD buttons. These repeated DOWN key events must - * all be consumed and ignored because the UP key event is only sent once at the end. The action - * must not be executed for each repeat. The user may potentially have many hundreds - * of trigger keys so to reduce latency this set caches which keys - * will be affected by this behavior. - * - * NOTE: This only contains the trigger keys that are flagged to consume the key event. - */ - private var triggerKeysThatSendRepeatedKeyEvents: Set = emptySet() - - private var parallelTriggerActionPerformers: Map = - emptyMap() - private var sequenceTriggerActionPerformers: Map = - emptyMap() - - private val currentTime: Long - get() = useCase.currentTime - - private val defaultVibrateDuration: StateFlow = - useCase.defaultVibrateDuration.stateIn( - coroutineScope, - SharingStarted.Eagerly, - PreferenceDefaults.VIBRATION_DURATION.toLong(), - ) - - private val defaultSequenceTriggerTimeout: StateFlow = - useCase.defaultSequenceTriggerTimeout.stateIn( - coroutineScope, - SharingStarted.Eagerly, - PreferenceDefaults.SEQUENCE_TRIGGER_TIMEOUT.toLong(), - ) - - private val defaultLongPressDelay: StateFlow = - useCase.defaultLongPressDelay.stateIn( - coroutineScope, - SharingStarted.Eagerly, - PreferenceDefaults.LONG_PRESS_DELAY.toLong(), - ) - - private val defaultDoublePressDelay: StateFlow = - useCase.defaultDoublePressDelay.stateIn( - coroutineScope, - SharingStarted.Eagerly, - PreferenceDefaults.DOUBLE_PRESS_DELAY.toLong(), - ) - - private val forceVibrate: StateFlow = - useCase.forceVibrate.stateIn( - coroutineScope, - SharingStarted.Eagerly, - PreferenceDefaults.FORCE_VIBRATE, - ) - - private val dpadMotionEventTracker: DpadMotionEventTracker = DpadMotionEventTracker() - - init { - coroutineScope.launch { - useCase.allKeyMapList.collectLatest { keyMapList -> - reset() - loadKeyMaps(keyMapList) - } - } - } - - fun onMotionEvent(event: MyMotionEvent): Boolean { + fun onMotionEvent(event: KMGamePadEvent): Boolean { if (!detectKeyMaps) return false // See https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input#dpad @@ -594,26 +602,30 @@ class KeyMapController( /** * @return whether to consume the [KeyEvent]. */ - fun onKeyEvent(keyEvent: MyKeyEvent): Boolean { + fun onInputEvent(event: KMInputEvent): Boolean { if (!detectKeyMaps) return false - if (dpadMotionEventTracker.onKeyEvent(keyEvent)) { - return true - } + if (event is KMKeyEvent) { + if (dpadMotionEventTracker.onKeyEvent(event)) { + return true + } - val device = keyEvent.device + val device = event.device - if (device != null) { - if ((device.isExternal && !detectExternalEvents) || (!device.isExternal && !detectInternalEvents)) { + if ((device.isExternal && !detectExternalEvents) || + (!device.isExternal && !detectInternalEvents) + ) { return false } } - return onKeyEventPostFilter(keyEvent) + return onKeyEventPostFilter(event) } - private fun onKeyEventPostFilter(keyEvent: MyKeyEvent): Boolean { - metaStateFromKeyEvent = keyEvent.metaState + private fun onKeyEventPostFilter(inputEvent: KMInputEvent): Boolean { + if (inputEvent is KMKeyEvent) { + metaStateFromKeyEvent = inputEvent.metaState + } // remove the metastate from any modifier keys that remapped and are pressed down for ((triggerIndex, eventIndex) in parallelTriggerModifierKeyIndices) { @@ -625,49 +637,67 @@ class KeyMapController( if (parallelTriggerEventsAwaitingRelease[triggerIndex][eventIndex]) { metaStateFromKeyEvent = - metaStateFromKeyEvent.minusFlag(InputEventUtils.modifierKeycodeToMetaState(key.keyCode)) + metaStateFromKeyEvent.minusFlag( + KeyEventUtils.modifierKeycodeToMetaState(key.keyCode), + ) } } - val device = keyEvent.device - - val event = if (device != null && device.isExternal) { - KeyCodeEvent( - keyCode = keyEvent.keyCode, - clickType = null, - descriptor = device.descriptor, - deviceId = device.id, - scanCode = keyEvent.scanCode, - repeatCount = keyEvent.repeatCount, - source = keyEvent.source, - ) - } else { - KeyCodeEvent( - keyCode = keyEvent.keyCode, - clickType = null, - descriptor = null, - deviceId = device?.id ?: 0, - scanCode = keyEvent.scanCode, - repeatCount = keyEvent.repeatCount, - source = keyEvent.source, - ) - } + when (inputEvent) { + is KMEvdevEvent -> { + val event = EvdevEventAlgo( + keyCode = inputEvent.androidCode, + clickType = null, + devicePath = inputEvent.device.path, + device = EvdevDeviceInfo( + name = inputEvent.device.name, + bus = inputEvent.device.bus, + vendor = inputEvent.device.vendor, + product = inputEvent.device.product, + ), + scanCode = inputEvent.code, + ) - when (keyEvent.action) { - KeyEvent.ACTION_DOWN -> return onKeyDown(event) - KeyEvent.ACTION_UP -> return onKeyUp(event) + if (inputEvent.isDownEvent) { + return onKeyDown(event) + } else if (inputEvent.isUpEvent) { + return onKeyUp(event) + } + } + + is KMKeyEvent -> { + val device = inputEvent.device + + val event = KeyEventAlgo( + keyCode = inputEvent.keyCode, + clickType = null, + descriptor = device.descriptor, + deviceId = device.id, + scanCode = inputEvent.scanCode, + repeatCount = inputEvent.repeatCount, + source = inputEvent.source, + isExternal = device.isExternal, + ) + + when (inputEvent.action) { + KeyEvent.ACTION_DOWN -> return onKeyDown(event) + KeyEvent.ACTION_UP -> return onKeyUp(event) + } + } + + is KMGamePadEvent -> {} } return false } /** - * @return whether to consume the [KeyEvent]. + * @return whether to consume the event. */ - private fun onKeyDown(event: Event): Boolean { + private fun onKeyDown(event: AlgoEvent): Boolean { // Must come before saving the event down time because // there is no corresponding up key event for key events with a repeat count > 0 - if (event is KeyCodeEvent && event.repeatCount > 0) { + if (event is KeyEventAlgo && event.repeatCount > 0) { val matchingTriggerKey = triggerKeysThatSendRepeatedKeyEvents.any { it.matchesEvent(event.withShortPress) || it.matchesEvent(event.withLongPress) || @@ -682,7 +712,7 @@ class KeyMapController( eventDownTimeMap[event] = currentTime var consumeEvent = false - val isModifierKeyCode = event is KeyCodeEvent && isModifierKey(event.keyCode) + val isModifierKeyCode = event is KeyEventAlgo && isModifierKey(event.keyCode) var mappedToParallelTriggerAction = false val constraintSnapshot: ConstraintSnapshot by lazy { detectConstraints.getSnapshot() } @@ -734,7 +764,7 @@ class KeyMapController( consumeEvent = true } - key is KeyCodeTriggerKey && event is KeyCodeEvent -> + key is KeyCodeTriggerKey && event is KeyEventAlgo -> if (key.keyCode == event.keyCode && key.consumeEvent) { consumeEvent = true } @@ -759,7 +789,7 @@ class KeyMapController( val doublePressEvent = triggers[eventLocation.triggerIndex].keys[eventLocation.keyIndex] - triggers[triggerIndex].keys.forEachIndexed { eventIndex, event -> + for ((eventIndex, event) in triggers[triggerIndex].keys.withIndex()) { if (event == doublePressEvent && triggers[triggerIndex].keys[eventIndex].consumeEvent ) { @@ -820,14 +850,24 @@ class KeyMapController( val trigger = triggers[triggerIndex] val lastMatchedIndex = lastMatchedEventIndices[triggerIndex] - for (overlappingTriggerIndex in sequenceTriggersOverlappingParallelTriggers[triggerIndex]) { - if (lastMatchedEventIndices[overlappingTriggerIndex] == triggers[overlappingTriggerIndex].keys.lastIndex) { + for ( + overlappingTriggerIndex in + sequenceTriggersOverlappingParallelTriggers[triggerIndex] + ) { + if (lastMatchedEventIndices[overlappingTriggerIndex] == + triggers[overlappingTriggerIndex].keys.lastIndex + ) { continue@triggerLoop } } - for (overlappingTriggerIndex in parallelTriggersOverlappingParallelTriggers[triggerIndex]) { - if (lastMatchedEventIndices[overlappingTriggerIndex] == triggers[overlappingTriggerIndex].keys.lastIndex) { + for ( + overlappingTriggerIndex in + parallelTriggersOverlappingParallelTriggers[triggerIndex] + ) { + if (lastMatchedEventIndices[overlappingTriggerIndex] == + triggers[overlappingTriggerIndex].keys.lastIndex + ) { continue@triggerLoop } } @@ -879,7 +919,7 @@ class KeyMapController( if (isModifierKey(actionKeyCode)) { val actionMetaState = - InputEventUtils.modifierKeycodeToMetaState(actionKeyCode) + KeyEventUtils.modifierKeycodeToMetaState(actionKeyCode) metaStateFromActions = metaStateFromActions.withFlag(actionMetaState) } @@ -926,16 +966,16 @@ class KeyMapController( !isModifierKeyCode && metaStateFromActions != 0 && !mappedToParallelTriggerAction && - event is KeyCodeEvent + event is KeyEventAlgo ) { consumeEvent = true keyCodesToImitateUpAction.add(event.keyCode) - useCase.imitateButtonPress( + useCase.imitateKeyEvent( keyCode = event.keyCode, metaState = metaStateFromKeyEvent.withFlag(metaStateFromActions), deviceId = event.deviceId, - inputEventType = InputEventType.DOWN, + action = KeyEvent.ACTION_DOWN, scanCode = event.scanCode, source = event.source, ) @@ -1048,7 +1088,7 @@ class KeyMapController( /** * @return whether to consume the event. */ - private fun onKeyUp(event: Event): Boolean { + private fun onKeyUp(event: AlgoEvent): Boolean { val downTime = eventDownTimeMap[event] ?: currentTime eventDownTimeMap.remove(event) @@ -1074,7 +1114,7 @@ class KeyMapController( var metaStateFromActionsToRemove = 0 - if (event is KeyCodeEvent) { + if (event is KeyEventAlgo) { if (keyCodesToImitateUpAction.contains(event.keyCode)) { consumeEvent = true imitateUpKeyEvent = true @@ -1153,7 +1193,12 @@ class KeyMapController( if ((currentTime - downTime) >= longPressDelay(trigger)) { successfulLongPressTrigger = true } else if (detectSequenceLongPresses && - longPressSequenceTriggerKeys.any { it.matchesEvent(event.withLongPress) } + longPressSequenceTriggerKeys.any { key -> + when (key) { + is EvdevTriggerKey -> key.matchesEvent(event.withLongPress) + is KeyEventTriggerKey -> key.matchesEvent(event.withLongPress) + } + } ) { imitateDownUpKeyEvent = true } @@ -1164,7 +1209,10 @@ class KeyMapController( else -> event.withShortPress } - for (overlappingTriggerIndex in sequenceTriggersOverlappingSequenceTriggers[triggerIndex]) { + for ( + overlappingTriggerIndex in + sequenceTriggersOverlappingSequenceTriggers[triggerIndex] + ) { if (lastMatchedEventIndices[overlappingTriggerIndex] != -1) { continue@triggerLoop } @@ -1259,10 +1307,7 @@ class KeyMapController( // short press if (keyAwaitingRelease && - trigger.matchingEventAtIndex( - event.withShortPress, - keyIndex, - ) + trigger.matchingEventAtIndex(event.withShortPress, keyIndex) ) { if (isSingleKeyTrigger) { shortPressSingleKeyTriggerJustReleased = true @@ -1274,12 +1319,15 @@ class KeyMapController( if (modifierKeyEventActions) { val actionKeys = triggerActions[triggerIndex] - actionKeys.forEach { actionKey -> - + for (actionKey in actionKeys) { actionMap[actionKey]?.let { action -> - if (action.data is ActionData.InputKeyEvent && isModifierKey(action.data.keyCode)) { + if (action.data is ActionData.InputKeyEvent && + isModifierKey(action.data.keyCode) + ) { val actionMetaState = - InputEventUtils.modifierKeycodeToMetaState(action.data.keyCode) + KeyEventUtils.modifierKeycodeToMetaState( + action.data.keyCode, + ) metaStateFromActionsToRemove = metaStateFromActionsToRemove.withFlag(actionMetaState) @@ -1314,7 +1362,10 @@ class KeyMapController( } if (!imitateDownUpKeyEvent) { - if (isSingleKeyTrigger && !successfulLongPressTrigger && !releasedSuccessfulTrigger) { + if (isSingleKeyTrigger && + !successfulLongPressTrigger && + !releasedSuccessfulTrigger + ) { imitateDownUpKeyEvent = true } else if (lastMatchedIndex > -1 && lastMatchedIndex < triggers[triggerIndex].keys.lastIndex && @@ -1341,7 +1392,9 @@ class KeyMapController( // let actions know that the trigger has been released if (lastHeldDownEventIndex != triggers[triggerIndex].keys.lastIndex) { - parallelTriggerActionPerformers[triggerIndex]?.onReleased(metaStateFromKeyEvent + metaStateFromActions) + parallelTriggerActionPerformers[triggerIndex]?.onReleased( + metaStateFromKeyEvent + metaStateFromActions, + ) } } @@ -1385,7 +1438,9 @@ class KeyMapController( ) } - if (detectedSequenceTriggerIndexes.isNotEmpty() || detectedParallelTriggerIndexes.isNotEmpty()) { + if (detectedSequenceTriggerIndexes.isNotEmpty() || + detectedParallelTriggerIndexes.isNotEmpty() + ) { if (forceVibrate.value) { useCase.vibrate(defaultVibrateDuration.value) } else { @@ -1419,13 +1474,34 @@ class KeyMapController( return@launch } - if (event is KeyCodeEvent) { - useCase.imitateButtonPress( + if (event is KeyEventAlgo) { + useCase.imitateKeyEvent( event.keyCode, - inputEventType = InputEventType.DOWN_UP, + action = KeyEvent.ACTION_DOWN, scanCode = event.scanCode, source = event.source, ) + + useCase.imitateKeyEvent( + event.keyCode, + action = KeyEvent.ACTION_UP, + scanCode = event.scanCode, + source = event.source, + ) + } else if (event is EvdevEventAlgo) { + useCase.imitateEvdevEvent( + devicePath = event.devicePath, + KMEvdevEvent.TYPE_KEY_EVENT, + event.scanCode, + KMEvdevEvent.VALUE_DOWN, + ) + + useCase.imitateEvdevEvent( + devicePath = event.devicePath, + KMEvdevEvent.TYPE_KEY_EVENT, + event.scanCode, + KMEvdevEvent.VALUE_UP, + ) } } } @@ -1434,25 +1510,61 @@ class KeyMapController( detectedSequenceTriggerIndexes.isEmpty() && detectedParallelTriggerIndexes.isEmpty() && !shortPressSingleKeyTriggerJustReleased && - !mappedToDoublePress && - event is KeyCodeEvent + !mappedToDoublePress ) { - val keyEventAction = if (imitateUpKeyEvent) { - InputEventType.UP - } else { - InputEventType.DOWN_UP + if (event is KeyEventAlgo) { + if (imitateUpKeyEvent) { + useCase.imitateKeyEvent( + keyCode = event.keyCode, + metaState = metaStateFromKeyEvent.withFlag(metaStateFromActions), + deviceId = event.deviceId, + action = KeyEvent.ACTION_UP, + scanCode = event.scanCode, + source = event.source, + ) + } else { + useCase.imitateKeyEvent( + keyCode = event.keyCode, + metaState = metaStateFromKeyEvent.withFlag(metaStateFromActions), + deviceId = event.deviceId, + action = KeyEvent.ACTION_DOWN, + scanCode = event.scanCode, + source = event.source, + ) + useCase.imitateKeyEvent( + keyCode = event.keyCode, + metaState = metaStateFromKeyEvent.withFlag(metaStateFromActions), + deviceId = event.deviceId, + action = KeyEvent.ACTION_UP, + scanCode = event.scanCode, + source = event.source, + ) + } + keyCodesToImitateUpAction.remove(event.keyCode) + } else if (event is EvdevEventAlgo) { + if (imitateUpKeyEvent) { + useCase.imitateEvdevEvent( + devicePath = event.devicePath, + type = KMEvdevEvent.TYPE_KEY_EVENT, + code = event.scanCode, + value = KMEvdevEvent.VALUE_UP, + ) + } else { + useCase.imitateEvdevEvent( + devicePath = event.devicePath, + type = KMEvdevEvent.TYPE_KEY_EVENT, + code = event.scanCode, + value = KMEvdevEvent.VALUE_DOWN, + ) + useCase.imitateEvdevEvent( + devicePath = event.devicePath, + type = KMEvdevEvent.TYPE_KEY_EVENT, + code = event.scanCode, + value = KMEvdevEvent.VALUE_UP, + ) + } + keyCodesToImitateUpAction.remove(event.keyCode) } - - useCase.imitateButtonPress( - keyCode = event.keyCode, - metaState = metaStateFromKeyEvent.withFlag(metaStateFromActions), - deviceId = event.deviceId, - inputEventType = keyEventAction, - scanCode = event.scanCode, - source = event.source, - ) - - keyCodesToImitateUpAction.remove(event.keyCode) } return consumeEvent @@ -1516,7 +1628,7 @@ class KeyMapController( /** * @return whether any actions were performed. */ - private fun performActionsOnFailedDoublePress(event: Event): Boolean { + private fun performActionsOnFailedDoublePress(event: AlgoEvent): Boolean { var showToast = false val detectedTriggerIndexes = mutableListOf() val vibrateDurations = mutableListOf() @@ -1561,8 +1673,9 @@ class KeyMapController( return detectedTriggerIndexes.isNotEmpty() } - private fun encodeActionList(actions: List): IntArray = - actions.map { getActionKey(it) }.toIntArray() + private fun encodeActionList(actions: List): IntArray = actions.map { + getActionKey(it) + }.toIntArray() /** * @return the key for the action in [actionMap]. Returns -1 if the [action] can't be found. @@ -1586,11 +1699,11 @@ class KeyMapController( delay(400) while (keyCodesToImitateUpAction.contains(keyCode)) { - useCase.imitateButtonPress( + useCase.imitateKeyEvent( keyCode = keyCode, metaState = metaStateFromKeyEvent.withFlag(metaStateFromActions), deviceId = deviceId, - inputEventType = InputEventType.DOWN, + action = KeyEvent.ACTION_DOWN, scanCode = scanCode, source = source, ) // use down action because this is what Android does @@ -1656,29 +1769,45 @@ class KeyMapController( } } - private fun Trigger.matchingEventAtIndex(event: Event, index: Int): Boolean { + private fun Trigger.matchingEventAtIndex(event: AlgoEvent, index: Int): Boolean { if (index >= this.keys.size) return false return this.keys[index].matchesEvent(event) } - private fun TriggerKey.matchesEvent(event: Event): Boolean { - if (this is KeyCodeTriggerKey && event is KeyCodeEvent) { + private fun TriggerKey.matchesEvent(event: AlgoEvent): Boolean { + if (this is KeyEventTriggerKey && event is KeyEventAlgo) { + val codeMatches = if (this.detectWithScancode()) { + this.scanCode == event.scanCode + } else { + this.keyCode == event.keyCode + } + return when (this.device) { - TriggerKeyDevice.Any -> this.keyCode == event.keyCode && this.clickType == event.clickType - is TriggerKeyDevice.External -> - this.keyCode == event.keyCode && - event.descriptor != null && + KeyEventTriggerDevice.Any -> codeMatches && this.clickType == event.clickType + is KeyEventTriggerDevice.External -> + event.isExternal && + codeMatches && event.descriptor == this.device.descriptor && this.clickType == event.clickType - TriggerKeyDevice.Internal -> - this.keyCode == event.keyCode && - event.descriptor == null && + KeyEventTriggerDevice.Internal -> + !event.isExternal && + codeMatches && this.clickType == event.clickType } + } else if (this is EvdevTriggerKey && event is EvdevEventAlgo) { + val codeMatches = if (this.detectWithScancode()) { + this.scanCode == event.scanCode + } else { + this.keyCode == event.keyCode + } + + return codeMatches && this.clickType == event.clickType && this.device == event.device } else if (this is AssistantTriggerKey && event is AssistantEvent) { - return if (this.type == AssistantTriggerType.ANY || event.type == AssistantTriggerType.ANY) { + return if (this.type == AssistantTriggerType.ANY || + event.type == AssistantTriggerType.ANY + ) { this.clickType == event.clickType } else { this.type == event.type && this.clickType == event.clickType @@ -1693,22 +1822,38 @@ class KeyMapController( } private fun TriggerKey.matchesWithOtherKey(otherKey: TriggerKey): Boolean { - if (this is KeyCodeTriggerKey && otherKey is KeyCodeTriggerKey) { + if (this is KeyEventTriggerKey && otherKey is KeyEventTriggerKey) { + val codeMatches = if (this.detectWithScancode()) { + otherKey.detectWithScancode() && this.scanCode == otherKey.scanCode + } else { + this.keyCode == otherKey.keyCode + } + return when (this.device) { - TriggerKeyDevice.Any -> - this.keyCode == otherKey.keyCode && + KeyEventTriggerDevice.Any -> + codeMatches && this.clickType == otherKey.clickType - is TriggerKeyDevice.External -> - this.keyCode == otherKey.keyCode && + is KeyEventTriggerDevice.External -> + codeMatches && this.device == otherKey.device && this.clickType == otherKey.clickType - TriggerKeyDevice.Internal -> - this.keyCode == otherKey.keyCode && - otherKey.device == TriggerKeyDevice.Internal && + KeyEventTriggerDevice.Internal -> + codeMatches && + otherKey.device == KeyEventTriggerDevice.Internal && this.clickType == otherKey.clickType } + } else if (this is EvdevTriggerKey && otherKey is EvdevTriggerKey) { + val codeMatches = if (this.detectWithScancode()) { + otherKey.detectWithScancode() && this.scanCode == otherKey.scanCode + } else { + this.keyCode == otherKey.keyCode + } + + return codeMatches && + this.clickType == otherKey.clickType && + this.device == otherKey.device } else if (this is AssistantTriggerKey && otherKey is AssistantTriggerKey) { return this.type == otherKey.type && this.clickType == otherKey.clickType } else if (this is FloatingButtonKey && otherKey is FloatingButtonKey) { @@ -1758,61 +1903,79 @@ class KeyMapController( KeyEvent.KEYCODE_SYM, KeyEvent.KEYCODE_NUM, KeyEvent.KEYCODE_FUNCTION, - -> true + -> true else -> false } - private val Event.withShortPress: Event + private val AlgoEvent.withShortPress: AlgoEvent get() = setClickType(clickType = ClickType.SHORT_PRESS) - private val Event.withLongPress: Event + private val AlgoEvent.withLongPress: AlgoEvent get() = setClickType(clickType = ClickType.LONG_PRESS) - private val Event.withDoublePress: Event + private val AlgoEvent.withDoublePress: AlgoEvent get() = setClickType(clickType = ClickType.DOUBLE_PRESS) + private val TriggerKey.consumeEvent: Boolean + get() { + return when (this) { + is AssistantTriggerKey -> true + is EvdevTriggerKey -> consumeEvent + is FingerprintTriggerKey -> true + is FloatingButtonKey -> true + is KeyEventTriggerKey -> consumeEvent + } + } + /** * Represents the kind of event a trigger key is expecting to happen. */ - private sealed class Event { + private sealed class AlgoEvent { abstract val clickType: ClickType? - fun setClickType(clickType: ClickType?): Event = when (this) { - is KeyCodeEvent -> this.copy(clickType = clickType) + fun setClickType(clickType: ClickType?): AlgoEvent = when (this) { + is EvdevEventAlgo -> this.copy(clickType = clickType) + is KeyEventAlgo -> this.copy(clickType = clickType) is AssistantEvent -> this.copy(clickType = clickType) is FloatingButtonEvent -> this.copy(clickType = clickType) is FingerprintGestureEvent -> this.copy(clickType = clickType) } } - private data class KeyCodeEvent( + private data class EvdevEventAlgo( + val devicePath: String, + val device: EvdevDeviceInfo, + val scanCode: Int, val keyCode: Int, override val clickType: ClickType?, - /** - * null if not an external device - */ - val descriptor: String?, + ) : AlgoEvent() + + private data class KeyEventAlgo( + val keyCode: Int, + override val clickType: ClickType?, + val descriptor: String, val deviceId: Int, + val isExternal: Boolean, val scanCode: Int, val repeatCount: Int, val source: Int, - ) : Event() + ) : AlgoEvent() private data class AssistantEvent( val type: AssistantTriggerType, override val clickType: ClickType?, - ) : Event() + ) : AlgoEvent() private data class FingerprintGestureEvent( val type: FingerprintGestureType, override val clickType: ClickType?, - ) : Event() + ) : AlgoEvent() private data class FloatingButtonEvent( val buttonUid: String, override val clickType: ClickType?, - ) : Event() + ) : AlgoEvent() private data class TriggerKeyLocation(val triggerIndex: Int, val keyIndex: Int) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapDetectionController.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapDetectionController.kt new file mode 100644 index 0000000000..955023690b --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapDetectionController.kt @@ -0,0 +1,119 @@ +package io.github.sds100.keymapper.base.detection + +import io.github.sds100.keymapper.base.actions.PerformActionsUseCase +import io.github.sds100.keymapper.base.constraints.DetectConstraintsUseCase +import io.github.sds100.keymapper.base.input.InputEventDetectionSource +import io.github.sds100.keymapper.base.input.InputEventHub +import io.github.sds100.keymapper.base.input.InputEventHubCallback +import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase +import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.base.trigger.AssistantTriggerType +import io.github.sds100.keymapper.base.trigger.EvdevTriggerKey +import io.github.sds100.keymapper.base.trigger.RecordTriggerController +import io.github.sds100.keymapper.base.trigger.RecordTriggerState +import io.github.sds100.keymapper.base.trigger.Trigger +import io.github.sds100.keymapper.system.inputevents.KMEvdevEvent +import io.github.sds100.keymapper.system.inputevents.KMInputEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import timber.log.Timber + +class KeyMapDetectionController( + private val coroutineScope: CoroutineScope, + private val detectUseCase: DetectKeyMapsUseCase, + private val performActionsUseCase: PerformActionsUseCase, + private val detectConstraints: DetectConstraintsUseCase, + private val inputEventHub: InputEventHub, + private val pauseKeyMapsUseCase: PauseKeyMapsUseCase, + private val recordTriggerController: RecordTriggerController, +) : InputEventHubCallback { + companion object { + private const val INPUT_EVENT_HUB_ID = "key_map_controller" + } + + private val algorithm: KeyMapAlgorithm = + KeyMapAlgorithm(coroutineScope, detectUseCase, performActionsUseCase, detectConstraints) + + private val isPaused: StateFlow = + pauseKeyMapsUseCase.isPaused.stateIn(coroutineScope, SharingStarted.Eagerly, false) + + init { + // Must first register before collecting anything that may call reset() + inputEventHub.registerClient(INPUT_EVENT_HUB_ID, this, listOf(KMEvdevEvent.TYPE_KEY_EVENT)) + + coroutineScope.launch { + combine(detectUseCase.allKeyMapList, isPaused) { keyMapList, isPaused -> + algorithm.reset() + + if (isPaused) { + algorithm.loadKeyMaps(emptyList()) + inputEventHub.setGrabbedEvdevDevices(INPUT_EVENT_HUB_ID, emptyList()) + } else { + algorithm.loadKeyMaps(keyMapList) + // Only grab the triggers that are actually being listened to by the algorithm + grabEvdevDevicesForTriggers(algorithm.triggers) + } + }.launchIn(coroutineScope) + } + } + + override fun onInputEvent( + event: KMInputEvent, + detectionSource: InputEventDetectionSource, + ): Boolean { + if (isPaused.value) { + return false + } + + if (recordTriggerController.state.value is RecordTriggerState.CountingDown) { + return false + } + + return algorithm.onInputEvent(event) + } + + fun onFingerprintGesture(type: FingerprintGestureType) { + algorithm.onFingerprintGesture(type) + } + + fun onFloatingButtonDown(button: String) { + algorithm.onFloatingButtonDown(button) + } + + fun onFloatingButtonUp(button: String) { + algorithm.onFloatingButtonUp(button) + } + + fun onAssistantEvent(event: AssistantTriggerType) { + algorithm.onAssistantEvent(event) + } + + fun teardown() { + reset() + inputEventHub.unregisterClient(INPUT_EVENT_HUB_ID) + } + + private fun grabEvdevDevicesForTriggers(triggers: Array) { + val evdevDevices = triggers + .flatMap { trigger -> trigger.keys.filterIsInstance() } + .map { it.device } + .distinct() + .toList() + + Timber.i("Grab evdev devices for key map detection: ${evdevDevices.joinToString()}") + inputEventHub.setGrabbedEvdevDevices( + INPUT_EVENT_HUB_ID, + evdevDevices, + ) + } + + private fun reset() { + algorithm.reset() + inputEventHub.setGrabbedEvdevDevices(INPUT_EVENT_HUB_ID, emptyList()) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/KeyPressedCallback.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyPressedCallback.kt similarity index 62% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/KeyPressedCallback.kt rename to base/src/main/java/io/github/sds100/keymapper/base/detection/KeyPressedCallback.kt index cada80083c..3b8377b56a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/KeyPressedCallback.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyPressedCallback.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.base.keymaps.detection +package io.github.sds100.keymapper.base.detection interface KeyPressedCallback { fun onDownEvent(button: T) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/ParallelTriggerActionPerformer.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/ParallelTriggerActionPerformer.kt similarity index 80% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/ParallelTriggerActionPerformer.kt rename to base/src/main/java/io/github/sds100/keymapper/base/detection/ParallelTriggerActionPerformer.kt index dcdac06267..a4b46b0833 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/ParallelTriggerActionPerformer.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/ParallelTriggerActionPerformer.kt @@ -1,12 +1,12 @@ -package io.github.sds100.keymapper.base.keymaps.detection +package io.github.sds100.keymapper.base.detection import io.github.sds100.keymapper.base.actions.Action import io.github.sds100.keymapper.base.actions.ActionData import io.github.sds100.keymapper.base.actions.PerformActionsUseCase import io.github.sds100.keymapper.base.actions.RepeatMode -import io.github.sds100.keymapper.common.utils.InputEventType +import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.data.PreferenceDefaults -import io.github.sds100.keymapper.system.inputevents.InputEventUtils +import io.github.sds100.keymapper.system.inputevents.KeyEventUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -57,7 +57,10 @@ class ParallelTriggerActionPerformer( for ((actionIndex, action) in actionList.withIndex()) { var performUpAction = false - if (action.holdDown && action.repeat && action.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN) { + if (action.holdDown && + action.repeat && + action.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN + ) { if (actionIsHeldDown[actionIndex]) { actionIsHeldDown[actionIndex] = false performUpAction = true @@ -75,13 +78,13 @@ class ParallelTriggerActionPerformer( actionIsHeldDown[actionIndex] = true } - val actionInputEventType = when { - performUpAction -> InputEventType.UP - action.holdDown -> InputEventType.DOWN - else -> InputEventType.DOWN_UP + val actionInputEventAction = when { + performUpAction -> InputEventAction.UP + action.holdDown -> InputEventAction.DOWN + else -> InputEventAction.DOWN_UP } - performAction(action, actionInputEventType, metaState) + performAction(action, actionInputEventAction, metaState) if (action.repeat && action.holdDown) { delay(action.holdDownDuration?.toLong() ?: defaultHoldDownDuration.value) @@ -105,14 +108,18 @@ class ParallelTriggerActionPerformer( } // don't start repeating if it is already repeating - if (action.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN && repeatJobs[actionIndex] != null) { + if (action.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN && + repeatJobs[actionIndex] != null + ) { repeatJobs[actionIndex]?.cancel() repeatJobs[actionIndex] = null continue } - if (action.data is ActionData.InputKeyEvent && InputEventUtils.isModifierKey(action.data.keyCode)) { + if (action.data is ActionData.InputKeyEvent && + KeyEventUtils.isModifierKey(action.data.keyCode) + ) { continue } @@ -124,13 +131,13 @@ class ParallelTriggerActionPerformer( while (isActive && continueRepeating) { if (action.holdDown) { - performAction(action, InputEventType.DOWN, metaState) + performAction(action, InputEventAction.DOWN, metaState) delay( action.holdDownDuration?.toLong() ?: defaultHoldDownDuration.value, ) - performAction(action, InputEventType.UP, metaState) + performAction(action, InputEventAction.UP, metaState) } else { - performAction(action, InputEventType.DOWN_UP, metaState) + performAction(action, InputEventAction.DOWN_UP, metaState) } repeatCount++ @@ -159,7 +166,7 @@ class ParallelTriggerActionPerformer( if (actionIsHeldDown[actionIndex]) { actionIsHeldDown[actionIndex] = false - performAction(action, InputEventType.UP, metaState) + performAction(action, InputEventAction.UP, metaState) } } } @@ -173,7 +180,7 @@ class ParallelTriggerActionPerformer( coroutineScope.launch { for ((index, isHeldDown) in actionIsHeldDown.withIndex()) { if (isHeldDown) { - performAction(actionList[index], inputEventType = InputEventType.UP, 0) + performAction(actionList[index], inputEventAction = InputEventAction.UP, 0) } } } @@ -190,11 +197,11 @@ class ParallelTriggerActionPerformer( private suspend fun performAction( action: Action, - inputEventType: InputEventType, + inputEventAction: InputEventAction, metaState: Int, ) { repeat(action.multiplier ?: 1) { - useCase.perform(action.data, inputEventType, metaState) + useCase.perform(action.data, inputEventAction, metaState) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/SequenceTriggerActionPerformer.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/SequenceTriggerActionPerformer.kt similarity index 85% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/SequenceTriggerActionPerformer.kt rename to base/src/main/java/io/github/sds100/keymapper/base/detection/SequenceTriggerActionPerformer.kt index a040f7f860..dc7d40816b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/SequenceTriggerActionPerformer.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/SequenceTriggerActionPerformer.kt @@ -1,8 +1,8 @@ -package io.github.sds100.keymapper.base.keymaps.detection +package io.github.sds100.keymapper.base.detection import io.github.sds100.keymapper.base.actions.Action import io.github.sds100.keymapper.base.actions.PerformActionsUseCase -import io.github.sds100.keymapper.common.utils.InputEventType +import io.github.sds100.keymapper.common.utils.InputEventAction import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -37,7 +37,7 @@ class SequenceTriggerActionPerformer( private suspend fun performAction(action: Action, metaState: Int) { repeat(action.multiplier ?: 1) { - useCase.perform(action.data, InputEventType.DOWN_UP, metaState) + useCase.perform(action.data, InputEventAction.DOWN_UP, metaState) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/SimpleMappingController.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/SimpleMappingController.kt similarity index 83% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/SimpleMappingController.kt rename to base/src/main/java/io/github/sds100/keymapper/base/detection/SimpleMappingController.kt index c0bb27f2ef..4952391d4a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/SimpleMappingController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/SimpleMappingController.kt @@ -1,12 +1,12 @@ -package io.github.sds100.keymapper.base.keymaps +package io.github.sds100.keymapper.base.detection import io.github.sds100.keymapper.base.actions.Action import io.github.sds100.keymapper.base.actions.PerformActionsUseCase import io.github.sds100.keymapper.base.actions.RepeatMode import io.github.sds100.keymapper.base.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.base.constraints.isSatisfied -import io.github.sds100.keymapper.base.keymaps.detection.DetectKeyMapsUseCase -import io.github.sds100.keymapper.common.utils.InputEventType +import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.data.PreferenceDefaults import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart @@ -30,27 +30,27 @@ abstract class SimpleMappingController( private val defaultRepeatRate: StateFlow = performActionsUseCase.defaultRepeatRate.stateIn( coroutineScope, - SharingStarted.Eagerly, + SharingStarted.Companion.Eagerly, PreferenceDefaults.REPEAT_RATE.toLong(), ) private val forceVibrate: StateFlow = detectMappingUseCase.forceVibrate.stateIn( coroutineScope, - SharingStarted.Eagerly, + SharingStarted.Companion.Eagerly, PreferenceDefaults.FORCE_VIBRATE, ) private val defaultHoldDownDuration: StateFlow = performActionsUseCase.defaultHoldDownDuration.stateIn( coroutineScope, - SharingStarted.Eagerly, + SharingStarted.Companion.Eagerly, PreferenceDefaults.HOLD_DOWN_DURATION.toLong(), ) private val defaultVibrateDuration: StateFlow = detectMappingUseCase.defaultVibrateDuration.stateIn( coroutineScope, - SharingStarted.Eagerly, + SharingStarted.Companion.Eagerly, PreferenceDefaults.VIBRATION_DURATION.toLong(), ) @@ -81,7 +81,9 @@ abstract class SimpleMappingController( for (job in this@SimpleMappingController.repeatJobs[keyMap.uid] ?: emptyList()) { - if (job.actionUid == action.uid && action.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN) { + if (job.actionUid == action.uid && + action.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN + ) { alreadyRepeating = true job.cancel() break @@ -97,9 +99,9 @@ abstract class SimpleMappingController( val alreadyBeingHeldDown = actionsBeingHeldDown.any { action.uid == it.uid } val keyEventAction = when { - action.holdDown && !alreadyBeingHeldDown -> InputEventType.DOWN - alreadyBeingHeldDown -> InputEventType.UP - else -> InputEventType.DOWN_UP + action.holdDown && !alreadyBeingHeldDown -> InputEventAction.DOWN + alreadyBeingHeldDown -> InputEventAction.UP + else -> InputEventAction.DOWN_UP } when { @@ -129,10 +131,10 @@ abstract class SimpleMappingController( private suspend fun performAction( action: Action, - inputEventType: InputEventType = InputEventType.DOWN_UP, + inputEventAction: InputEventAction = InputEventAction.DOWN_UP, ) { repeat(action.multiplier ?: 1) { - performActionsUseCase.perform(action.data, inputEventType) + performActionsUseCase.perform(action.data, inputEventAction) } } @@ -149,22 +151,22 @@ abstract class SimpleMappingController( while (continueRepeating) { val keyEventAction = when { - holdDown -> InputEventType.DOWN - else -> InputEventType.DOWN_UP + holdDown -> InputEventAction.DOWN + else -> InputEventAction.DOWN_UP } performAction(action, keyEventAction) if (holdDown) { delay(holdDownDuration) - performAction(action, InputEventType.UP) + performAction(action, InputEventAction.UP) } repeatCount++ if (action.repeatLimit != null) { - continueRepeating = - repeatCount < action.repeatLimit + 1 // this value is how many times it should REPEAT. The first repeat happens after the first time it is performed + // this value is how many times it should REPEAT. The first repeat happens after the first time it is performed + continueRepeating = repeatCount < action.repeatLimit + 1 } delay(repeatRate) @@ -186,7 +188,7 @@ abstract class SimpleMappingController( coroutineScope.launch { for (it in actionsBeingHeldDown) { - performAction(it, InputEventType.UP) + performAction(it, InputEventAction.UP) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/TriggerKeyMapFromOtherAppsController.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/TriggerKeyMapFromOtherAppsController.kt similarity index 84% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/TriggerKeyMapFromOtherAppsController.kt rename to base/src/main/java/io/github/sds100/keymapper/base/detection/TriggerKeyMapFromOtherAppsController.kt index c9d04b1f0d..01755046ef 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/TriggerKeyMapFromOtherAppsController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/TriggerKeyMapFromOtherAppsController.kt @@ -1,9 +1,8 @@ -package io.github.sds100.keymapper.base.keymaps.detection +package io.github.sds100.keymapper.base.detection import io.github.sds100.keymapper.base.actions.PerformActionsUseCase import io.github.sds100.keymapper.base.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.base.keymaps.KeyMap -import io.github.sds100.keymapper.base.keymaps.SimpleMappingController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -38,7 +37,9 @@ class TriggerKeyMapFromOtherAppsController( Timber.d("Triggered key map successfully from Intent, $keyMap") } else { - Timber.d("Failed to trigger key map from intent because key map doesn't exist, uid = $uid") + Timber.d( + "Failed to trigger key map from intent because key map doesn't exist, uid = $uid", + ) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/floating/FloatingButtonData.kt b/base/src/main/java/io/github/sds100/keymapper/base/floating/FloatingButtonData.kt index 84a9c5535e..d696dcc7f2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/floating/FloatingButtonData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/floating/FloatingButtonData.kt @@ -6,8 +6,8 @@ import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.common.utils.getKey import io.github.sds100.keymapper.data.db.typeconverter.ConstantTypeConverters import io.github.sds100.keymapper.data.entities.FloatingButtonEntity -import kotlinx.serialization.Serializable import java.util.UUID +import kotlinx.serialization.Serializable @Serializable data class FloatingButtonData( @@ -16,6 +16,8 @@ data class FloatingButtonData( val layoutName: String, val appearance: FloatingButtonAppearance, val location: Location, + val showOverStatusBar: Boolean, + val showOverInputMethod: Boolean, ) { /** * This stores data about where a draggable overlay is located. It needs extra information @@ -79,6 +81,8 @@ object FloatingButtonEntityMapper { orientation = ConstantTypeConverters.ORIENTATION_MAP.getKey(entity.orientation)!!, displaySize = SizeKM(entity.displayWidth, entity.displayHeight), ), + showOverStatusBar = entity.showOverStatusBar ?: false, + showOverInputMethod = entity.showOverInputMethod ?: false, ) } @@ -95,6 +99,8 @@ object FloatingButtonEntityMapper { orientation = ConstantTypeConverters.ORIENTATION_MAP[button.location.orientation]!!, displayWidth = button.location.displaySize.width, displayHeight = button.location.displaySize.height, + showOverStatusBar = button.showOverStatusBar, + showOverInputMethod = button.showOverInputMethod, ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupBreadcrumbRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupBreadcrumbRow.kt index d3ece625dc..4564acc895 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupBreadcrumbRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupBreadcrumbRow.kt @@ -63,7 +63,11 @@ fun GroupBreadcrumbRow( ) Breadcrumb( - modifier = Modifier.widthIn(max = LocalDensity.current.run { maxCrumbWidth.toDp() }), + modifier = Modifier.widthIn( + max = LocalDensity.current.run { + maxCrumbWidth.toDp() + }, + ), text = group.name, onClick = { onGroupClick(group.uid) }, color = if (index == groups.lastIndex) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt index 890a61d6c4..f9e4e6ae49 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt @@ -71,7 +71,9 @@ fun GroupConstraintRow( for ((index, constraint) in constraints.withIndex()) { when (constraint) { is ComposeChipModel.Normal -> - CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurface, + ) { ConstraintButton( modifier = Modifier.widthIn(max = maxChipWidth), text = constraint.text, @@ -92,7 +94,9 @@ fun GroupConstraintRow( modifier = Modifier .size(24.dp) .padding(end = 8.dp), - painter = rememberDrawablePainter(constraint.icon.drawable), + painter = rememberDrawablePainter( + constraint.icon.drawable, + ), contentDescription = null, tint = Color.Unspecified, ) @@ -102,7 +106,9 @@ fun GroupConstraintRow( } is ComposeChipModel.Error -> - CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onErrorContainer) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onErrorContainer, + ) { ConstraintErrorButton( modifier = Modifier.widthIn(max = maxChipWidth), text = constraint.text, @@ -232,7 +238,9 @@ private fun ConstraintButton( ) { Icon( imageVector = Icons.Rounded.Close, - contentDescription = stringResource(R.string.home_group_delete_constraint_button), + contentDescription = stringResource( + R.string.home_group_delete_constraint_button, + ), ) } } @@ -289,7 +297,9 @@ private fun ConstraintErrorButton( ) { Icon( imageVector = Icons.Rounded.Close, - contentDescription = stringResource(R.string.home_group_delete_constraint_button), + contentDescription = stringResource( + R.string.home_group_delete_constraint_button, + ), ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupFamily.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupFamily.kt index afd78e9231..9df58a8969 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupFamily.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupFamily.kt @@ -1,7 +1,3 @@ package io.github.sds100.keymapper.base.groups -data class GroupFamily( - val group: Group?, - val children: List, - val parents: List, -) +data class GroupFamily(val group: Group?, val children: List, val parents: List) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt index 6a384f04a8..151051f500 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt @@ -130,7 +130,11 @@ fun GroupRow( for (group in groups) { GroupButton( - modifier = Modifier.widthIn(max = LocalDensity.current.run { maxChipWidth.toDp() }), + modifier = Modifier.widthIn( + max = LocalDensity.current.run { + maxChipWidth.toDp() + }, + ), onClick = { onGroupClick(group.uid) }, text = group.name, enabled = enabled, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/AnimatedFloatingActionButton.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/AnimatedFloatingActionButton.kt new file mode 100644 index 0000000000..1a3c55edf8 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/AnimatedFloatingActionButton.kt @@ -0,0 +1,80 @@ +package io.github.sds100.keymapper.base.home + +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +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.CollapsableFloatingActionButton + +@Composable +fun AnimatedFloatingActionButton( + modifier: Modifier = Modifier, + pulse: Boolean, + showFabText: Boolean, + text: String, + onClick: () -> Unit, +) { + val defaultColor = MaterialTheme.colorScheme.primaryContainer + val pulseColor = LocalCustomColorsPalette.current.primaryContainerDarker + + val animatedPulseColor = remember { Animatable(defaultColor) } + var finishedAnimation by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(pulse) { + if (pulse && !finishedAnimation) { + repeat(10) { + animatedPulseColor.animateTo(pulseColor, tween(700)) + animatedPulseColor.animateTo(defaultColor, tween(700)) + } + + finishedAnimation = true + } + } + + CollapsableFloatingActionButton( + modifier = modifier, + onClick = { + finishedAnimation = true + onClick() + }, + showText = showFabText, + text = text, + containerColor = animatedPulseColor.value, + ) +} + +@Preview +@Composable +private fun PreviewAnimatedFloatingActionButton() { + KeyMapperTheme { + AnimatedFloatingActionButton( + pulse = false, + showFabText = true, + text = "New Key Map", + onClick = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewAnimatedFloatingActionButtonPulsing() { + KeyMapperTheme { + AnimatedFloatingActionButton( + pulse = true, + showFabText = true, + text = "New Key Map", + onClick = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/BaseHomeViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/BaseHomeViewModel.kt index 47e7d65a73..83cbf4229b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/BaseHomeViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/BaseHomeViewModel.kt @@ -3,14 +3,13 @@ package io.github.sds100.keymapper.base.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.keyevent.FixKeyEventActionDelegate import io.github.sds100.keymapper.base.backup.BackupRestoreMappingsUseCase -import io.github.sds100.keymapper.base.keymaps.KeyMapListViewModel -import io.github.sds100.keymapper.base.keymaps.ListKeyMapsUseCase import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate import io.github.sds100.keymapper.base.sorting.SortKeyMapsUseCase import io.github.sds100.keymapper.base.system.inputmethod.ShowInputMethodPickerUseCase -import io.github.sds100.keymapper.base.trigger.SetupGuiKeyboardUseCase 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 @@ -19,7 +18,6 @@ import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.DialogResponse import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch abstract class BaseHomeViewModel( @@ -29,9 +27,10 @@ abstract class BaseHomeViewModel( private val showAlertsUseCase: ShowHomeScreenAlertsUseCase, private val onboarding: OnboardingUseCase, resourceProvider: ResourceProvider, - private val setupGuiKeyboard: SetupGuiKeyboardUseCase, private val sortKeyMaps: SortKeyMapsUseCase, private val showInputMethodPickerUseCase: ShowInputMethodPickerUseCase, + setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, + fixKeyEventActionDelegate: FixKeyEventActionDelegate, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, ) : ViewModel(), @@ -44,13 +43,14 @@ abstract class BaseHomeViewModel( viewModelScope, listKeyMaps, resourceProvider, - setupGuiKeyboard, sortKeyMaps, showAlertsUseCase, pauseKeyMaps, backupRestore, showInputMethodPickerUseCase, onboarding, + setupAccessibilityServiceDelegate, + fixKeyEventActionDelegate, navigationProvider, dialogProvider, ) @@ -64,12 +64,6 @@ abstract class BaseHomeViewModel( } } } - - viewModelScope.launch { - if (setupGuiKeyboard.isInstalled.first() && !setupGuiKeyboard.isCompatibleVersion.first()) { - showUpgradeGuiKeyboardDialog() - } - } } fun launchSettings() { @@ -101,24 +95,6 @@ abstract class BaseHomeViewModel( onboarding.showedWhatsNew() } - - private suspend fun showUpgradeGuiKeyboardDialog() { - val dialog = DialogModel.Alert( - title = getString(R.string.dialog_upgrade_gui_keyboard_title), - message = getString(R.string.dialog_upgrade_gui_keyboard_message), - positiveButtonText = getString(R.string.dialog_upgrade_gui_keyboard_positive), - negativeButtonText = getString(R.string.dialog_upgrade_gui_keyboard_neutral), - ) - - val response = showDialog("upgrade_gui_keyboard", dialog) - - if (response == DialogResponse.POSITIVE) { - showDialog( - "gui_keyboard_play_store", - DialogModel.OpenUrl(getString(R.string.url_play_store_keymapper_gui_keyboard)), - ) - } - } } enum class SelectedKeyMapsEnabled { @@ -127,7 +103,4 @@ enum class SelectedKeyMapsEnabled { MIXED, } -data class HomeWarningListItem( - val id: String, - val text: String, -) +data class HomeWarningListItem(val id: String, val text: String) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt index 1b4175e7c8..4118c107f2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt @@ -52,27 +52,20 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.canopas.lib.showcase.IntroShowcase import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.keyevent.FixKeyEventActionBottomSheet import io.github.sds100.keymapper.base.backup.ImportExportState import io.github.sds100.keymapper.base.backup.RestoreType import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.groups.GroupListItemModel -import io.github.sds100.keymapper.base.keymaps.KeyMapAppBarState -import io.github.sds100.keymapper.base.keymaps.KeyMapList -import io.github.sds100.keymapper.base.keymaps.KeyMapListViewModel -import io.github.sds100.keymapper.base.onboarding.OnboardingTapTarget import io.github.sds100.keymapper.base.sorting.SortBottomSheet -import io.github.sds100.keymapper.base.trigger.DpadTriggerSetupBottomSheet import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel import io.github.sds100.keymapper.base.trigger.TriggerError import io.github.sds100.keymapper.base.utils.ShareUtils import io.github.sds100.keymapper.base.utils.ui.compose.CollapsableFloatingActionButton import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperTapTarget -import io.github.sds100.keymapper.base.utils.ui.compose.keyMapperShowcaseStyle import io.github.sds100.keymapper.base.utils.ui.compose.openUriSafe import io.github.sds100.keymapper.base.utils.ui.drawable import io.github.sds100.keymapper.common.utils.KMError @@ -93,7 +86,6 @@ fun HomeKeyMapListScreen( val state by viewModel.state.collectAsStateWithLifecycle() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val setupGuiKeyboardState by viewModel.setupGuiKeyboardState.collectAsStateWithLifecycle() val importFileLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> @@ -111,20 +103,6 @@ fun HomeKeyMapListScreen( onConfirmImport = viewModel::onConfirmImport, ) - if (viewModel.showDpadTriggerSetupBottomSheet) { - DpadTriggerSetupBottomSheet( - modifier = Modifier.systemBarsPadding(), - onDismissRequest = { - viewModel.showDpadTriggerSetupBottomSheet = false - }, - guiKeyboardState = setupGuiKeyboardState, - onEnableKeyboardClick = viewModel::onEnableGuiKeyboardClick, - onChooseKeyboardClick = viewModel::onChooseGuiKeyboardClick, - onNeverShowAgainClick = viewModel::onNeverShowSetupDpadClick, - sheetState = sheetState, - ) - } - if (viewModel.showSortBottomSheet) { SortBottomSheet( viewModel = viewModel.sortViewModel, @@ -148,6 +126,25 @@ fun HomeKeyMapListScreen( ) } + val fixKeyEventActionState by viewModel.fixKeyEventActionState.collectAsStateWithLifecycle() + + if (fixKeyEventActionState != null) { + FixKeyEventActionBottomSheet( + modifier = Modifier.systemBarsPadding(), + state = fixKeyEventActionState!!, + sheetState = sheetState, + onDismissRequest = viewModel::dismissFixKeyEventActionBottomSheet, + onEnableAccessibilityServiceClick = viewModel::onEnableAccessibilityServiceClick, + onEnableProModeClick = viewModel::onEnableProModeForKeyEventActionsClick, + onEnableInputMethodClick = viewModel::onEnableImeClick, + onChooseInputMethodClick = viewModel::onChooseImeClick, + onDoneClick = viewModel::dismissFixKeyEventActionBottomSheet, + onSelectProMode = viewModel::onSelectProMode, + onSelectInputMethod = viewModel::onSelectInputMethod, + onAutoSwitchImeCheckedChange = viewModel::onAutoSwitchImeCheckedChange, + ) + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val uriHandler = LocalUriHandler.current val ctx = LocalContext.current @@ -155,124 +152,109 @@ fun HomeKeyMapListScreen( var keyMapListBottomPadding by remember { mutableStateOf(100.dp) } - IntroShowcase( - showIntroShowCase = state.showCreateKeyMapTapTarget, - onShowCaseCompleted = { - viewModel.onTapTargetsCompleted() + HomeKeyMapListScreen( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + snackbarState = snackbarState, + floatingActionButton = { + AnimatedVisibility( + state.appBarState !is KeyMapAppBarState.Selecting, + enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }), + exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it }), + ) { + AnimatedFloatingActionButton( + modifier = Modifier + .padding(bottom = fabBottomPadding) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.End)), + pulse = state.showCreateKeyMapTapTarget, + showFabText = viewModel.showFabText, + text = stringResource(R.string.home_fab_new_key_map), + onClick = viewModel::onNewKeyMapClick, + ) + } }, - dismissOnClickOutside = true, - ) { - HomeKeyMapListScreen( - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - snackbarState = snackbarState, - floatingActionButton = { - AnimatedVisibility( - state.appBarState !is KeyMapAppBarState.Selecting, - enter = fadeIn() + slideInHorizontally(initialOffsetX = { it }), - exit = fadeOut() + slideOutHorizontally(targetOffsetX = { it }), - ) { - CollapsableFloatingActionButton( - modifier = Modifier - .padding(bottom = fabBottomPadding) - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.End)) - .introShowCaseTarget( - index = 0, - style = keyMapperShowcaseStyle(), - ) { - KeyMapperTapTarget( - OnboardingTapTarget.CREATE_KEY_MAP, - onSkipClick = viewModel::onSkipTapTargetClick, - ) - }, - onClick = viewModel::onNewKeyMapClick, - showText = viewModel.showFabText, - text = stringResource(R.string.home_fab_new_key_map), + listContent = { + KeyMapList( + modifier = Modifier.animateContentSize(), + lazyListState = rememberLazyListState(), + listItems = state.listItems, + footerText = stringResource(R.string.home_key_map_list_footer_text), + isSelectable = state.appBarState is KeyMapAppBarState.Selecting, + onClickKeyMap = viewModel::onKeyMapCardClick, + onLongClickKeyMap = viewModel::onKeyMapCardLongClick, + onSelectedChange = viewModel::onKeyMapSelectedChanged, + onFixClick = viewModel::onFixClick, + onTriggerErrorClick = viewModel::onFixTriggerError, + bottomListPadding = keyMapListBottomPadding, + ) + }, + appBarContent = { + KeyMapListAppBar( + state = state.appBarState, + scrollBehavior = scrollBehavior, + onSettingsClick = onSettingsClick, + onAboutClick = onAboutClick, + onSortClick = { viewModel.showSortBottomSheet = true }, + onHelpClick = { uriHandler.openUriSafe(ctx, helpUrl) }, + onExportClick = viewModel::onExportClick, + onImportClick = { importFileLauncher.launch(FileUtils.MIME_TYPE_ALL) }, + onInputMethodPickerClick = viewModel::showInputMethodPicker, + onTogglePausedClick = viewModel::onTogglePausedClick, + onFixWarningClick = viewModel::onFixWarningClick, + onBackClick = { + if (!viewModel.onBackClick()) { + finishActivity() + } + }, + onSelectAllClick = viewModel::onSelectAllClick, + onNewGroupClick = viewModel::onNewGroupClick, + onRenameGroupClick = viewModel::onRenameGroupClick, + onEditGroupNameClick = viewModel::onEditGroupNameClick, + onGroupClick = viewModel::onGroupClick, + onDeleteGroupClick = viewModel::onDeleteGroupClick, + onNewConstraintClick = viewModel::onNewGroupConstraintClick, + onRemoveConstraintClick = viewModel::onRemoveGroupConstraintClick, + onConstraintModeChanged = viewModel::onGroupConstraintModeChanged, + onFixConstraintClick = viewModel::onFixClick, + onKeyMapsEnabledChange = viewModel::onGroupKeyMapsEnabledChanged, + ) + }, + selectionBottomSheet = { + AnimatedVisibility( + visible = state.appBarState is KeyMapAppBarState.Selecting, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + ) { + val selectionState = (state.appBarState as? KeyMapAppBarState.Selecting) + ?: KeyMapAppBarState.Selecting( + selectionCount = 0, + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.NONE, + isAllSelected = false, + groups = emptyList(), + breadcrumbs = emptyList(), + showThisGroup = false, ) - } - }, - listContent = { - KeyMapList( - modifier = Modifier.animateContentSize(), - lazyListState = rememberLazyListState(), - listItems = state.listItems, - footerText = stringResource(R.string.home_key_map_list_footer_text), - isSelectable = state.appBarState is KeyMapAppBarState.Selecting, - onClickKeyMap = viewModel::onKeyMapCardClick, - onLongClickKeyMap = viewModel::onKeyMapCardLongClick, - onSelectedChange = viewModel::onKeyMapSelectedChanged, - onFixClick = viewModel::onFixClick, - onTriggerErrorClick = viewModel::onFixTriggerError, - bottomListPadding = keyMapListBottomPadding, - ) - }, - appBarContent = { - KeyMapListAppBar( - state = state.appBarState, - scrollBehavior = scrollBehavior, - onSettingsClick = onSettingsClick, - onAboutClick = onAboutClick, - onSortClick = { viewModel.showSortBottomSheet = true }, - onHelpClick = { uriHandler.openUriSafe(ctx, helpUrl) }, - onExportClick = viewModel::onExportClick, - onImportClick = { importFileLauncher.launch(FileUtils.MIME_TYPE_ALL) }, - onInputMethodPickerClick = viewModel::showInputMethodPicker, - onTogglePausedClick = viewModel::onTogglePausedClick, - onFixWarningClick = viewModel::onFixWarningClick, - onBackClick = { - if (!viewModel.onBackClick()) { - finishActivity() - } + + SelectionBottomSheet( + modifier = Modifier.onSizeChanged { size -> + keyMapListBottomPadding = + ((size.height.dp / 2) - 100.dp).coerceAtLeast(0.dp) }, - onSelectAllClick = viewModel::onSelectAllClick, + enabled = selectionState.selectionCount > 0, + groups = selectionState.groups, + breadcrumbs = selectionState.breadcrumbs, + selectedKeyMapsEnabled = selectionState.selectedKeyMapsEnabled, + onEnabledKeyMapsChange = viewModel::onEnabledKeyMapsChange, + onDuplicateClick = viewModel::onDuplicateSelectedKeyMapsClick, + onExportClick = viewModel::onExportSelectedKeyMaps, + onDeleteClick = { showDeleteDialog = true }, + onGroupClick = viewModel::onSelectionGroupClick, onNewGroupClick = viewModel::onNewGroupClick, - onRenameGroupClick = viewModel::onRenameGroupClick, - onEditGroupNameClick = viewModel::onEditGroupNameClick, - onGroupClick = viewModel::onGroupClick, - onDeleteGroupClick = viewModel::onDeleteGroupClick, - onNewConstraintClick = viewModel::onNewGroupConstraintClick, - onRemoveConstraintClick = viewModel::onRemoveGroupConstraintClick, - onConstraintModeChanged = viewModel::onGroupConstraintModeChanged, - onFixConstraintClick = viewModel::onFixClick, + showThisGroup = selectionState.showThisGroup, + onThisGroupClick = viewModel::onMoveToThisGroupClick, ) - }, - selectionBottomSheet = { - AnimatedVisibility( - visible = state.appBarState is KeyMapAppBarState.Selecting, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - ) { - val selectionState = (state.appBarState as? KeyMapAppBarState.Selecting) - ?: KeyMapAppBarState.Selecting( - selectionCount = 0, - selectedKeyMapsEnabled = SelectedKeyMapsEnabled.NONE, - isAllSelected = false, - groups = emptyList(), - breadcrumbs = emptyList(), - showThisGroup = false, - ) - - SelectionBottomSheet( - modifier = Modifier.onSizeChanged { size -> - keyMapListBottomPadding = - ((size.height.dp / 2) - 100.dp).coerceAtLeast(0.dp) - }, - enabled = selectionState.selectionCount > 0, - groups = selectionState.groups, - breadcrumbs = selectionState.breadcrumbs, - selectedKeyMapsEnabled = selectionState.selectedKeyMapsEnabled, - onEnabledKeyMapsChange = viewModel::onEnabledKeyMapsChange, - onDuplicateClick = viewModel::onDuplicateSelectedKeyMapsClick, - onExportClick = viewModel::onExportSelectedKeyMaps, - onDeleteClick = { showDeleteDialog = true }, - onGroupClick = viewModel::onSelectionGroupClick, - onNewGroupClick = viewModel::onNewGroupClick, - showThisGroup = selectionState.showThisGroup, - onThisGroupClick = viewModel::onMoveToThisGroupClick, - ) - } - }, - ) - } + } + }, + ) } @Composable @@ -381,7 +363,9 @@ private fun sampleList(): List { actions = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Open Key Mapper", ), ComposeChipModel.Error( @@ -404,7 +388,9 @@ private fun sampleList(): List { constraints = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Key Mapper is not open", ), ComposeChipModel.Error( @@ -427,7 +413,9 @@ private fun sampleList(): List { actions = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Open Key Mapper", ), ), @@ -435,7 +423,9 @@ private fun sampleList(): List { constraints = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Key Mapper is not open", ), ), @@ -456,7 +446,9 @@ private fun sampleList(): List { actions = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Open Key Mapper", ), ), @@ -464,7 +456,9 @@ private fun sampleList(): List { constraints = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Key Mapper is not open", ), ), @@ -482,7 +476,9 @@ private fun sampleList(): List { actions = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Open Key Mapper", ), ), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapAppBarState.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapAppBarState.kt similarity index 86% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapAppBarState.kt rename to base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapAppBarState.kt index d2b56debf0..fd1c2d53f6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapAppBarState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapAppBarState.kt @@ -1,9 +1,7 @@ -package io.github.sds100.keymapper.base.keymaps +package io.github.sds100.keymapper.base.home import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.groups.GroupListItemModel -import io.github.sds100.keymapper.base.home.HomeWarningListItem -import io.github.sds100.keymapper.base.home.SelectedKeyMapsEnabled import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel sealed class KeyMapAppBarState { @@ -22,6 +20,10 @@ sealed class KeyMapAppBarState { val breadcrumbs: List, val isEditingGroupName: Boolean, val isNewGroup: Boolean, + /** + * If it is null then the Switch should be disabled. + */ + val keyMapsEnabled: SelectedKeyMapsEnabled?, ) : KeyMapAppBarState() data class Selecting( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapGroup.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapGroup.kt similarity index 72% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapGroup.kt rename to base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapGroup.kt index 8ddf927570..79d33b8888 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapGroup.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapGroup.kt @@ -1,6 +1,7 @@ -package io.github.sds100.keymapper.base.keymaps +package io.github.sds100.keymapper.base.home import io.github.sds100.keymapper.base.groups.Group +import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.common.utils.State data class KeyMapGroup( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt index b85c8e1c4e..4e047294e4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -60,11 +61,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -99,7 +102,6 @@ import io.github.sds100.keymapper.base.groups.GroupBreadcrumbRow import io.github.sds100.keymapper.base.groups.GroupConstraintRow import io.github.sds100.keymapper.base.groups.GroupListItemModel import io.github.sds100.keymapper.base.groups.GroupRow -import io.github.sds100.keymapper.base.keymaps.KeyMapAppBarState import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText @@ -135,6 +137,7 @@ fun KeyMapListAppBar( onRemoveConstraintClick: (String) -> Unit = {}, onConstraintModeChanged: (ConstraintMode) -> Unit = {}, onFixConstraintClick: (KMError) -> Unit = {}, + onKeyMapsEnabledChange: (Boolean) -> Unit = {}, ) { BackHandler(onBack = onBackClick) @@ -279,6 +282,8 @@ fun KeyMapListAppBar( onRemoveConstraintClick = onRemoveConstraintClick, onConstraintModeChanged = onConstraintModeChanged, onFixConstraintClick = onFixConstraintClick, + keyMapsEnabled = state.keyMapsEnabled, + onKeyMapsEnabledChange = onKeyMapsEnabledChange, actions = { AnimatedVisibility(!state.isEditingGroupName) { var expandedDropdown by rememberSaveable { mutableStateOf(false) } @@ -384,7 +389,7 @@ private fun RootGroupAppBar( Surface(color = appBarContainerColor) { HomeWarningList( modifier = Modifier.padding(bottom = 8.dp), - warnings = (state as? KeyMapAppBarState.RootGroup)?.warnings ?: emptyList(), + warnings = state.warnings, onFixClick = onFixWarningClick, ) } @@ -427,6 +432,8 @@ private fun ChildGroupAppBar( onRemoveConstraintClick: (String) -> Unit = {}, onConstraintModeChanged: (ConstraintMode) -> Unit = {}, onFixConstraintClick: (KMError) -> Unit = {}, + keyMapsEnabled: SelectedKeyMapsEnabled?, + onKeyMapsEnabledChange: (Boolean) -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, ) { // Make custom top app bar because the height can not be set to fix the text field error in. @@ -484,29 +491,65 @@ private fun ChildGroupAppBar( Spacer(Modifier.height(8.dp)) - androidx.compose.animation.AnimatedVisibility( - modifier = Modifier.align(Alignment.End), - visible = constraints.size > 1, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, ) { - Row { - RadioButtonText( - text = stringResource(R.string.constraint_mode_and), - isSelected = constraintMode == ConstraintMode.AND, - isEnabled = !isEditingGroupName, - onSelected = { - onConstraintModeChanged(ConstraintMode.AND) - }, - ) + androidx.compose.animation.AnimatedVisibility( + visible = constraints.size > 1, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButtonText( + text = stringResource(R.string.constraint_mode_and), + isSelected = constraintMode == ConstraintMode.AND, + isEnabled = !isEditingGroupName, + onSelected = { + onConstraintModeChanged(ConstraintMode.AND) + }, + ) + + RadioButtonText( + text = stringResource(R.string.constraint_mode_or), + isSelected = constraintMode == ConstraintMode.OR, + isEnabled = !isEditingGroupName, + onSelected = { + onConstraintModeChanged(ConstraintMode.OR) + }, + ) - RadioButtonText( - text = stringResource(R.string.constraint_mode_or), - isSelected = constraintMode == ConstraintMode.OR, - isEnabled = !isEditingGroupName, - onSelected = { - onConstraintModeChanged(ConstraintMode.OR) - }, + VerticalDivider( + modifier = Modifier.height(24.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Spacer(Modifier.width(16.dp)) + + val text = when (keyMapsEnabled) { + SelectedKeyMapsEnabled.ALL -> stringResource( + R.string.home_enabled_key_maps_enabled, + ) + SelectedKeyMapsEnabled.MIXED -> stringResource( + R.string.home_enabled_key_maps_mixed, + ) + SelectedKeyMapsEnabled.NONE, null -> stringResource( + R.string.home_enabled_key_maps_disabled, ) } + + Switch( + checked = keyMapsEnabled == SelectedKeyMapsEnabled.ALL, + onCheckedChange = onKeyMapsEnabledChange, + enabled = keyMapsEnabled != null, + ) + + Spacer(Modifier.width(16.dp)) + + Text(text = text, style = MaterialTheme.typography.bodyMedium) + + Spacer(Modifier.width(16.dp)) } } } @@ -639,7 +682,9 @@ private fun GroupNameRow( ), value = value, onValueChange = onValueChange, - textStyle = MaterialTheme.typography.titleLarge.copy(color = LocalContentColor.current), + textStyle = MaterialTheme.typography.titleLarge.copy( + color = LocalContentColor.current, + ), enabled = isEditing, keyboardActions = KeyboardActions(onDone = { onRenameClick() }), keyboardOptions = KeyboardOptions( @@ -767,7 +812,8 @@ private fun AppBarStatus( } val transition = - slideInVertically { height -> -height } + fadeIn() togetherWith slideOutVertically { height -> height } + fadeOut() + slideInVertically { height -> -height } + fadeIn() togetherWith + slideOutVertically { height -> height } + fadeOut() AnimatedContent(targetState = buttonIcon, transitionSpec = { transition }) { icon -> Icon(icon, contentDescription = null) @@ -806,10 +852,7 @@ private fun SelectedText(modifier: Modifier = Modifier, selectionCount: Int) { } } -private fun selectedTextTransition( - targetState: Int, - initialState: Int, -): ContentTransform { +private fun selectedTextTransition(targetState: Int, initialState: Int): ContentTransform { return slideInVertically { height -> if (targetState > initialState) { -height @@ -951,7 +994,7 @@ private fun groupSampleList(): List { } @OptIn(ExperimentalMaterial3Api::class) -@Preview(showSystemUi = true) +@Preview @Composable private fun KeyMapsChildGroupPreview() { val state = KeyMapAppBarState.ChildGroup( @@ -963,6 +1006,7 @@ private fun KeyMapsChildGroupPreview() { breadcrumbs = groupSampleList(), isEditingGroupName = false, isNewGroup = false, + keyMapsEnabled = SelectedKeyMapsEnabled.ALL, ) KeyMapperTheme { KeyMapListAppBar(modifier = Modifier.fillMaxWidth(), state = state) @@ -970,7 +1014,7 @@ private fun KeyMapsChildGroupPreview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview(showSystemUi = true) +@Preview @Composable private fun KeyMapsChildGroupDarkPreview() { val state = KeyMapAppBarState.ChildGroup( @@ -982,6 +1026,7 @@ private fun KeyMapsChildGroupDarkPreview() { breadcrumbs = emptyList(), isEditingGroupName = false, isNewGroup = false, + keyMapsEnabled = SelectedKeyMapsEnabled.MIXED, ) KeyMapperTheme(darkTheme = true) { KeyMapListAppBar(modifier = Modifier.fillMaxWidth(), state = state) @@ -989,7 +1034,7 @@ private fun KeyMapsChildGroupDarkPreview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview(showSystemUi = true) +@Preview @Composable private fun KeyMapsChildGroupEditingPreview() { val focusRequester = FocusRequester() @@ -1009,12 +1054,13 @@ private fun KeyMapsChildGroupEditingPreview() { constraints = emptyList(), constraintMode = ConstraintMode.AND, parentConstraintCount = 1, + keyMapsEnabled = SelectedKeyMapsEnabled.NONE, ) } } @OptIn(ExperimentalMaterial3Api::class) -@Preview(showSystemUi = true) +@Preview @Composable private fun KeyMapsChildGroupEditingDarkPreview() { val state = KeyMapAppBarState.ChildGroup( @@ -1026,6 +1072,7 @@ private fun KeyMapsChildGroupEditingDarkPreview() { breadcrumbs = emptyList(), isEditingGroupName = true, isNewGroup = true, + keyMapsEnabled = SelectedKeyMapsEnabled.ALL, ) val focusRequester = FocusRequester() @@ -1041,7 +1088,7 @@ private fun KeyMapsChildGroupEditingDarkPreview() { } } -@Preview(showSystemUi = true) +@Preview @Composable private fun KeyMapsChildGroupErrorPreview() { val focusRequester = FocusRequester() @@ -1061,6 +1108,7 @@ private fun KeyMapsChildGroupErrorPreview() { constraints = emptyList(), constraintMode = ConstraintMode.AND, parentConstraintCount = 0, + keyMapsEnabled = null, ) } } @@ -1146,7 +1194,7 @@ private fun HomeStateWarningsDarkPreview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview(showSystemUi = true) +@Preview @Composable private fun HomeStateSelectingPreview() { val state = KeyMapAppBarState.Selecting( @@ -1163,7 +1211,7 @@ private fun HomeStateSelectingPreview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview(showSystemUi = true) +@Preview @Composable private fun HomeStateSelectingDisabledPreview() { val state = KeyMapAppBarState.Selecting( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapListItemCreator.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt similarity index 81% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapListItemCreator.kt rename to base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt index 31fceffd98..2083624e6e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapListItemCreator.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.base.keymaps +package io.github.sds100.keymapper.base.home import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowForward @@ -9,24 +9,28 @@ import io.github.sds100.keymapper.base.actions.ActionUiHelper import io.github.sds100.keymapper.base.constraints.ConstraintErrorSnapshot import io.github.sds100.keymapper.base.constraints.ConstraintState import io.github.sds100.keymapper.base.constraints.ConstraintUiHelper +import io.github.sds100.keymapper.base.keymaps.ClickType +import io.github.sds100.keymapper.base.keymaps.DisplayKeyMapUseCase +import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType import io.github.sds100.keymapper.base.trigger.AssistantTriggerKey import io.github.sds100.keymapper.base.trigger.AssistantTriggerType +import io.github.sds100.keymapper.base.trigger.EvdevTriggerKey import io.github.sds100.keymapper.base.trigger.FingerprintTriggerKey import io.github.sds100.keymapper.base.trigger.FloatingButtonKey -import io.github.sds100.keymapper.base.trigger.KeyEventDetectionSource +import io.github.sds100.keymapper.base.trigger.KeyEventTriggerDevice +import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel import io.github.sds100.keymapper.base.trigger.Trigger import io.github.sds100.keymapper.base.trigger.TriggerErrorSnapshot -import io.github.sds100.keymapper.base.trigger.TriggerKeyDevice import io.github.sds100.keymapper.base.trigger.TriggerMode -import io.github.sds100.keymapper.base.utils.InputEventStrings +import io.github.sds100.keymapper.base.trigger.getCodeLabel import io.github.sds100.keymapper.base.utils.isFixable import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.common.utils.InputDeviceUtils import io.github.sds100.keymapper.common.utils.KMError -import io.github.sds100.keymapper.system.devices.InputDeviceUtils class KeyMapListItemCreator( private val displayMapping: DisplayKeyMapUseCase, @@ -56,12 +60,14 @@ class KeyMapListItemCreator( val triggerKeys = keyMap.trigger.keys.map { key -> when (key) { is AssistantTriggerKey -> assistantTriggerKeyName(key) - is io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey -> keyCodeTriggerKeyName( + is KeyEventTriggerKey -> keyEventTriggerKeyName( key, showDeviceDescriptors, ) + is FloatingButtonKey -> floatingButtonKeyName(key) is FingerprintTriggerKey -> fingerprintKeyName(key) + is EvdevTriggerKey -> evdevTriggerKeyName(key) } } @@ -135,7 +141,9 @@ class KeyMapListItemCreator( append(label) } - if (keyMap.isDelayBeforeNextActionAllowed() && action.delayBeforeNextAction != null) { + if (keyMap.isDelayBeforeNextActionAllowed() && + action.delayBeforeNextAction != null + ) { if (this@buildString.isNotBlank()) { append(" $midDot ") } @@ -243,8 +251,8 @@ class KeyMapListItemCreator( } } - private fun keyCodeTriggerKeyName( - key: io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey, + private fun keyEventTriggerKeyName( + key: KeyEventTriggerKey, showDeviceDescriptors: Boolean, ): String = buildString { when (key.clickType) { @@ -253,12 +261,12 @@ class KeyMapListItemCreator( else -> Unit } - append(InputEventStrings.keyCodeToString(key.keyCode)) + append(key.getCodeLabel(this@KeyMapListItemCreator)) val deviceName = when (key.device) { - is TriggerKeyDevice.Internal -> null - is TriggerKeyDevice.Any -> getString(R.string.any_device) - is TriggerKeyDevice.External -> { + is KeyEventTriggerDevice.Internal -> null + is KeyEventTriggerDevice.Any -> getString(R.string.any_device) + is KeyEventTriggerDevice.External -> { if (showDeviceDescriptors) { InputDeviceUtils.appendDeviceDescriptorToName( key.device.descriptor, @@ -272,8 +280,8 @@ class KeyMapListItemCreator( val parts = mutableListOf() - if (deviceName != null || key.detectionSource == KeyEventDetectionSource.INPUT_METHOD || !key.consumeEvent) { - if (key.detectionSource == KeyEventDetectionSource.INPUT_METHOD) { + if (deviceName != null || key.requiresIme || !key.consumeEvent) { + if (key.requiresIme) { parts.add(getString(R.string.flag_detect_from_input_method)) } @@ -293,6 +301,31 @@ class KeyMapListItemCreator( } } + private fun evdevTriggerKeyName(key: EvdevTriggerKey): String = buildString { + when (key.clickType) { + ClickType.LONG_PRESS -> append(longPressString).append(" ") + ClickType.DOUBLE_PRESS -> append(doublePressString).append(" ") + else -> Unit + } + + append(key.getCodeLabel(this@KeyMapListItemCreator)) + + val parts = buildList { + add("PRO") + add(key.device.name) + + if (!key.consumeEvent) { + add(getString(R.string.flag_dont_override_default_action)) + } + } + + if (parts.isNotEmpty()) { + append(" (") + append(parts.joinToString(separator = " $midDot ")) + append(")") + } + } + private fun assistantTriggerKeyName(key: AssistantTriggerKey): String = buildString { when (key.clickType) { ClickType.DOUBLE_PRESS -> append(doublePressString).append(" ") @@ -313,10 +346,18 @@ class KeyMapListItemCreator( } when (key.type) { - FingerprintGestureType.SWIPE_DOWN -> append(getString(R.string.trigger_key_fingerprint_gesture_down)) - FingerprintGestureType.SWIPE_UP -> append(getString(R.string.trigger_key_fingerprint_gesture_up)) - FingerprintGestureType.SWIPE_LEFT -> append(getString(R.string.trigger_key_fingerprint_gesture_left)) - FingerprintGestureType.SWIPE_RIGHT -> append(getString(R.string.trigger_key_fingerprint_gesture_right)) + FingerprintGestureType.SWIPE_DOWN -> append( + getString(R.string.trigger_key_fingerprint_gesture_down), + ) + FingerprintGestureType.SWIPE_UP -> append( + getString(R.string.trigger_key_fingerprint_gesture_up), + ) + FingerprintGestureType.SWIPE_LEFT -> append( + getString(R.string.trigger_key_fingerprint_gesture_left), + ) + FingerprintGestureType.SWIPE_RIGHT -> append( + getString(R.string.trigger_key_fingerprint_gesture_right), + ) } } @@ -331,10 +372,6 @@ class KeyMapListItemCreator( labels.add(getString(R.string.flag_long_press_double_vibration)) } - if (trigger.isDetectingWhenScreenOffAllowed() && trigger.screenOffTrigger) { - labels.add(getString(R.string.flag_detect_triggers_screen_off)) - } - if (trigger.triggerFromOtherApps) { labels.add(getString(R.string.flag_trigger_from_other_apps)) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapListScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt similarity index 90% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapListScreen.kt rename to base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt index c18452592d..9d8a47a83d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapListScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.base.keymaps +package io.github.sds100.keymapper.base.home import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -128,7 +128,9 @@ private fun EmptyKeyMapList(modifier: Modifier = Modifier) { val shrug = stringResource(R.string.shrug) val text = stringResource(R.string.home_key_map_list_empty) Text( - modifier = Modifier.align(Alignment.Center), + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 48.dp), text = buildAnnotatedString { withStyle(MaterialTheme.typography.headlineLarge.toSpanStyle()) { append(shrug) @@ -411,10 +413,7 @@ private fun TriggerDescription( } @Composable -private fun OptionsDescription( - modifier: Modifier = Modifier, - options: List, -) { +private fun OptionsDescription(modifier: Modifier = Modifier, options: List) { val dot = stringResource(R.string.middot) val text = buildAnnotatedString { pushStyle( @@ -441,10 +440,7 @@ private fun OptionsDescription( } @Composable -private fun ActionConstraintChip( - model: ComposeChipModel, - onFixClick: (KMError) -> Unit, -) { +private fun ActionConstraintChip(model: ComposeChipModel, onFixClick: (KMError) -> Unit) { when (model) { is ComposeChipModel.Normal -> { CompactChip( @@ -483,13 +479,33 @@ private fun ActionConstraintChip( private fun getTriggerErrorMessage(error: TriggerError): String { return when (error) { TriggerError.DND_ACCESS_DENIED -> stringResource(R.string.trigger_error_dnd_access_denied) - TriggerError.SCREEN_OFF_ROOT_DENIED -> stringResource(R.string.trigger_error_screen_off_root_permission_denied) - TriggerError.CANT_DETECT_IN_PHONE_CALL -> stringResource(R.string.trigger_error_cant_detect_in_phone_call) - TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> stringResource(R.string.trigger_error_assistant_not_purchased) - TriggerError.DPAD_IME_NOT_SELECTED -> stringResource(R.string.trigger_error_dpad_ime_not_selected) - TriggerError.FLOATING_BUTTON_DELETED -> stringResource(R.string.trigger_error_floating_button_deleted) - TriggerError.FLOATING_BUTTONS_NOT_PURCHASED -> stringResource(R.string.trigger_error_floating_buttons_not_purchased) - TriggerError.PURCHASE_VERIFICATION_FAILED -> stringResource(R.string.trigger_error_product_verification_failed) + TriggerError.CANT_DETECT_IN_PHONE_CALL -> stringResource( + R.string.trigger_error_cant_detect_in_phone_call, + ) + TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> stringResource( + R.string.trigger_error_assistant_not_purchased, + ) + TriggerError.DPAD_IME_NOT_SELECTED -> stringResource( + R.string.trigger_error_dpad_ime_not_selected, + ) + TriggerError.FLOATING_BUTTON_DELETED -> stringResource( + R.string.trigger_error_floating_button_deleted, + ) + TriggerError.FLOATING_BUTTONS_NOT_PURCHASED -> stringResource( + R.string.trigger_error_floating_buttons_not_purchased, + ) + TriggerError.PURCHASE_VERIFICATION_FAILED -> stringResource( + R.string.trigger_error_product_verification_failed, + ) + TriggerError.SYSTEM_BRIDGE_UNSUPPORTED -> stringResource( + R.string.trigger_error_system_bridge_unsupported, + ) + TriggerError.SYSTEM_BRIDGE_DISCONNECTED -> stringResource( + R.string.trigger_error_system_bridge_disconnected, + ) + TriggerError.EVDEV_DEVICE_NOT_FOUND -> stringResource( + R.string.trigger_error_evdev_device_not_found, + ) } } @@ -507,7 +523,9 @@ private fun sampleList(): List { actions = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Open Key Mapper", ), ComposeChipModel.Error( @@ -530,7 +548,9 @@ private fun sampleList(): List { constraints = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Key Mapper is not open", ), ComposeChipModel.Error( @@ -553,7 +573,9 @@ private fun sampleList(): List { actions = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Open Key Mapper", ), ), @@ -561,7 +583,9 @@ private fun sampleList(): List { constraints = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Key Mapper is not open", ), ), @@ -582,7 +606,9 @@ private fun sampleList(): List { actions = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Open Key Mapper", ), ), @@ -590,7 +616,9 @@ private fun sampleList(): List { constraints = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Key Mapper is not open", ), ), @@ -608,7 +636,9 @@ private fun sampleList(): List { actions = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Open Key Mapper", ), ), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapListState.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListState.kt similarity index 71% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapListState.kt rename to base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListState.kt index 16189cadba..4f2fbc0fbc 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapListState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListState.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.base.keymaps +package io.github.sds100.keymapper.base.home import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel import io.github.sds100.keymapper.common.utils.State @@ -6,5 +6,5 @@ import io.github.sds100.keymapper.common.utils.State data class KeyMapListState( val appBarState: KeyMapAppBarState, val listItems: State>, - val showCreateKeyMapTapTarget: Boolean = false, + val showCreateKeyMapTapTarget: Boolean, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapListViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt similarity index 87% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapListViewModel.kt rename to base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt index 90c49ecea1..111593b800 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapListViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt @@ -1,10 +1,11 @@ -package io.github.sds100.keymapper.base.keymaps +package io.github.sds100.keymapper.base.home import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.ActionErrorSnapshot +import io.github.sds100.keymapper.base.actions.keyevent.FixKeyEventActionDelegate import io.github.sds100.keymapper.base.backup.BackupRestoreMappingsUseCase import io.github.sds100.keymapper.base.backup.ImportExportState import io.github.sds100.keymapper.base.backup.RestoreType @@ -14,17 +15,15 @@ import io.github.sds100.keymapper.base.constraints.ConstraintUiHelper import io.github.sds100.keymapper.base.groups.Group import io.github.sds100.keymapper.base.groups.GroupFamily import io.github.sds100.keymapper.base.groups.GroupListItemModel -import io.github.sds100.keymapper.base.home.HomeWarningListItem -import io.github.sds100.keymapper.base.home.SelectedKeyMapsEnabled -import io.github.sds100.keymapper.base.home.ShowHomeScreenAlertsUseCase +import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase import io.github.sds100.keymapper.base.onboarding.OnboardingTapTarget import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate import io.github.sds100.keymapper.base.sorting.SortKeyMapsUseCase import io.github.sds100.keymapper.base.sorting.SortViewModel import io.github.sds100.keymapper.base.system.inputmethod.ShowInputMethodPickerUseCase import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel -import io.github.sds100.keymapper.base.trigger.SetupGuiKeyboardState -import io.github.sds100.keymapper.base.trigger.SetupGuiKeyboardUseCase import io.github.sds100.keymapper.base.trigger.TriggerError import io.github.sds100.keymapper.base.trigger.TriggerErrorSnapshot import io.github.sds100.keymapper.base.utils.getFullMessage @@ -40,6 +39,7 @@ import io.github.sds100.keymapper.base.utils.ui.SelectionState import io.github.sds100.keymapper.base.utils.ui.ViewModelHelper import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.common.utils.AccessibilityServiceError import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State @@ -77,18 +77,21 @@ class KeyMapListViewModel( private val coroutineScope: CoroutineScope, private val listKeyMaps: ListKeyMapsUseCase, resourceProvider: ResourceProvider, - private val setupGuiKeyboard: SetupGuiKeyboardUseCase, private val sortKeyMaps: SortKeyMapsUseCase, private val showAlertsUseCase: ShowHomeScreenAlertsUseCase, private val pauseKeyMaps: PauseKeyMapsUseCase, private val backupRestore: BackupRestoreMappingsUseCase, private val showInputMethodPickerUseCase: ShowInputMethodPickerUseCase, private val onboarding: OnboardingUseCase, - private val navigationProvider: NavigationProvider, - private val dialogProvider: DialogProvider, + setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, + fixKeyEventActionDelegate: FixKeyEventActionDelegate, + navigationProvider: NavigationProvider, + dialogProvider: DialogProvider, ) : DialogProvider by dialogProvider, ResourceProvider by resourceProvider, - NavigationProvider by navigationProvider { + NavigationProvider by navigationProvider, + FixKeyEventActionDelegate by fixKeyEventActionDelegate, + SetupAccessibilityServiceDelegate by setupAccessibilityServiceDelegate { private companion object { const val ID_ACCESSIBILITY_SERVICE_DISABLED_LIST_ITEM = "accessibility_service_disabled" @@ -125,20 +128,6 @@ class KeyMapListViewModel( var showFabText: Boolean by mutableStateOf(true) - val setupGuiKeyboardState: StateFlow = combine( - setupGuiKeyboard.isInstalled, - setupGuiKeyboard.isEnabled, - setupGuiKeyboard.isChosen, - ) { isInstalled, isEnabled, isChosen -> - SetupGuiKeyboardState( - isInstalled, - isEnabled, - isChosen, - ) - }.stateIn(coroutineScope, SharingStarted.Lazily, SetupGuiKeyboardState.DEFAULT) - - var showDpadTriggerSetupBottomSheet: Boolean by mutableStateOf(false) - private val keyMapGroupStateFlow = listKeyMaps.keyMapGroup.stateIn( coroutineScope, SharingStarted.Eagerly, @@ -157,11 +146,17 @@ class KeyMapListViewModel( private val warnings: Flow> = combine( showAlertsUseCase.isBatteryOptimised, - showAlertsUseCase.accessibilityServiceState, + accessibilityServiceState, showAlertsUseCase.hideAlerts, showAlertsUseCase.isLoggingEnabled, showAlertsUseCase.showNotificationPermissionAlert, - ) { isBatteryOptimised, serviceState, isHidden, isLoggingEnabled, showNotificationPermissionAlert -> + ) { + isBatteryOptimised, + serviceState, + isHidden, + isLoggingEnabled, + showNotificationPermissionAlert, + -> if (isHidden) { return@combine emptyList() } @@ -369,27 +364,10 @@ class KeyMapListViewModel( breadcrumbListItems: List, showThisGroup: Boolean, ): KeyMapAppBarState.Selecting { - var selectedKeyMapsEnabled: SelectedKeyMapsEnabled? = null val keyMaps = keyMapGroup.keyMaps.dataOrNull() ?: emptyList() - for (keyMap in keyMaps) { - if (keyMap.uid in selectionState.selectedIds) { - if (selectedKeyMapsEnabled == null) { - selectedKeyMapsEnabled = if (keyMap.isEnabled) { - SelectedKeyMapsEnabled.ALL - } else { - SelectedKeyMapsEnabled.NONE - } - } else { - if ((keyMap.isEnabled && selectedKeyMapsEnabled == SelectedKeyMapsEnabled.NONE) || - (!keyMap.isEnabled && selectedKeyMapsEnabled == SelectedKeyMapsEnabled.ALL) - ) { - selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED - break - } - } - } - } + val selectedKeyMapsEnabled: SelectedKeyMapsEnabled? = + getKeyMapSelectedState(keyMaps.filter { it.uid in selectionState.selectedIds }) return KeyMapAppBarState.Selecting( selectionCount = selectionState.selectedIds.size, @@ -401,6 +379,29 @@ class KeyMapListViewModel( ) } + private fun getKeyMapSelectedState(keyMaps: List): SelectedKeyMapsEnabled? { + var selectedKeyMapsEnabled: SelectedKeyMapsEnabled? = null + + for (keyMap in keyMaps) { + if (selectedKeyMapsEnabled == null) { + selectedKeyMapsEnabled = if (keyMap.isEnabled) { + SelectedKeyMapsEnabled.ALL + } else { + SelectedKeyMapsEnabled.NONE + } + } else { + if ((keyMap.isEnabled && selectedKeyMapsEnabled == SelectedKeyMapsEnabled.NONE) || + (!keyMap.isEnabled && selectedKeyMapsEnabled == SelectedKeyMapsEnabled.ALL) + ) { + selectedKeyMapsEnabled = SelectedKeyMapsEnabled.MIXED + break + } + } + } + + return selectedKeyMapsEnabled + } + private fun buildGroupAppBarState( keyMapGroup: KeyMapGroup, warnings: List, @@ -427,6 +428,9 @@ class KeyMapListViewModel( isPaused = isPaused, ) } else { + val selectedKeyMapsEnabled: SelectedKeyMapsEnabled? = + getKeyMapSelectedState(keyMapGroup.keyMaps.dataOrNull() ?: emptyList()) + return KeyMapAppBarState.ChildGroup( groupName = keyMapGroup.group.name, constraints = listItemCreator.buildConstraintChipList( @@ -434,11 +438,14 @@ class KeyMapListViewModel( constraintErrorSnapshot, ), constraintMode = keyMapGroup.group.constraintState.mode, - parentConstraintCount = keyMapGroup.parents.sumOf { it.constraintState.constraints.size }, + parentConstraintCount = keyMapGroup.parents.sumOf { + it.constraintState.constraints.size + }, subGroups = subGroupListItems, breadcrumbs = breadcrumbs, isEditingGroupName = isEditingGroupName, isNewGroup = isNewGroup, + keyMapsEnabled = selectedKeyMapsEnabled, ) } } @@ -525,16 +532,21 @@ class KeyMapListViewModel( ) } - TriggerError.DPAD_IME_NOT_SELECTED -> { - showDpadTriggerSetupBottomSheet = true - } + TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED, + TriggerError.FLOATING_BUTTONS_NOT_PURCHASED, + -> { + val result = navigate( + "purchase_advanced_trigger", + NavDestination.AdvancedTriggers, + ) ?: return@launch + + val groupUid = listKeyMaps.keyMapGroup.first().group?.uid - TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED, TriggerError.FLOATING_BUTTONS_NOT_PURCHASED -> { navigate( - "purchase_advanced_trigger", + "use_advanced_trigger", NavDestination.NewKeyMap( - groupUid = null, - showAdvancedTriggers = true, + groupUid = groupUid, + triggerSetupShortcut = result, ), ) } @@ -558,6 +570,8 @@ class KeyMapListViewModel( ) } + is KMError.KeyEventActionError -> showFixKeyEventActionBottomSheet() + else -> { ViewModelHelper.showFixErrorDialog( resourceProvider = this@KeyMapListViewModel, @@ -571,20 +585,6 @@ class KeyMapListViewModel( } } - fun onEnableGuiKeyboardClick() { - coroutineScope.launch { - setupGuiKeyboard.enableInputMethod() - } - } - - fun onChooseGuiKeyboardClick() { - setupGuiKeyboard.chooseInputMethod() - } - - fun onNeverShowSetupDpadClick() { - listKeyMaps.neverShowDpadImeSetupError() - } - fun onSelectAllClick() { state.value.also { state -> if (state.appBarState is KeyMapAppBarState.Selecting) { @@ -675,35 +675,20 @@ class KeyMapListViewModel( coroutineScope.launch { when (id) { ID_ACCESSIBILITY_SERVICE_DISABLED_LIST_ITEM -> { - val explanationResponse = - ViewModelHelper.showAccessibilityServiceExplanationDialog( - resourceProvider = this@KeyMapListViewModel, - dialogProvider = this@KeyMapListViewModel, - ) - - if (explanationResponse != DialogResponse.POSITIVE) { - return@launch - } - - if (!showAlertsUseCase.startAccessibilityService()) { - ViewModelHelper.handleCantFindAccessibilitySettings( - resourceProvider = this@KeyMapListViewModel, - dialogProvider = this@KeyMapListViewModel, - ) - } + showEnableAccessibilityServiceDialog() } ID_ACCESSIBILITY_SERVICE_CRASHED_LIST_ITEM -> - ViewModelHelper.handleKeyMapperCrashedDialog( - resourceProvider = this@KeyMapListViewModel, - dialogProvider = this@KeyMapListViewModel, - restartService = showAlertsUseCase::restartAccessibilityService, - ignoreCrashed = showAlertsUseCase::acknowledgeCrashed, - ) + showFixAccessibilityServiceDialog(AccessibilityServiceError.Crashed) + + ID_BATTERY_OPTIMISATION_LIST_ITEM -> + showAlertsUseCase.disableBatteryOptimisation() - ID_BATTERY_OPTIMISATION_LIST_ITEM -> showAlertsUseCase.disableBatteryOptimisation() - ID_LOGGING_ENABLED_LIST_ITEM -> showAlertsUseCase.disableLogging() - ID_NOTIFICATION_PERMISSION_DENIED_LIST_ITEM -> showNotificationPermissionAlertDialog() + ID_LOGGING_ENABLED_LIST_ITEM -> + showAlertsUseCase.disableLogging() + + ID_NOTIFICATION_PERMISSION_DENIED_LIST_ITEM -> + showNotificationPermissionAlertDialog() } } } @@ -884,7 +869,17 @@ class KeyMapListViewModel( } } + fun onGroupKeyMapsEnabledChanged(enabled: Boolean) { + if (enabled) { + listKeyMaps.enableGroupKeyMaps() + } else { + listKeyMaps.disableGroupKeyMaps() + } + } + fun onNewKeyMapClick() { + onboarding.completedTapTarget(OnboardingTapTarget.CREATE_KEY_MAP) + coroutineScope.launch { val groupUid = listKeyMaps.keyMapGroup.first().group?.uid @@ -920,12 +915,4 @@ class KeyMapListViewModel( } } } - - fun onTapTargetsCompleted() { - onboarding.completedTapTarget(OnboardingTapTarget.CREATE_KEY_MAP) - } - - fun onSkipTapTargetClick() { - onboarding.skipTapTargetOnboarding() - } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ListKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt similarity index 92% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/ListKeyMapsUseCase.kt rename to base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt index d1dc791061..684c8a6e81 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ListKeyMapsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.base.keymaps +package io.github.sds100.keymapper.base.home import android.database.sqlite.SQLiteConstraintException import io.github.sds100.keymapper.base.R @@ -6,12 +6,16 @@ import io.github.sds100.keymapper.base.backup.BackupManager import io.github.sds100.keymapper.base.backup.BackupManagerImpl import io.github.sds100.keymapper.base.backup.BackupUtils import io.github.sds100.keymapper.base.constraints.Constraint +import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.constraints.ConstraintEntityMapper import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.constraints.ConstraintModeEntityMapper import io.github.sds100.keymapper.base.groups.Group import io.github.sds100.keymapper.base.groups.GroupEntityMapper import io.github.sds100.keymapper.base.groups.GroupFamily +import io.github.sds100.keymapper.base.keymaps.DisplayKeyMapUseCase +import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.base.keymaps.KeyMapEntityMapper import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State @@ -23,6 +27,8 @@ import io.github.sds100.keymapper.data.repositories.GroupRepository import io.github.sds100.keymapper.data.repositories.KeyMapRepository import io.github.sds100.keymapper.data.repositories.RepositoryUtils import io.github.sds100.keymapper.system.files.FileAdapter +import java.util.LinkedList +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -36,8 +42,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext -import java.util.LinkedList -import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) class ListKeyMapsUseCaseImpl @Inject constructor( @@ -205,7 +209,11 @@ class ListKeyMapsUseCaseImpl @Inject constructor( return RepositoryUtils.saveUniqueName( entity = group, saveBlock = { renamedGroup -> - if (siblings.any { sibling -> sibling.uid != group.uid && sibling.name == renamedGroup.name }) { + if (siblings.any { sibling -> + sibling.uid != group.uid && + sibling.name == renamedGroup.name + } + ) { throw IllegalStateException("Non unique group name") } }, @@ -242,8 +250,9 @@ class ListKeyMapsUseCaseImpl @Inject constructor( } } - override suspend fun addGroupConstraint(constraint: Constraint) { + override suspend fun addGroupConstraint(constraintData: ConstraintData) { keyMapListGroupUid.value?.also { groupUid -> + val constraint = Constraint(data = constraintData) val constraintEntity = ConstraintEntityMapper.toEntity(constraint) var groupEntity = groupRepository.getGroup(groupUid) ?: return @@ -301,6 +310,18 @@ class ListKeyMapsUseCaseImpl @Inject constructor( keyMapRepository.moveToGroup(selectionGroupUid.value, *keyMapUids) } + override fun enableGroupKeyMaps() { + keyMapListGroupUid.value?.also { groupUid -> + keyMapRepository.enableByGroup(groupUid) + } + } + + override fun disableGroupKeyMaps() { + keyMapListGroupUid.value?.also { groupUid -> + keyMapRepository.disableByGroup(groupUid) + } + } + private fun getKeyMapsByGroup(groupUid: String?): Flow>> = channelFlow { send(State.Loading) @@ -365,10 +386,12 @@ interface ListKeyMapsUseCase : DisplayKeyMapUseCase { suspend fun popGroup() suspend fun deleteGroup() suspend fun renameGroup(name: String): Boolean - suspend fun addGroupConstraint(constraint: Constraint) + suspend fun addGroupConstraint(constraintData: ConstraintData) suspend fun removeGroupConstraint(constraintUid: String) suspend fun setGroupConstraintMode(mode: ConstraintMode) fun getGroups(parentUid: String?): Flow> + fun enableGroupKeyMaps() + fun disableGroupKeyMaps() val selectionGroupFamily: Flow suspend fun openSelectionGroup(uid: String?) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/ShowHomeScreenAlertsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/ShowHomeScreenAlertsUseCase.kt index 91566c5df7..d37f979aef 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/ShowHomeScreenAlertsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/ShowHomeScreenAlertsUseCase.kt @@ -4,13 +4,12 @@ import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter -import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map -import javax.inject.Inject class ShowHomeScreenAlertsUseCaseImpl @Inject constructor( private val preferences: PreferenceRepository, @@ -29,21 +28,10 @@ class ShowHomeScreenAlertsUseCaseImpl @Inject constructor( override val isLoggingEnabled: Flow = preferences.get(Keys.log).map { it == true } - override val accessibilityServiceState: Flow = - accessibilityServiceAdapter.state - override fun disableBatteryOptimisation() { permissions.request(Permission.IGNORE_BATTERY_OPTIMISATION) } - override fun startAccessibilityService(): Boolean = accessibilityServiceAdapter.start() - - override fun restartAccessibilityService(): Boolean = accessibilityServiceAdapter.restart() - - override fun acknowledgeCrashed() { - accessibilityServiceAdapter.acknowledgeCrashed() - } - override fun resumeMappings() { pauseKeyMapsUseCase.resume() } @@ -70,11 +58,6 @@ class ShowHomeScreenAlertsUseCaseImpl @Inject constructor( } interface ShowHomeScreenAlertsUseCase { - val accessibilityServiceState: Flow - fun startAccessibilityService(): Boolean - fun restartAccessibilityService(): Boolean - fun acknowledgeCrashed() - val hideAlerts: Flow fun disableBatteryOptimisation() val isBatteryOptimised: Flow diff --git a/base/src/main/java/io/github/sds100/keymapper/base/input/EvdevHandleCache.kt b/base/src/main/java/io/github/sds100/keymapper/base/input/EvdevHandleCache.kt new file mode 100644 index 0000000000..5db30e4278 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/input/EvdevHandleCache.kt @@ -0,0 +1,97 @@ +package io.github.sds100.keymapper.base.input + +import androidx.annotation.RequiresApi +import io.github.sds100.keymapper.common.models.EvdevDeviceHandle +import io.github.sds100.keymapper.common.models.EvdevDeviceInfo +import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.common.utils.onFailure +import io.github.sds100.keymapper.common.utils.valueIfFailure +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import io.github.sds100.keymapper.sysbridge.manager.isConnected +import io.github.sds100.keymapper.system.devices.DevicesAdapter +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +@RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) +@Singleton +class EvdevHandleCache @Inject constructor( + private val coroutineScope: CoroutineScope, + private val devicesAdapter: DevicesAdapter, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, +) { + private val devicesByPath: MutableStateFlow> = + MutableStateFlow(emptyMap()) + + val devices: StateFlow> = + devicesByPath + .map { pathMap -> + pathMap.values.map { device -> + EvdevDeviceInfo( + name = device.name, + bus = device.bus, + vendor = device.vendor, + product = device.product, + ) + } + } + .stateIn(coroutineScope, SharingStarted.Eagerly, emptyList()) + + init { + coroutineScope.launch { + combine( + devicesAdapter.connectedInputDevices, + systemBridgeConnectionManager.connectionState, + ) { _, connectionState -> + if (connectionState !is SystemBridgeConnectionState.Connected) { + devicesByPath.value = emptyMap() + } else { + invalidate() + } + }.collect() + } + } + + fun getByPath(path: String): EvdevDeviceHandle? { + return devicesByPath.value[path] + } + + fun getByInfo(deviceInfo: EvdevDeviceInfo): EvdevDeviceHandle? { + return devicesByPath.value.values.firstOrNull { + it.name == deviceInfo.name && + it.bus == deviceInfo.bus && + it.vendor == deviceInfo.vendor && + it.product == deviceInfo.product + } + } + + suspend fun invalidate() { + if (!systemBridgeConnectionManager.isConnected()) { + devicesByPath.value = emptyMap() + return + } + + // Do it on a separate thread in case there is deadlock + val newDevices = withContext(Dispatchers.IO) { + systemBridgeConnectionManager.run { bridge -> + bridge.evdevInputDevices.associateBy { it.path } + } + }.onFailure { error -> + Timber.e("Failed to get evdev input devices from system bridge $error") + }.valueIfFailure { emptyMap() } + + devicesByPath.value = newDevices + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/input/InjectKeyEventModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/input/InjectKeyEventModel.kt new file mode 100644 index 0000000000..276c6b4bb5 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/input/InjectKeyEventModel.kt @@ -0,0 +1,30 @@ +package io.github.sds100.keymapper.base.input + +import android.os.SystemClock +import android.view.KeyEvent + +data class InjectKeyEventModel( + val keyCode: Int, + val action: Int, + val metaState: Int, + val deviceId: Int, + val scanCode: Int, + val source: Int, + val repeatCount: Int = 0, +) { + fun toAndroidKeyEvent(flags: Int = 0): KeyEvent { + val eventTime = SystemClock.uptimeMillis() + return KeyEvent( + eventTime, + eventTime, + action, + keyCode, + repeatCount, + metaState, + deviceId, + scanCode, + flags, + source, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventDetectionSource.kt b/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventDetectionSource.kt new file mode 100644 index 0000000000..8b06b10e01 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventDetectionSource.kt @@ -0,0 +1,7 @@ +package io.github.sds100.keymapper.base.input + +enum class InputEventDetectionSource { + ACCESSIBILITY_SERVICE, + INPUT_METHOD, + EVDEV, +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHub.kt b/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHub.kt new file mode 100644 index 0000000000..4aa5ca5ce8 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHub.kt @@ -0,0 +1,438 @@ +package io.github.sds100.keymapper.base.input + +import android.os.Build +import android.view.KeyEvent +import androidx.annotation.RequiresApi +import io.github.sds100.keymapper.base.BuildConfig +import io.github.sds100.keymapper.base.system.inputmethod.ImeInputEventInjector +import io.github.sds100.keymapper.common.models.EvdevDeviceInfo +import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.firstBlocking +import io.github.sds100.keymapper.common.utils.onFailure +import io.github.sds100.keymapper.common.utils.onSuccess +import io.github.sds100.keymapper.common.utils.then +import io.github.sds100.keymapper.common.utils.valueOrNull +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.sysbridge.IEvdevCallback +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import io.github.sds100.keymapper.sysbridge.manager.isConnected +import io.github.sds100.keymapper.system.inputevents.KMEvdevEvent +import io.github.sds100.keymapper.system.inputevents.KMGamePadEvent +import io.github.sds100.keymapper.system.inputevents.KMInputEvent +import io.github.sds100.keymapper.system.inputevents.KMKeyEvent +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +@Singleton +class InputEventHubImpl @Inject constructor( + private val coroutineScope: CoroutineScope, + private val systemBridgeConnManager: SystemBridgeConnectionManager, + private val imeInputEventInjector: ImeInputEventInjector, + private val preferenceRepository: PreferenceRepository, + private val evdevHandlesCache: EvdevHandleCache, +) : IEvdevCallback.Stub(), + InputEventHub { + + companion object { + const val INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH = 2 + } + + private val clients: ConcurrentHashMap = ConcurrentHashMap() + + // Event queue for processing key events asynchronously in order + private val keyEventQueue = Channel(capacity = 100) + + private val logInputEventsEnabled: StateFlow = + preferenceRepository.get(Keys.log).map { isLogEnabled -> + if (isLogEnabled == true) { + isLogEnabled + } else { + BuildConfig.DEBUG + } + }.stateIn(coroutineScope, SharingStarted.Eagerly, false) + + private val invalidateGrabbedDevicesChannel: Channel = Channel(capacity = 10) + + init { + startKeyEventProcessingLoop() + + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + coroutineScope.launch { + systemBridgeConnManager.connectionState + .filterIsInstance() + .collect { + systemBridgeConnManager.run { bridge -> + bridge.registerEvdevCallback(this@InputEventHubImpl) + } + } + } + + coroutineScope.launch { + invalidateGrabbedDevicesChannel + .consumeAsFlow() + .map { + clients.values.flatMap { it.grabbedEvdevDevices }.toSet().toList() + } + .flowOn(Dispatchers.Default) + .collectLatest { devices -> + invalidateGrabbedEvdevDevices(devices) + } + } + } + } + + /** + * Starts a coroutine that processes key events from the queue in order on the IO thread. + **/ + private fun startKeyEventProcessingLoop() { + coroutineScope.launch(Dispatchers.IO) { + for (event in keyEventQueue) { + try { + injectKeyEvent(event, useSystemBridgeIfAvailable = true) + } catch (e: Exception) { + Timber.e(e, "Error processing key event: $event") + } + } + } + } + + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) + override fun isSystemBridgeConnected(): Boolean { + return systemBridgeConnManager.isConnected() + } + + override fun onEvdevEventLoopStarted() { + Timber.i("On evdev event loop started") + + coroutineScope.launch { + invalidateGrabbedDevicesChannel.send(Unit) + } + } + + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) + override fun onEvdevEvent( + devicePath: String?, + timeSec: Long, + timeUsec: Long, + type: Int, + code: Int, + value: Int, + androidCode: Int, + ): Boolean { + devicePath ?: return false + + val handle = evdevHandlesCache.getByPath(devicePath) ?: return false + val evdevEvent = KMEvdevEvent(handle, type, code, value, androidCode, timeSec, timeUsec) + + return onInputEvent(evdevEvent, InputEventDetectionSource.EVDEV) + } + + override fun onInputEvent( + event: KMInputEvent, + detectionSource: InputEventDetectionSource, + ): Boolean { + if (logInputEventsEnabled.value) { + logInputEvent(event) + } + + var consume = false + + for (clientContext in clients.values) { + if (event is KMEvdevEvent) { + if (!clientContext.evdevEventTypes.contains(event.type) || + clientContext.grabbedEvdevDevices.isEmpty() + ) { + continue + } + + val deviceInfo = EvdevDeviceInfo( + event.device.name, + event.device.bus, + event.device.vendor, + event.device.product, + ) + + // Only send events from evdev devices to the client if they grabbed it + if (!clientContext.grabbedEvdevDevices.contains(deviceInfo)) { + continue + } + + // Lazy evaluation may not execute this if its inlined + val result = clientContext.callback.onInputEvent(event, detectionSource) + consume = consume || result + } else { + // Lazy evaluation may not execute this if its inlined + val result = clientContext.callback.onInputEvent(event, detectionSource) + consume = consume || result + } + } + + if (logInputEventsEnabled.value) { + Timber.d("Consumed: $consume") + } + + return consume + } + + private fun logInputEvent(event: KMInputEvent) { + when (event) { + is KMEvdevEvent -> { + Timber.d( + "Evdev event: devicePath=${event.device.path}, deviceName=${event.device.name}, type=${event.type}, code=${event.code}, value=${event.value}", + ) + } + + is KMGamePadEvent -> { + Timber.d( + "GamePad event: deviceId=${event.deviceId}, axisHatX=${event.axisHatX}, axisHatY=${event.axisHatY}", + ) + } + + is KMKeyEvent -> { + when (event.action) { + KeyEvent.ACTION_DOWN -> { + Timber.d( + "Key down ${KeyEvent.keyCodeToString( + event.keyCode, + )}: keyCode=${event.keyCode}, scanCode=${event.scanCode}, deviceId=${event.deviceId}, metaState=${event.metaState}, source=${event.source}", + ) + } + + KeyEvent.ACTION_UP -> { + Timber.d( + "Key up ${KeyEvent.keyCodeToString( + event.keyCode, + )}: keyCode=${event.keyCode}, scanCode=${event.scanCode}, deviceId=${event.deviceId}, metaState=${event.metaState}, source=${event.source}", + ) + } + + else -> { + Timber.w("Unknown key event action: ${event.action}") + } + } + } + } + } + + override fun registerClient( + clientId: String, + callback: InputEventHubCallback, + evdevEventTypes: List, + ) { + Timber.d("InputEventHub: Registering client $clientId") + if (clients.containsKey(clientId)) { + throw IllegalArgumentException("This client already has a callback registered!") + } + + clients[clientId] = ClientContext(callback, emptySet(), evdevEventTypes.toSet()) + } + + override fun unregisterClient(clientId: String) { + Timber.d("InputEventHub: Unregistering client $clientId") + clients.remove(clientId) + invalidateGrabbedDevicesChannel.trySend(Unit) + } + + override fun setGrabbedEvdevDevices(clientId: String, devices: List) { + if (!clients.containsKey(clientId)) { + throw IllegalArgumentException( + "This client $clientId is not registered when trying to grab devices!", + ) + } + + clients[clientId] = clients[clientId]!!.copy(grabbedEvdevDevices = devices.toSet()) + + invalidateGrabbedDevicesChannel.trySend(Unit) + } + + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) + override fun grabAllEvdevDevices(clientId: String) { + if (!clients.containsKey(clientId)) { + throw IllegalArgumentException( + "This client $clientId is not registered when trying to grab devices!", + ) + } + + val devices = evdevHandlesCache.devices.value.toSet() + clients[clientId] = clients[clientId]!!.copy(grabbedEvdevDevices = devices) + + invalidateGrabbedDevicesChannel.trySend(Unit) + } + + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) + private suspend fun invalidateGrabbedEvdevDevices(evdevDevices: List) { + // Invalidate the cache first to make sure it is up to date. + evdevHandlesCache.invalidate() + + // Grabbing can block if there are other grabbing or event loop start/stop operations happening. + systemBridgeConnManager.run { bridge -> bridge.ungrabAllEvdevDevices() } + .onSuccess { Timber.i("Ungrabbed all evdev devices: $it") } + .then { + val handles: Array = + evdevDevices.mapNotNull { evdevHandlesCache.getByInfo(it)?.path }.toTypedArray() + + val result = + systemBridgeConnManager.run { bridge -> bridge.grabEvdevDeviceArray(handles) } + + if (result.valueOrNull() == true) { + Success(Unit) + } else { + KMError.Exception(Exception("Failed to grab")) + } + } + .onSuccess { result -> + Timber.i("Grabbed evdev devices [${evdevDevices.joinToString { it.name }}]") + } + .onFailure { + Timber.e("Failed to grab evdev devices.") + } + } + + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) + override fun injectEvdevEvent( + devicePath: String, + type: Int, + code: Int, + value: Int, + ): KMResult { + return systemBridgeConnManager.run { bridge -> + bridge.writeEvdevEvent( + devicePath, + type, + code, + value, + ) + }.onSuccess { + Timber.d("Injected evdev event: $it") + } + } + + override suspend fun injectKeyEvent( + event: InjectKeyEventModel, + useSystemBridgeIfAvailable: Boolean, + ): KMResult { + val isSysBridgeConnected = Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API && + systemBridgeConnManager.connectionState.value is SystemBridgeConnectionState.Connected + + if (isSysBridgeConnected && useSystemBridgeIfAvailable) { + val androidKeyEvent = event.toAndroidKeyEvent(flags = KeyEvent.FLAG_FROM_SYSTEM) + + if (logInputEventsEnabled.value) { + Timber.d("Injecting key event $androidKeyEvent with system bridge") + } + + return withContext(Dispatchers.IO) { + // All injected events have their device id set to -1 (VIRTUAL_KEYBOARD_ID) + // in InputDispatcher.cpp injectInputEvent. + systemBridgeConnManager.run { bridge -> + bridge.injectInputEvent( + androidKeyEvent, + INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH, + ) + }.then { Success(Unit) } + } + } else { + imeInputEventInjector.inputKeyEvent(event) + return Success(Unit) + } + } + + override fun injectKeyEventAsync(event: InjectKeyEventModel): KMResult { + return try { + // Send the event to the queue for processing + if (keyEventQueue.trySend(event).isSuccess) { + Success(Unit) + } else { + KMError.Exception(Exception("Failed to queue key event")) + } + } catch (e: Exception) { + KMError.Exception(e) + } + } + + override fun onEmergencyKillSystemBridge() { + preferenceRepository.set(Keys.isSystemBridgeEmergencyKilled, true) + // Wait for it to be persisted. This method is a synchronous Binder call + // so the system bridge will not be killed until this returns + preferenceRepository.get(Keys.isSystemBridgeEmergencyKilled).filter { it == true } + .firstBlocking() + } + + private data class ClientContext( + val callback: InputEventHubCallback, + /** + * The evdev devices that this client wants to grab. + */ + val grabbedEvdevDevices: Set, + val evdevEventTypes: Set, + ) +} + +interface InputEventHub { + /** + * Register a client that will receive input events through the [callback]. The same [clientId] + * must be used for any requests to other methods in this class. The input events will either + * come from the key event relay service, accessibility service, or system bridge + * depending on the type of event and Key Mapper's permissions. + */ + fun registerClient( + clientId: String, + callback: InputEventHubCallback, + evdevEventTypes: List, + ) + + fun unregisterClient(clientId: String) + + fun setGrabbedEvdevDevices(clientId: String, devices: List) + fun grabAllEvdevDevices(clientId: String) + + /** + * Inject a key event. This may either use the key event relay service or the system + * bridge depending on the permissions granted to Key Mapper. + * + * Must be suspend so injecting to the system bridge can happen on another thread. + */ + suspend fun injectKeyEvent( + event: InjectKeyEventModel, + useSystemBridgeIfAvailable: Boolean, + ): KMResult + + /** + * Some callers don't care about the result from injecting and it isn't critical + * for it to be synchronous so calls to this + * will be put in a queue and processed. + */ + fun injectKeyEventAsync(event: InjectKeyEventModel): KMResult + + fun injectEvdevEvent(devicePath: String, type: Int, code: Int, value: Int): KMResult + + /** + * Send an input event to the connected clients. + * + * @return whether the input event was consumed by a client. + */ + fun onInputEvent(event: KMInputEvent, detectionSource: InputEventDetectionSource): Boolean + + fun isSystemBridgeConnected(): Boolean +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHubCallback.kt b/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHubCallback.kt new file mode 100644 index 0000000000..68e6806eee --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/input/InputEventHubCallback.kt @@ -0,0 +1,10 @@ +package io.github.sds100.keymapper.base.input + +import io.github.sds100.keymapper.system.inputevents.KMInputEvent + +interface InputEventHubCallback { + /** + * @return whether to consume the event. + */ + fun onInputEvent(event: KMInputEvent, detectionSource: InputEventDetectionSource): Boolean +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/BaseConfigKeyMapScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/BaseConfigKeyMapScreen.kt index 06517591d7..62ab1dfaff 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/BaseConfigKeyMapScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/BaseConfigKeyMapScreen.kt @@ -1,6 +1,9 @@ package io.github.sds100.keymapper.base.keymaps import androidx.activity.compose.BackHandler +import androidx.compose.animation.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -15,6 +18,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.HelpOutline @@ -36,10 +40,12 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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 @@ -50,12 +56,8 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.canopas.lib.showcase.IntroShowcase import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme -import io.github.sds100.keymapper.base.onboarding.OnboardingTapTarget -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperTapTarget -import io.github.sds100.keymapper.base.utils.ui.compose.keyMapperShowcaseStyle import io.github.sds100.keymapper.base.utils.ui.compose.openUriSafe import kotlinx.coroutines.launch @@ -66,17 +68,13 @@ fun BaseConfigKeyMapScreen( isKeyMapEnabled: Boolean, onKeyMapEnabledChange: (Boolean) -> Unit = {}, triggerScreen: @Composable () -> Unit, - actionScreen: @Composable () -> Unit, + actionsScreen: @Composable () -> Unit, constraintsScreen: @Composable () -> Unit, optionsScreen: @Composable () -> Unit, onBackClick: () -> Unit = {}, onDoneClick: () -> Unit = {}, snackbarHostState: SnackbarHostState = SnackbarHostState(), - showActionTapTarget: Boolean = false, - onActionTapTargetCompleted: () -> Unit = {}, - showConstraintTapTarget: Boolean = false, - onConstraintTapTargetCompleted: () -> Unit = {}, - onSkipTutorialClick: () -> Unit = {}, + showActionPulse: Boolean = false, ) { val scope = rememberCoroutineScope() val triggerHelpUrl = stringResource(R.string.url_trigger_guide) @@ -130,49 +128,75 @@ fun BaseConfigKeyMapScreen( @Composable fun Tabs() { for ((index, tab) in tabs.withIndex()) { - val tapTarget: OnboardingTapTarget? = when { - showActionTapTarget && tab == ConfigKeyMapTab.ACTIONS -> OnboardingTapTarget.CHOOSE_ACTION - showConstraintTapTarget && (tab == ConfigKeyMapTab.CONSTRAINTS || tab == ConfigKeyMapTab.CONSTRAINTS_AND_OPTIONS) -> OnboardingTapTarget.CHOOSE_CONSTRAINT - else -> null - } + val tabModifier = if (tab == ConfigKeyMapTab.ACTIONS) { + val defaultBackgroundColor = MaterialTheme.colorScheme.surface + val pulseBackgroundColor = + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + val animatedBackgroundColor = + remember { Animatable(defaultBackgroundColor) } + + var finishedAnimation by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(showActionPulse) { + var startedAnimation = false + + repeat(10) { + // Check at the start of each repeat so that it is + // a smooth animation to the old position when it stops. + val isActionsTabSelected = + pagerState.targetPage == index && + tab == ConfigKeyMapTab.ACTIONS + + if (!showActionPulse || + finishedAnimation || + isActionsTabSelected + ) { + return@repeat + } - IntroShowcase( - showIntroShowCase = tapTarget != null, - onShowCaseCompleted = if (tapTarget == OnboardingTapTarget.CHOOSE_ACTION) onActionTapTargetCompleted else onConstraintTapTargetCompleted, - dismissOnClickOutside = true, - ) { - var tabModifier: Modifier = Modifier - - if (tapTarget != null) { - tabModifier = tabModifier.introShowCaseTarget( - index = 0, - style = keyMapperShowcaseStyle(), - ) { - KeyMapperTapTarget( - tapTarget = tapTarget, - onSkipClick = onSkipTutorialClick, + startedAnimation = true + + animatedBackgroundColor.animateTo( + pulseBackgroundColor, + tween(700), ) + + animatedBackgroundColor.animateTo( + defaultBackgroundColor, + tween(700), + ) + } + + if (startedAnimation) { + finishedAnimation = true } } - Tab( - modifier = tabModifier, - selected = pagerState.targetPage == index, - text = { - Text( - text = getTabTitle(tab), - maxLines = 1, - ) - }, - onClick = { - scope.launch { - pagerState.animateScrollToPage( - tabs.indexOf(tab), - ) - } - }, + Modifier.background( + color = animatedBackgroundColor.value, + shape = RoundedCornerShape(8.dp), ) + } else { + Modifier } + + Tab( + modifier = tabModifier, + selected = pagerState.targetPage == index, + text = { + Text( + text = getTabTitle(tab), + maxLines = 1, + ) + }, + onClick = { + scope.launch { + pagerState.animateScrollToPage( + tabs.indexOf(tab), + ) + } + }, + ) } } @@ -202,7 +226,7 @@ fun BaseConfigKeyMapScreen( ) { pageIndex -> when (tabs[pageIndex]) { ConfigKeyMapTab.TRIGGER -> triggerScreen() - ConfigKeyMapTab.ACTIONS -> actionScreen() + ConfigKeyMapTab.ACTIONS -> actionsScreen() ConfigKeyMapTab.CONSTRAINTS -> constraintsScreen() ConfigKeyMapTab.OPTIONS -> optionsScreen() ConfigKeyMapTab.TRIGGER_AND_ACTIONS -> { @@ -213,7 +237,7 @@ fun BaseConfigKeyMapScreen( topScreen = triggerScreen, bottomTitle = stringResource(R.string.tab_actions), bottomHelpUrl = actionsHelpUrl, - bottomScreen = actionScreen, + bottomScreen = actionsScreen, ) } else { HorizontalTwoScreens( @@ -222,7 +246,7 @@ fun BaseConfigKeyMapScreen( leftScreen = triggerScreen, rightTitle = stringResource(R.string.tab_actions), rightHelpUrl = actionsHelpUrl, - rightScreen = actionScreen, + rightScreen = actionsScreen, ) } } @@ -255,7 +279,7 @@ fun BaseConfigKeyMapScreen( topLeftScreen = triggerScreen, topRightTitle = stringResource(R.string.tab_actions), topRightHelpUrl = actionsHelpUrl, - topRightScreen = actionScreen, + topRightScreen = actionsScreen, bottomLeftTitle = stringResource(R.string.tab_constraints), bottomLeftHelpUrl = constraintsHelpUrl, bottomLeftScreen = constraintsScreen, @@ -557,7 +581,7 @@ private fun SmallScreenPreview() { modifier = Modifier.fillMaxSize(), isKeyMapEnabled = false, triggerScreen = {}, - actionScreen = {}, + actionsScreen = {}, constraintsScreen = {}, optionsScreen = {}, ) @@ -572,7 +596,7 @@ private fun MediumScreenPreview() { modifier = Modifier.fillMaxSize(), isKeyMapEnabled = true, triggerScreen = {}, - actionScreen = {}, + actionsScreen = {}, constraintsScreen = {}, optionsScreen = {}, ) @@ -587,7 +611,7 @@ private fun MediumScreenLandscapePreview() { modifier = Modifier.fillMaxSize(), isKeyMapEnabled = true, triggerScreen = {}, - actionScreen = {}, + actionsScreen = {}, constraintsScreen = {}, optionsScreen = {}, ) @@ -602,7 +626,7 @@ private fun LargeScreenPreview() { modifier = Modifier.fillMaxSize(), isKeyMapEnabled = true, triggerScreen = {}, - actionScreen = {}, + actionsScreen = {}, constraintsScreen = {}, optionsScreen = {}, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/BaseConfigKeyMapViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/BaseConfigKeyMapViewModel.kt deleted file mode 100644 index 7dd8d9d1f4..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/BaseConfigKeyMapViewModel.kt +++ /dev/null @@ -1,102 +0,0 @@ -package io.github.sds100.keymapper.base.keymaps - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import io.github.sds100.keymapper.base.actions.ConfigActionsViewModel -import io.github.sds100.keymapper.base.constraints.ConfigConstraintsViewModel -import io.github.sds100.keymapper.base.onboarding.OnboardingTapTarget -import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase -import io.github.sds100.keymapper.base.trigger.BaseConfigTriggerViewModel -import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider -import io.github.sds100.keymapper.base.utils.ui.DialogProvider -import io.github.sds100.keymapper.common.utils.dataOrNull -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -abstract class BaseConfigKeyMapViewModel( - private val config: ConfigKeyMapUseCase, - private val onboarding: OnboardingUseCase, - navigationProvider: NavigationProvider, - dialogProvider: DialogProvider, -) : ViewModel(), - NavigationProvider by navigationProvider, - DialogProvider by dialogProvider { - - abstract val configActionsViewModel: ConfigActionsViewModel - abstract val configTriggerViewModel: BaseConfigTriggerViewModel - abstract val configConstraintsViewModel: ConfigConstraintsViewModel - - val isEnabled: StateFlow = config.keyMap - .map { state -> state.dataOrNull()?.isEnabled ?: true } - .stateIn(viewModelScope, SharingStarted.Eagerly, true) - - val isKeyMapEdited: Boolean - get() = config.isEdited - - val showActionsTapTarget: StateFlow = - combine( - onboarding.showTapTarget(OnboardingTapTarget.CHOOSE_ACTION), - config.keyMap, - ) { showTapTarget, keyMapState -> - // Show the choose action tap target if they have recorded a key. - showTapTarget && keyMapState.dataOrNull()?.trigger?.keys?.isNotEmpty() ?: false - }.stateIn(viewModelScope, SharingStarted.Lazily, false) - - val showConstraintsTapTarget: StateFlow = - combine( - onboarding.showTapTarget(OnboardingTapTarget.CHOOSE_CONSTRAINT), - config.keyMap, - ) { showTapTarget, keyMapState -> - // Show the choose constraint tap target if they have added an action. - showTapTarget && keyMapState.dataOrNull()?.actionList?.isNotEmpty() ?: false - }.stateIn(viewModelScope, SharingStarted.Lazily, false) - - fun onDoneClick() { - config.save() - - viewModelScope.launch { - popBackStack() - } - } - - fun loadNewKeyMap(floatingButtonUid: String? = null, groupUid: String?) { - config.loadNewKeyMap(groupUid) - if (floatingButtonUid != null) { - viewModelScope.launch { - config.addFloatingButtonTriggerKey(floatingButtonUid) - } - } - } - - fun loadKeyMap(uid: String) { - viewModelScope.launch { - config.loadKeyMap(uid) - } - } - - fun onBackClick() { - viewModelScope.launch { - popBackStack() - } - } - - fun onEnabledChanged(enabled: Boolean) { - config.setEnabled(enabled) - } - - fun onActionTapTargetCompleted() { - onboarding.completedTapTarget(OnboardingTapTarget.CHOOSE_ACTION) - } - - fun onConstraintTapTargetCompleted() { - onboarding.completedTapTarget(OnboardingTapTarget.CHOOSE_CONSTRAINT) - } - - fun onSkipTutorialClick() { - onboarding.skipTapTargetOnboarding() - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt index 9067eabe14..63657c3fab 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt @@ -4,6 +4,8 @@ import android.graphics.Color import android.graphics.drawable.Drawable import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.ActionUiHelper +import io.github.sds100.keymapper.base.shortcuts.CreateKeyMapShortcutUseCase +import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCase import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider @@ -25,7 +27,7 @@ import kotlinx.coroutines.launch class ConfigKeyMapOptionsViewModel( private val coroutineScope: CoroutineScope, - private val config: ConfigKeyMapUseCase, + private val config: ConfigTriggerUseCase, private val displayUseCase: DisplayKeyMapUseCase, private val createKeyMapShortcut: CreateKeyMapShortcutUseCase, private val dialogProvider: DialogProvider, @@ -64,10 +66,6 @@ class ConfigKeyMapOptionsViewModel( config.setLongPressDoubleVibrationEnabled(checked) } - override fun onScreenOffTriggerChanged(checked: Boolean) { - config.setTriggerWhenScreenOff(checked) - } - override fun onShowToastChanged(checked: Boolean) { config.setShowToastEnabled(checked) } @@ -164,9 +162,6 @@ class ConfigKeyMapOptionsViewModel( showLongPressDoubleVibration = keyMap.trigger.isLongPressDoubleVibrationAllowed(), longPressDoubleVibration = keyMap.trigger.longPressDoubleVibration, - showScreenOffTrigger = keyMap.trigger.isDetectingWhenScreenOffAllowed(), - screenOffTrigger = keyMap.trigger.screenOffTrigger, - triggerFromOtherApps = keyMap.trigger.triggerFromOtherApps, keyMapUid = keyMap.uid, isLauncherShortcutButtonEnabled = createKeyMapShortcut.isSupported, @@ -199,9 +194,6 @@ data class KeyMapOptionsState( val showLongPressDoubleVibration: Boolean, val longPressDoubleVibration: Boolean, - val showScreenOffTrigger: Boolean, - val screenOffTrigger: Boolean, - val triggerFromOtherApps: Boolean, val keyMapUid: String, val isLauncherShortcutButtonEnabled: Boolean, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapScreen.kt new file mode 100644 index 0000000000..a9eb84da5c --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapScreen.kt @@ -0,0 +1,56 @@ +package io.github.sds100.keymapper.base.keymaps + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.sds100.keymapper.base.utils.ui.UnsavedChangesDialog + +@Composable +fun ConfigKeyMapScreen( + modifier: Modifier = Modifier, + snackbarHostState: SnackbarHostState, + keyMapViewModel: ConfigKeyMapViewModel, + triggerScreen: @Composable () -> Unit, + actionsScreen: @Composable () -> Unit, + constraintsScreen: @Composable () -> Unit, + optionsScreen: @Composable () -> Unit, +) { + val isKeyMapEnabled by keyMapViewModel.isEnabled.collectAsStateWithLifecycle() + val showActionPulse by keyMapViewModel.showActionsTapTarget.collectAsStateWithLifecycle() + var showBackDialog by rememberSaveable { mutableStateOf(false) } + + if (showBackDialog) { + UnsavedChangesDialog( + onDismiss = { showBackDialog = false }, + onDiscardClick = { + showBackDialog = false + keyMapViewModel.onBackClick() + }, + ) + } + + BaseConfigKeyMapScreen( + modifier = modifier, + isKeyMapEnabled = isKeyMapEnabled, + onKeyMapEnabledChange = keyMapViewModel::onEnabledChanged, + triggerScreen = triggerScreen, + actionsScreen = actionsScreen, + constraintsScreen = constraintsScreen, + optionsScreen = optionsScreen, + onBackClick = { + if (keyMapViewModel.isKeyMapEdited) { + showBackDialog = true + } else { + keyMapViewModel.onBackClick() + } + }, + onDoneClick = keyMapViewModel::onDoneClick, + snackbarHostState = snackbarHostState, + showActionPulse = showActionPulse, + ) +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt new file mode 100644 index 0000000000..c4cc8c2222 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt @@ -0,0 +1,122 @@ +package io.github.sds100.keymapper.base.keymaps + +import android.database.sqlite.SQLiteConstraintException +import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.dataOrNull +import io.github.sds100.keymapper.common.utils.mapData +import io.github.sds100.keymapper.data.entities.FloatingButtonEntityWithLayout +import io.github.sds100.keymapper.data.repositories.FloatingButtonRepository +import io.github.sds100.keymapper.data.repositories.KeyMapRepository +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +@Singleton +class ConfigKeyMapStateImpl @Inject constructor( + private val coroutineScope: CoroutineScope, + private val keyMapRepository: KeyMapRepository, + private val floatingButtonRepository: FloatingButtonRepository, +) : ConfigKeyMapState { + private var originalKeyMap: KeyMap? = null + + private val _keyMap: MutableStateFlow> = MutableStateFlow(State.Loading) + override val keyMap: StateFlow> = _keyMap.asStateFlow() + + override val floatingButtonToUse: MutableStateFlow = MutableStateFlow(null) + + init { + // Update button data in the key map whenever the floating buttons changes. + coroutineScope.launch { + floatingButtonRepository.buttonsList + .filterIsInstance>>() + .map { it.data } + .collectLatest(::updateFloatingButtonTriggerKeys) + } + } + + private fun updateFloatingButtonTriggerKeys(buttons: List) { + update { keyMap -> + keyMap.copy(trigger = keyMap.trigger.updateFloatingButtonData(buttons)) + } + } + + /** + * Whether any changes were made to the key map. + */ + override val isEdited: Boolean + get() = when (val keyMap = keyMap.value) { + is State.Data -> originalKeyMap?.let { it != keyMap.data } ?: false + State.Loading -> false + } + + override suspend fun loadKeyMap(uid: String) { + _keyMap.update { State.Loading } + val entity = keyMapRepository.get(uid) ?: return + val floatingButtons = floatingButtonRepository.buttonsList + .filterIsInstance>>() + .map { it.data } + .first() + + val keyMap = KeyMapEntityMapper.fromEntity(entity, floatingButtons) + _keyMap.update { State.Data(keyMap) } + originalKeyMap = keyMap + } + + override fun loadNewKeyMap(groupUid: String?) { + val keyMap = KeyMap(groupUid = groupUid) + _keyMap.update { State.Data(keyMap) } + originalKeyMap = keyMap + } + + // Useful for testing + fun setKeyMap(keyMap: KeyMap) { + _keyMap.update { State.Data(keyMap) } + originalKeyMap = keyMap + } + + override fun save() { + val keyMap = keyMap.value.dataOrNull() ?: return + + if (keyMap.dbId == null) { + val entity = KeyMapEntityMapper.toEntity(keyMap, 0) + try { + keyMapRepository.insert(entity) + } catch (e: SQLiteConstraintException) { + keyMapRepository.update(entity) + } + } else { + keyMapRepository.update(KeyMapEntityMapper.toEntity(keyMap, keyMap.dbId)) + } + } + + override fun restoreState(keyMap: KeyMap) { + _keyMap.update { State.Data(keyMap) } + } + + override fun update(block: (keyMap: KeyMap) -> KeyMap) { + _keyMap.update { value -> value.mapData { block.invoke(it) } } + } +} + +interface ConfigKeyMapState { + val keyMap: StateFlow> + val isEdited: Boolean + + fun update(block: (keyMap: KeyMap) -> KeyMap) + fun save() + + fun restoreState(keyMap: KeyMap) + suspend fun loadKeyMap(uid: String) + fun loadNewKeyMap(groupUid: String?) + + val floatingButtonToUse: MutableStateFlow +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapUseCase.kt deleted file mode 100644 index 5d87b95a92..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapUseCase.kt +++ /dev/null @@ -1,1046 +0,0 @@ -package io.github.sds100.keymapper.base.keymaps - -import android.database.sqlite.SQLiteConstraintException -import io.github.sds100.keymapper.base.actions.Action -import io.github.sds100.keymapper.base.actions.ActionData -import io.github.sds100.keymapper.base.actions.RepeatMode -import io.github.sds100.keymapper.base.constraints.Constraint -import io.github.sds100.keymapper.base.constraints.ConstraintMode -import io.github.sds100.keymapper.base.constraints.ConstraintState -import io.github.sds100.keymapper.base.floating.FloatingButtonEntityMapper -import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType -import io.github.sds100.keymapper.base.trigger.AssistantTriggerKey -import io.github.sds100.keymapper.base.trigger.AssistantTriggerType -import io.github.sds100.keymapper.base.trigger.FingerprintTriggerKey -import io.github.sds100.keymapper.base.trigger.FloatingButtonKey -import io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey -import io.github.sds100.keymapper.base.trigger.KeyEventDetectionSource -import io.github.sds100.keymapper.base.trigger.Trigger -import io.github.sds100.keymapper.base.trigger.TriggerKey -import io.github.sds100.keymapper.base.trigger.TriggerKeyDevice -import io.github.sds100.keymapper.base.trigger.TriggerMode -import io.github.sds100.keymapper.common.utils.KMResult -import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.common.utils.dataOrNull -import io.github.sds100.keymapper.common.utils.firstBlocking -import io.github.sds100.keymapper.common.utils.ifIsData -import io.github.sds100.keymapper.common.utils.moveElement -import io.github.sds100.keymapper.data.Keys -import io.github.sds100.keymapper.data.entities.FloatingButtonEntityWithLayout -import io.github.sds100.keymapper.data.repositories.FloatingButtonRepository -import io.github.sds100.keymapper.data.repositories.FloatingLayoutRepository -import io.github.sds100.keymapper.data.repositories.KeyMapRepository -import io.github.sds100.keymapper.data.repositories.PreferenceRepository -import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter -import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceEvent -import io.github.sds100.keymapper.system.devices.DevicesAdapter -import io.github.sds100.keymapper.system.devices.InputDeviceUtils -import io.github.sds100.keymapper.system.inputevents.InputEventUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import java.util.LinkedList -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class ConfigKeyMapUseCaseController @Inject constructor( - private val coroutineScope: CoroutineScope, - private val keyMapRepository: KeyMapRepository, - private val devicesAdapter: DevicesAdapter, - private val preferenceRepository: PreferenceRepository, - private val floatingLayoutRepository: FloatingLayoutRepository, - private val floatingButtonRepository: FloatingButtonRepository, - private val serviceAdapter: AccessibilityServiceAdapter, -) : ConfigKeyMapUseCase, - GetDefaultKeyMapOptionsUseCase by GetDefaultKeyMapOptionsUseCaseImpl( - coroutineScope, - preferenceRepository, - ) { - - private var originalKeyMap: KeyMap? = null - override val keyMap = MutableStateFlow>(State.Loading) - - override val floatingButtonToUse: MutableStateFlow = MutableStateFlow(null) - - private val showDeviceDescriptors: Flow = - preferenceRepository.get(Keys.showDeviceDescriptors).map { it == true } - - /** - * The most recently used is first. - */ - override val recentlyUsedActions: StateFlow> = - preferenceRepository.get(Keys.recentlyUsedActions) - .map(::getActionShortcuts) - .map { it.take(5) } - .stateIn( - coroutineScope, - SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), - emptyList(), - ) - - /** - * The most recently used is first. - */ - override val recentlyUsedConstraints: StateFlow> = - combine( - preferenceRepository.get(Keys.recentlyUsedConstraints).map(::getConstraintShortcuts), - keyMap.filterIsInstance>(), - ) { shortcuts, keyMap -> - - // Do not include constraints that the key map already contains. - shortcuts - .filter { !keyMap.data.constraintState.constraints.contains(it) } - .take(5) - }.stateIn( - coroutineScope, - SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), - emptyList(), - ) - - /** - * Whether any changes were made to the key map. - */ - override val isEdited: Boolean - get() = when (val keyMap = keyMap.value) { - is State.Data -> originalKeyMap?.let { it != keyMap.data } ?: false - State.Loading -> false - } - - init { - // Update button data in the key map whenever the floating buttons changes. - coroutineScope.launch { - floatingButtonRepository.buttonsList - .filterIsInstance>>() - .map { it.data } - .collectLatest(::updateFloatingButtonTriggerKeys) - } - } - - private fun updateFloatingButtonTriggerKeys(buttons: List) { - keyMap.update { keyMapState -> - if (keyMapState is State.Data) { - val trigger = keyMapState.data.trigger - val newKeyMap = - keyMapState.data.copy(trigger = trigger.updateFloatingButtonData(buttons)) - - State.Data(newKeyMap) - } else { - keyMapState - } - } - } - - override fun addConstraint(constraint: Constraint): Boolean { - var containsConstraint = false - - keyMap.value.ifIsData { mapping -> - val oldState = mapping.constraintState - - containsConstraint = oldState.constraints.contains(constraint) - val newState = oldState.copy(constraints = oldState.constraints.plus(constraint)) - - setConstraintState(newState) - } - - preferenceRepository.update( - Keys.recentlyUsedConstraints, - { old -> - val oldList: List = if (old == null) { - emptyList() - } else { - Json.decodeFromString>(old) - } - - val newShortcuts = LinkedList(oldList) - .also { it.addFirst(constraint) } - .distinct() - - Json.encodeToString(newShortcuts as List) - }, - ) - - return !containsConstraint - } - - override fun removeConstraint(id: String) { - keyMap.value.ifIsData { mapping -> - val newList = mapping.constraintState.constraints.toMutableSet().apply { - removeAll { it.uid == id } - } - - setConstraintState(mapping.constraintState.copy(constraints = newList)) - } - } - - override fun setAndMode() { - keyMap.value.ifIsData { mapping -> - setConstraintState(mapping.constraintState.copy(mode = ConstraintMode.AND)) - } - } - - override fun setOrMode() { - keyMap.value.ifIsData { mapping -> - setConstraintState(mapping.constraintState.copy(mode = ConstraintMode.OR)) - } - } - - override fun addAction(data: ActionData) = keyMap.value.ifIsData { mapping -> - mapping.actionList.toMutableList().apply { - add(createAction(data)) - setActionList(this) - } - - preferenceRepository.update( - Keys.recentlyUsedActions, - { old -> - val oldList: List = if (old == null) { - emptyList() - } else { - Json.decodeFromString>(old) - } - - val newShortcuts = LinkedList(oldList) - .also { it.addFirst(data) } - .distinct() - - Json.encodeToString(newShortcuts as List) - }, - ) - } - - override fun moveAction(fromIndex: Int, toIndex: Int) { - keyMap.value.ifIsData { mapping -> - mapping.actionList.toMutableList().apply { - moveElement(fromIndex, toIndex) - setActionList(this) - } - } - } - - override fun removeAction(uid: String) { - keyMap.value.ifIsData { mapping -> - mapping.actionList.toMutableList().apply { - removeAll { it.uid == uid } - setActionList(this) - } - } - } - - override suspend fun addFloatingButtonTriggerKey(buttonUid: String) { - floatingButtonToUse.update { null } - - editTrigger { trigger -> - val clickType = when (trigger.mode) { - is TriggerMode.Parallel -> trigger.mode.clickType - TriggerMode.Sequence -> ClickType.SHORT_PRESS - TriggerMode.Undefined -> ClickType.SHORT_PRESS - } - - // Check whether the trigger already contains the key because if so - // then it must be converted to a sequence trigger. - val containsKey = trigger.keys - .mapNotNull { it as? FloatingButtonKey } - .any { keyToCompare -> keyToCompare.buttonUid == buttonUid } - - val button = floatingButtonRepository.get(buttonUid) - ?.let { entity -> - FloatingButtonEntityMapper.fromEntity( - entity.button, - entity.layout.name, - ) - } - - val triggerKey = FloatingButtonKey( - buttonUid = buttonUid, - button = button, - clickType = clickType, - ) - - var newKeys = trigger.keys.plus(triggerKey) - - val newMode = when { - trigger.mode != TriggerMode.Sequence && containsKey -> TriggerMode.Sequence - newKeys.size <= 1 -> TriggerMode.Undefined - - /* Automatically make it a parallel trigger when the user makes a trigger with more than one key - because this is what most users are expecting when they make a trigger with multiple keys */ - newKeys.size == 2 && !containsKey -> { - newKeys = newKeys.map { it.setClickType(triggerKey.clickType) } - TriggerMode.Parallel(triggerKey.clickType) - } - - else -> trigger.mode - } - - trigger.copy(keys = newKeys, mode = newMode) - } - } - - override fun addAssistantTriggerKey(type: AssistantTriggerType) = editTrigger { trigger -> - val clickType = when (trigger.mode) { - is TriggerMode.Parallel -> trigger.mode.clickType - TriggerMode.Sequence -> ClickType.SHORT_PRESS - TriggerMode.Undefined -> ClickType.SHORT_PRESS - } - - // Check whether the trigger already contains the key because if so - // then it must be converted to a sequence trigger. - val containsAssistantKey = trigger.keys.any { it is AssistantTriggerKey } - - val triggerKey = AssistantTriggerKey(type = type, clickType = clickType) - - val newKeys = trigger.keys.plus(triggerKey).map { it.setClickType(ClickType.SHORT_PRESS) } - - val newMode = when { - trigger.mode != TriggerMode.Sequence && containsAssistantKey -> TriggerMode.Sequence - newKeys.size <= 1 -> TriggerMode.Undefined - - /* Automatically make it a parallel trigger when the user makes a trigger with more than one key - because this is what most users are expecting when they make a trigger with multiple keys. - - It must be a short press because long pressing the assistant key isn't supported. - */ - !containsAssistantKey -> TriggerMode.Parallel(ClickType.SHORT_PRESS) - else -> trigger.mode - } - - trigger.copy(keys = newKeys, mode = newMode) - } - - override fun addFingerprintGesture(type: FingerprintGestureType) = editTrigger { trigger -> - val clickType = when (trigger.mode) { - is TriggerMode.Parallel -> trigger.mode.clickType - TriggerMode.Sequence -> ClickType.SHORT_PRESS - TriggerMode.Undefined -> ClickType.SHORT_PRESS - } - - // Check whether the trigger already contains the key because if so - // then it must be converted to a sequence trigger. - val containsFingerprintGesture = trigger.keys.any { it is FingerprintTriggerKey } - - val triggerKey = FingerprintTriggerKey(type = type, clickType = clickType) - - val newKeys = trigger.keys.plus(triggerKey).map { it.setClickType(ClickType.SHORT_PRESS) } - - val newMode = when { - trigger.mode != TriggerMode.Sequence && containsFingerprintGesture -> TriggerMode.Sequence - newKeys.size <= 1 -> TriggerMode.Undefined - - /* Automatically make it a parallel trigger when the user makes a trigger with more than one key - because this is what most users are expecting when they make a trigger with multiple keys. - - It must be a short press because long pressing the assistant key isn't supported. - */ - !containsFingerprintGesture -> TriggerMode.Parallel(ClickType.SHORT_PRESS) - else -> trigger.mode - } - - trigger.copy(keys = newKeys, mode = newMode) - } - - override fun addKeyCodeTriggerKey( - keyCode: Int, - device: TriggerKeyDevice, - detectionSource: KeyEventDetectionSource, - ) = editTrigger { trigger -> - val clickType = when (trigger.mode) { - is TriggerMode.Parallel -> trigger.mode.clickType - TriggerMode.Sequence -> ClickType.SHORT_PRESS - TriggerMode.Undefined -> ClickType.SHORT_PRESS - } - - // Check whether the trigger already contains the key because if so - // then it must be converted to a sequence trigger. - val containsKey = trigger.keys - .mapNotNull { it as? KeyCodeTriggerKey } - .any { keyToCompare -> - keyToCompare.keyCode == keyCode && keyToCompare.device.isSameDevice(device) - } - - var consumeKeyEvent = true - - // Issue #753 - if (InputEventUtils.isModifierKey(keyCode)) { - consumeKeyEvent = false - } - - val triggerKey = KeyCodeTriggerKey( - keyCode = keyCode, - device = device, - clickType = clickType, - consumeEvent = consumeKeyEvent, - detectionSource = detectionSource, - ) - - var newKeys = trigger.keys.plus(triggerKey) - - val newMode = when { - trigger.mode != TriggerMode.Sequence && containsKey -> TriggerMode.Sequence - newKeys.size <= 1 -> TriggerMode.Undefined - - /* Automatically make it a parallel trigger when the user makes a trigger with more than one key - because this is what most users are expecting when they make a trigger with multiple keys */ - newKeys.size == 2 && !containsKey -> { - newKeys = newKeys.map { it.setClickType(triggerKey.clickType) } - TriggerMode.Parallel(triggerKey.clickType) - } - - else -> trigger.mode - } - - trigger.copy(keys = newKeys, mode = newMode) - } - - override fun removeTriggerKey(uid: String) = editTrigger { trigger -> - val newKeys = trigger.keys.toMutableList().apply { - removeAll { it.uid == uid } - } - - val newMode = when { - newKeys.size <= 1 -> TriggerMode.Undefined - else -> trigger.mode - } - - trigger.copy(keys = newKeys, mode = newMode) - } - - override fun moveTriggerKey(fromIndex: Int, toIndex: Int) = editTrigger { trigger -> - trigger.copy( - keys = trigger.keys.toMutableList().apply { - add(toIndex, removeAt(fromIndex)) - }, - ) - } - - override fun getTriggerKey(uid: String): TriggerKey? { - return keyMap.value.dataOrNull()?.trigger?.keys?.find { it.uid == uid } - } - - override fun setParallelTriggerMode() = editTrigger { trigger -> - if (trigger.mode is TriggerMode.Parallel) { - return@editTrigger trigger - } - - // undefined mode only allowed if one or no keys - if (trigger.keys.size <= 1) { - return@editTrigger trigger.copy(mode = TriggerMode.Undefined) - } - - val oldKeys = trigger.keys - var newKeys = oldKeys - - // set all the keys to a short press if coming from a non-parallel trigger - // because they must all be the same click type and can't all be double pressed - newKeys = newKeys - .map { key -> key.setClickType(clickType = ClickType.SHORT_PRESS) } - // remove duplicates of keys that have the same keycode and device id - .distinctBy { key -> - when (key) { - // You can't mix assistant trigger types in a parallel trigger because there is no notion of a "down" key event, which means they can't be pressed at the same time - is AssistantTriggerKey, is FingerprintTriggerKey -> 0 - is KeyCodeTriggerKey -> Pair( - key.keyCode, - key.device, - ) - - is FloatingButtonKey -> key.buttonUid - } - } - - val newMode = if (newKeys.size <= 1) { - TriggerMode.Undefined - } else { - TriggerMode.Parallel(newKeys[0].clickType) - } - - trigger.copy(keys = newKeys, mode = newMode) - } - - override fun setSequenceTriggerMode() = editTrigger { trigger -> - if (trigger.mode == TriggerMode.Sequence) return@editTrigger trigger - // undefined mode only allowed if one or no keys - if (trigger.keys.size <= 1) { - return@editTrigger trigger.copy(mode = TriggerMode.Undefined) - } - - trigger.copy(mode = TriggerMode.Sequence) - } - - override fun setUndefinedTriggerMode() = editTrigger { trigger -> - if (trigger.mode == TriggerMode.Undefined) return@editTrigger trigger - - // undefined mode only allowed if one or no keys - if (trigger.keys.size > 1) { - return@editTrigger trigger - } - - trigger.copy(mode = TriggerMode.Undefined) - } - - override fun setTriggerShortPress() { - editTrigger { oldTrigger -> - if (oldTrigger.mode == TriggerMode.Sequence) { - return@editTrigger oldTrigger - } - - val newKeys = oldTrigger.keys.map { it.setClickType(clickType = ClickType.SHORT_PRESS) } - val newMode = if (newKeys.size <= 1) { - TriggerMode.Undefined - } else { - TriggerMode.Parallel(ClickType.SHORT_PRESS) - } - oldTrigger.copy(keys = newKeys, mode = newMode) - } - } - - override fun setTriggerLongPress() { - editTrigger { trigger -> - if (trigger.mode == TriggerMode.Sequence) { - return@editTrigger trigger - } - - // You can't set the trigger to a long press if it contains a key - // that isn't detected with key codes. This is because there aren't - // separate key events for the up and down press that can be timed. - if (trigger.keys.any { !it.allowedLongPress }) { - return@editTrigger trigger - } - - val newKeys = trigger.keys.map { it.setClickType(clickType = ClickType.LONG_PRESS) } - val newMode = if (newKeys.size <= 1) { - TriggerMode.Undefined - } else { - TriggerMode.Parallel(ClickType.LONG_PRESS) - } - - trigger.copy(keys = newKeys, mode = newMode) - } - } - - override fun setTriggerDoublePress() { - editTrigger { trigger -> - if (trigger.mode != TriggerMode.Undefined) { - return@editTrigger trigger - } - - if (trigger.keys.any { !it.allowedDoublePress }) { - return@editTrigger trigger - } - - val newKeys = trigger.keys.map { it.setClickType(clickType = ClickType.DOUBLE_PRESS) } - val newMode = TriggerMode.Undefined - - trigger.copy(keys = newKeys, mode = newMode) - } - } - - override fun setTriggerKeyClickType(keyUid: String, clickType: ClickType) { - editTriggerKey(keyUid) { key -> - key.setClickType(clickType = clickType) - } - } - - override fun setTriggerKeyDevice(keyUid: String, device: TriggerKeyDevice) { - editTriggerKey(keyUid) { key -> - if (key is KeyCodeTriggerKey) { - key.copy(device = device) - } else { - key - } - } - } - - override fun setTriggerKeyConsumeKeyEvent(keyUid: String, consumeKeyEvent: Boolean) { - editTriggerKey(keyUid) { key -> - if (key is KeyCodeTriggerKey) { - key.copy(consumeEvent = consumeKeyEvent) - } else { - key - } - } - } - - override fun setAssistantTriggerKeyType(keyUid: String, type: AssistantTriggerType) { - editTriggerKey(keyUid) { key -> - if (key is AssistantTriggerKey) { - key.copy(type = type) - } else { - key - } - } - } - - override fun setFingerprintGestureType(keyUid: String, type: FingerprintGestureType) { - editTriggerKey(keyUid) { key -> - if (key is FingerprintTriggerKey) { - key.copy(type = type) - } else { - key - } - } - } - - override fun setVibrateEnabled(enabled: Boolean) = editTrigger { it.copy(vibrate = enabled) } - - override fun setVibrationDuration(duration: Int) = editTrigger { trigger -> - if (duration == defaultVibrateDuration.value) { - trigger.copy(vibrateDuration = null) - } else { - trigger.copy(vibrateDuration = duration) - } - } - - override fun setLongPressDelay(delay: Int) = editTrigger { trigger -> - if (delay == defaultLongPressDelay.value) { - trigger.copy(longPressDelay = null) - } else { - trigger.copy(longPressDelay = delay) - } - } - - override fun setDoublePressDelay(delay: Int) { - editTrigger { trigger -> - if (delay == defaultDoublePressDelay.value) { - trigger.copy(doublePressDelay = null) - } else { - trigger.copy(doublePressDelay = delay) - } - } - } - - override fun setSequenceTriggerTimeout(delay: Int) { - editTrigger { trigger -> - if (delay == defaultSequenceTriggerTimeout.value) { - trigger.copy(sequenceTriggerTimeout = null) - } else { - trigger.copy(sequenceTriggerTimeout = delay) - } - } - } - - override fun setLongPressDoubleVibrationEnabled(enabled: Boolean) { - editTrigger { it.copy(longPressDoubleVibration = enabled) } - } - - override fun setTriggerWhenScreenOff(enabled: Boolean) { - editTrigger { it.copy(screenOffTrigger = enabled) } - } - - override fun setTriggerFromOtherAppsEnabled(enabled: Boolean) { - editTrigger { it.copy(triggerFromOtherApps = enabled) } - } - - override fun setShowToastEnabled(enabled: Boolean) { - editTrigger { it.copy(showToast = enabled) } - } - - override fun getAvailableTriggerKeyDevices(): List { - val externalTriggerKeyDevices = sequence { - val inputDevices = - devicesAdapter.connectedInputDevices.value.dataOrNull() ?: emptyList() - - val showDeviceDescriptors = showDeviceDescriptors.firstBlocking() - - inputDevices.forEach { device -> - - if (device.isExternal) { - val name = if (showDeviceDescriptors) { - InputDeviceUtils.appendDeviceDescriptorToName( - device.descriptor, - device.name, - ) - } else { - device.name - } - - yield(TriggerKeyDevice.External(device.descriptor, name)) - } - } - } - - return sequence { - yield(TriggerKeyDevice.Internal) - yield(TriggerKeyDevice.Any) - yieldAll(externalTriggerKeyDevices) - }.toList() - } - - override fun setEnabled(enabled: Boolean) { - editKeyMap { it.copy(isEnabled = enabled) } - } - - override fun setActionData(uid: String, data: ActionData) { - editKeyMap { keyMap -> - val newActionList = keyMap.actionList.map { action -> - if (action.uid == uid) { - action.copy(data = data) - } else { - action - } - } - - keyMap.copy( - actionList = newActionList, - ) - } - } - - override fun setActionRepeatEnabled(uid: String, repeat: Boolean) { - setActionOption(uid) { action -> action.copy(repeat = repeat) } - } - - override fun setActionRepeatRate(uid: String, repeatRate: Int) { - setActionOption(uid) { action -> - if (repeatRate == defaultRepeatRate.value) { - action.copy(repeatRate = null) - } else { - action.copy(repeatRate = repeatRate) - } - } - } - - override fun setActionRepeatDelay(uid: String, repeatDelay: Int) { - setActionOption(uid) { action -> - if (repeatDelay == defaultRepeatDelay.value) { - action.copy(repeatDelay = null) - } else { - action.copy(repeatDelay = repeatDelay) - } - } - } - - override fun setActionRepeatLimit(uid: String, repeatLimit: Int) { - setActionOption(uid) { action -> - if (action.repeatMode == RepeatMode.LIMIT_REACHED) { - if (repeatLimit == 1) { - action.copy(repeatLimit = null) - } else { - action.copy(repeatLimit = repeatLimit) - } - } else { - if (repeatLimit == Int.MAX_VALUE) { - action.copy(repeatLimit = null) - } else { - action.copy(repeatLimit = repeatLimit) - } - } - } - } - - override fun setActionHoldDownEnabled(uid: String, holdDown: Boolean) = - setActionOption(uid) { it.copy(holdDown = holdDown) } - - override fun setActionHoldDownDuration(uid: String, holdDownDuration: Int) { - setActionOption(uid) { action -> - if (holdDownDuration == defaultHoldDownDuration.value) { - action.copy(holdDownDuration = null) - } else { - action.copy(holdDownDuration = holdDownDuration) - } - } - } - - override fun setActionStopRepeatingWhenTriggerPressedAgain(uid: String) = - setActionOption(uid) { it.copy(repeatMode = RepeatMode.TRIGGER_PRESSED_AGAIN) } - - override fun setActionStopRepeatingWhenLimitReached(uid: String) = - setActionOption(uid) { it.copy(repeatMode = RepeatMode.LIMIT_REACHED) } - - override fun setActionStopRepeatingWhenTriggerReleased(uid: String) = - setActionOption(uid) { it.copy(repeatMode = RepeatMode.TRIGGER_RELEASED) } - - override fun setActionStopHoldingDownWhenTriggerPressedAgain(uid: String, enabled: Boolean) = - setActionOption(uid) { it.copy(stopHoldDownWhenTriggerPressedAgain = enabled) } - - override fun setActionMultiplier(uid: String, multiplier: Int) { - setActionOption(uid) { action -> - if (multiplier == 1) { - action.copy(multiplier = null) - } else { - action.copy(multiplier = multiplier) - } - } - } - - override fun setDelayBeforeNextAction(uid: String, delay: Int) { - setActionOption(uid) { action -> - if (delay == 0) { - action.copy(delayBeforeNextAction = null) - } else { - action.copy(delayBeforeNextAction = delay) - } - } - } - - private fun createAction(data: ActionData): Action { - var holdDown = false - var repeat = false - - if (data is ActionData.InputKeyEvent) { - val trigger = keyMap.value.dataOrNull()?.trigger - - val containsDpadKey: Boolean = if (trigger == null) { - false - } else { - trigger.keys - .mapNotNull { it as? KeyCodeTriggerKey } - .any { InputEventUtils.isDpadKeyCode(it.keyCode) } - } - - if (InputEventUtils.isModifierKey(data.keyCode) || containsDpadKey) { - holdDown = true - repeat = false - } else { - repeat = true - } - } - - if (data is ActionData.Volume.Down || data is ActionData.Volume.Up || data is ActionData.Volume.Stream) { - repeat = true - } - - if (data is ActionData.AnswerCall) { - addConstraint(Constraint.PhoneRinging()) - } - - if (data is ActionData.EndCall) { - addConstraint(Constraint.InPhoneCall()) - } - - return Action( - data = data, - repeat = repeat, - holdDown = holdDown, - ) - } - - private fun setActionList(actionList: List) { - editKeyMap { it.copy(actionList = actionList) } - } - - private fun setConstraintState(constraintState: ConstraintState) { - editKeyMap { it.copy(constraintState = constraintState) } - } - - override suspend fun loadKeyMap(uid: String) { - keyMap.update { State.Loading } - val entity = keyMapRepository.get(uid) ?: return - val floatingButtons = floatingButtonRepository.buttonsList - .filterIsInstance>>() - .map { it.data } - .first() - - val keyMap = KeyMapEntityMapper.fromEntity(entity, floatingButtons) - this.keyMap.update { State.Data(keyMap) } - originalKeyMap = keyMap - } - - override fun loadNewKeyMap(groupUid: String?) { - val keyMap = KeyMap(groupUid = groupUid) - this.keyMap.update { State.Data(keyMap) } - originalKeyMap = keyMap - } - - override fun save() { - val keyMap = keyMap.value.dataOrNull() ?: return - - if (keyMap.dbId == null) { - val entity = KeyMapEntityMapper.toEntity(keyMap, 0) - try { - keyMapRepository.insert(entity) - } catch (e: SQLiteConstraintException) { - keyMapRepository.update(entity) - } - } else { - keyMapRepository.update(KeyMapEntityMapper.toEntity(keyMap, keyMap.dbId)) - } - } - - override fun restoreState(keyMap: KeyMap) { - this.keyMap.value = State.Data(keyMap) - } - - override suspend fun getFloatingLayoutCount(): Int { - return floatingLayoutRepository.count() - } - - override suspend fun sendServiceEvent(event: AccessibilityServiceEvent): KMResult<*> { - return serviceAdapter.send(event) - } - - private fun setActionOption( - uid: String, - block: (action: Action) -> Action, - ) { - editKeyMap { keyMap -> - val newActionList = keyMap.actionList.map { action -> - if (action.uid == uid) { - block.invoke(action) - } else { - action - } - } - - keyMap.copy( - actionList = newActionList, - ) - } - } - - private suspend fun getActionShortcuts(json: String?): List { - if (json == null) { - return emptyList() - } - - try { - return withContext(Dispatchers.Default) { - val list = Json.decodeFromString>(json) - - list.distinct() - } - } catch (_: Exception) { - preferenceRepository.set(Keys.recentlyUsedActions, null) - return emptyList() - } - } - - private suspend fun getConstraintShortcuts(json: String?): List { - if (json == null) { - return emptyList() - } - - try { - return withContext(Dispatchers.Default) { - val list = Json.decodeFromString>(json) - - list.distinct() - } - } catch (_: Exception) { - preferenceRepository.set(Keys.recentlyUsedConstraints, null) - return emptyList() - } - } - - private inline fun editTrigger(block: (trigger: Trigger) -> Trigger) { - editKeyMap { keyMap -> - val newTrigger = block(keyMap.trigger) - - keyMap.copy(trigger = newTrigger) - } - } - - private fun editTriggerKey(uid: String, block: (key: TriggerKey) -> TriggerKey) { - editTrigger { oldTrigger -> - val newKeys = oldTrigger.keys.map { - if (it.uid == uid) { - block.invoke(it) - } else { - it - } - } - - oldTrigger.copy(keys = newKeys) - } - } - - private inline fun editKeyMap(block: (keymap: KeyMap) -> KeyMap) { - keyMap.value.ifIsData { keyMap.value = State.Data(block.invoke(it)) } - } -} - -interface ConfigKeyMapUseCase : GetDefaultKeyMapOptionsUseCase { - val keyMap: Flow> - val isEdited: Boolean - - fun save() - - fun setEnabled(enabled: Boolean) - - fun addAction(data: ActionData) - fun moveAction(fromIndex: Int, toIndex: Int) - fun removeAction(uid: String) - - val recentlyUsedActions: Flow> - fun setActionData(uid: String, data: ActionData) - fun setActionMultiplier(uid: String, multiplier: Int) - fun setDelayBeforeNextAction(uid: String, delay: Int) - fun setActionRepeatRate(uid: String, repeatRate: Int) - fun setActionRepeatLimit(uid: String, repeatLimit: Int) - fun setActionStopRepeatingWhenTriggerPressedAgain(uid: String) - fun setActionStopRepeatingWhenLimitReached(uid: String) - fun setActionRepeatEnabled(uid: String, repeat: Boolean) - fun setActionRepeatDelay(uid: String, repeatDelay: Int) - fun setActionHoldDownEnabled(uid: String, holdDown: Boolean) - fun setActionHoldDownDuration(uid: String, holdDownDuration: Int) - fun setActionStopRepeatingWhenTriggerReleased(uid: String) - - fun setActionStopHoldingDownWhenTriggerPressedAgain(uid: String, enabled: Boolean) - - val recentlyUsedConstraints: Flow> - fun addConstraint(constraint: Constraint): Boolean - fun removeConstraint(id: String) - fun setAndMode() - fun setOrMode() - suspend fun sendServiceEvent(event: AccessibilityServiceEvent): KMResult<*> - - // trigger - fun addKeyCodeTriggerKey( - keyCode: Int, - device: TriggerKeyDevice, - detectionSource: KeyEventDetectionSource, - ) - - suspend fun addFloatingButtonTriggerKey(buttonUid: String) - fun addAssistantTriggerKey(type: AssistantTriggerType) - fun addFingerprintGesture(type: FingerprintGestureType) - fun removeTriggerKey(uid: String) - fun getTriggerKey(uid: String): TriggerKey? - fun moveTriggerKey(fromIndex: Int, toIndex: Int) - - fun restoreState(keyMap: KeyMap) - suspend fun loadKeyMap(uid: String) - fun loadNewKeyMap(groupUid: String?) - - fun setParallelTriggerMode() - fun setSequenceTriggerMode() - fun setUndefinedTriggerMode() - - fun setTriggerShortPress() - fun setTriggerLongPress() - fun setTriggerDoublePress() - - fun setTriggerKeyClickType(keyUid: String, clickType: ClickType) - fun setTriggerKeyDevice(keyUid: String, device: TriggerKeyDevice) - fun setTriggerKeyConsumeKeyEvent(keyUid: String, consumeKeyEvent: Boolean) - fun setAssistantTriggerKeyType(keyUid: String, type: AssistantTriggerType) - fun setFingerprintGestureType(keyUid: String, type: FingerprintGestureType) - - fun setVibrateEnabled(enabled: Boolean) - fun setVibrationDuration(duration: Int) - fun setLongPressDelay(delay: Int) - fun setDoublePressDelay(delay: Int) - fun setSequenceTriggerTimeout(delay: Int) - fun setLongPressDoubleVibrationEnabled(enabled: Boolean) - fun setTriggerWhenScreenOff(enabled: Boolean) - fun setTriggerFromOtherAppsEnabled(enabled: Boolean) - fun setShowToastEnabled(enabled: Boolean) - - fun getAvailableTriggerKeyDevices(): List - - val floatingButtonToUse: MutableStateFlow - suspend fun getFloatingLayoutCount(): Int -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapViewModel.kt new file mode 100644 index 0000000000..3a478e8f0f --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapViewModel.kt @@ -0,0 +1,79 @@ +package io.github.sds100.keymapper.base.keymaps + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.onboarding.OnboardingTapTarget +import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase +import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCase +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.ui.DialogProvider +import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.dataOrNull +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class ConfigKeyMapViewModel @Inject constructor( + private val configKeyMapState: ConfigKeyMapState, + private val configTrigger: ConfigTriggerUseCase, + private val onboarding: OnboardingUseCase, + navigationProvider: NavigationProvider, + dialogProvider: DialogProvider, +) : ViewModel(), + NavigationProvider by navigationProvider, + DialogProvider by dialogProvider { + + val isKeyMapEdited: Boolean + get() = configKeyMapState.isEdited + + val isEnabled: StateFlow = configTrigger.keyMap + .map { state -> state.dataOrNull()?.isEnabled ?: true } + .stateIn(viewModelScope, SharingStarted.Eagerly, true) + + val showActionsTapTarget: StateFlow = + combine( + onboarding.showTapTarget(OnboardingTapTarget.CHOOSE_ACTION), + configKeyMapState.keyMap.filterIsInstance>(), + ) { showTapTarget, keyMapState -> + // Show the choose action tap target if they have recorded a key and + // have no actions. + showTapTarget && + keyMapState.data.trigger.keys.isNotEmpty() && + keyMapState.data.actionList.isEmpty() + }.stateIn(viewModelScope, SharingStarted.Lazily, false) + + fun onDoneClick() { + configKeyMapState.save() + + viewModelScope.launch { + popBackStack() + } + } + + fun loadNewKeyMap(groupUid: String?) { + configKeyMapState.loadNewKeyMap(groupUid) + } + + fun loadKeyMap(uid: String) { + viewModelScope.launch { + configKeyMapState.loadKeyMap(uid) + } + } + + fun onBackClick() { + viewModelScope.launch { + popBackStack() + } + } + + fun onEnabledChanged(enabled: Boolean) { + configTrigger.setEnabled(enabled) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/DisplayKeyMapUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/DisplayKeyMapUseCase.kt index d4632d4e2b..8cda19f4fa 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/DisplayKeyMapUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/DisplayKeyMapUseCase.kt @@ -1,18 +1,25 @@ package io.github.sds100.keymapper.base.keymaps import android.graphics.drawable.Drawable +import android.os.Build import dagger.hilt.android.scopes.ViewModelScoped import io.github.sds100.keymapper.base.actions.DisplayActionUseCase import io.github.sds100.keymapper.base.actions.GetActionErrorUseCase import io.github.sds100.keymapper.base.constraints.DisplayConstraintUseCase import io.github.sds100.keymapper.base.constraints.GetConstraintErrorUseCase +import io.github.sds100.keymapper.base.input.EvdevHandleCache import io.github.sds100.keymapper.base.purchasing.ProductId -import io.github.sds100.keymapper.base.purchasing.PurchasingError +import io.github.sds100.keymapper.base.purchasing.PurchasingError.ProductNotPurchased import io.github.sds100.keymapper.base.purchasing.PurchasingManager import io.github.sds100.keymapper.base.system.inputmethod.KeyMapperImeHelper +import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface import io.github.sds100.keymapper.base.trigger.TriggerError import io.github.sds100.keymapper.base.trigger.TriggerErrorSnapshot +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.common.BuildConfigProvider +import io.github.sds100.keymapper.common.models.EvdevDeviceInfo import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State @@ -23,7 +30,11 @@ import io.github.sds100.keymapper.common.utils.then import io.github.sds100.keymapper.common.utils.valueIfFailure import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository -import io.github.sds100.keymapper.system.SystemError +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.SystemError.ImeDisabled +import io.github.sds100.keymapper.system.SystemError.PermissionDenied import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter import io.github.sds100.keymapper.system.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter @@ -31,20 +42,23 @@ import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.ringtones.RingtoneAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuUtils +import javax.inject.Inject import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.withTimeout -import javax.inject.Inject @ViewModelScoped class DisplayKeyMapUseCaseImpl @Inject constructor( private val permissionAdapter: PermissionAdapter, + private val switchImeInterface: SwitchImeInterface, private val inputMethodAdapter: InputMethodAdapter, private val packageManagerAdapter: PackageManagerAdapter, private val settingsRepository: PreferenceRepository, @@ -54,11 +68,14 @@ class DisplayKeyMapUseCaseImpl @Inject constructor( private val getActionErrorUseCase: GetActionErrorUseCase, private val getConstraintErrorUseCase: GetConstraintErrorUseCase, private val buildConfigProvider: BuildConfigProvider, + private val navigationProvider: NavigationProvider, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val evdevHandleCache: EvdevHandleCache, ) : DisplayKeyMapUseCase, GetActionErrorUseCase by getActionErrorUseCase, GetConstraintErrorUseCase by getConstraintErrorUseCase { private val keyMapperImeHelper = - KeyMapperImeHelper(inputMethodAdapter, buildConfigProvider.packageName) + KeyMapperImeHelper(switchImeInterface, inputMethodAdapter, buildConfigProvider.packageName) private val showDpadImeSetupError: Flow = settingsRepository.get(Keys.neverShowDpadImeTriggerError).map { neverShow -> @@ -87,41 +104,48 @@ class DisplayKeyMapUseCaseImpl @Inject constructor( purchasingManager.purchases.collect(this::send) } + private val systemBridgeConnectionState: Flow = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + systemBridgeConnectionManager.connectionState + } else { + flowOf(null) + } + + private val evdevDevices: Flow?> = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + evdevHandleCache.devices + } else { + flowOf(null) + } + /** * Cache the data required for checking errors to reduce the latency of repeatedly checking * the errors. */ override val triggerErrorSnapshot: Flow = combine( - permissionAdapter.onPermissionsUpdate.onStart { emit(Unit) }, + merge( + permissionAdapter.onPermissionsUpdate.onStart { emit(Unit) }, + inputMethodAdapter.chosenIme, + ), purchasesFlow, - inputMethodAdapter.chosenIme, showDpadImeSetupError, - ) { _, purchases, _, showDpadImeSetupError -> + systemBridgeConnectionState, + evdevDevices, + ) { _, purchases, showDpadImeSetupError, sysBridgeState, evdevDevices -> TriggerErrorSnapshot( isKeyMapperImeChosen = keyMapperImeHelper.isCompatibleImeChosen(), isDndAccessGranted = permissionAdapter.isGranted(Permission.ACCESS_NOTIFICATION_POLICY), isRootGranted = permissionAdapter.isGranted(Permission.ROOT), purchases = purchases.dataOrNull() ?: Success(emptySet()), showDpadImeSetupError = showDpadImeSetupError, + isSystemBridgeConnected = sysBridgeState is SystemBridgeConnectionState.Connected, + evdevDevices = evdevDevices, ) } - override val showTriggerKeyboardIconExplanation: Flow = - settingsRepository.get(Keys.neverShowTriggerKeyboardIconExplanation).map { neverShow -> - if (neverShow == null) { - true - } else { - !neverShow - } - } - override val showDeviceDescriptors: Flow = settingsRepository.get(Keys.showDeviceDescriptors).map { it == true } - override fun neverShowTriggerKeyboardIconExplanation() { - settingsRepository.set(Keys.neverShowTriggerKeyboardIconExplanation, true) - } - override fun neverShowDpadImeSetupError() { settingsRepository.set(Keys.neverShowDpadImeTriggerError, true) } @@ -132,38 +156,45 @@ class DisplayKeyMapUseCaseImpl @Inject constructor( override suspend fun fixTriggerError(error: TriggerError) { when (error) { - TriggerError.DND_ACCESS_DENIED -> fixError(SystemError.PermissionDenied(Permission.ACCESS_NOTIFICATION_POLICY)) - TriggerError.SCREEN_OFF_ROOT_DENIED -> fixError( - SystemError.PermissionDenied( - Permission.ROOT, - ), + TriggerError.DND_ACCESS_DENIED -> fixError( + PermissionDenied(Permission.ACCESS_NOTIFICATION_POLICY), ) - TriggerError.CANT_DETECT_IN_PHONE_CALL -> fixError(KMError.CantDetectKeyEventsInPhoneCall) + TriggerError.CANT_DETECT_IN_PHONE_CALL -> fixError( + KMError.CantDetectKeyEventsInPhoneCall, + ) TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> fixError( - PurchasingError.ProductNotPurchased( + ProductNotPurchased( ProductId.ASSISTANT_TRIGGER, ), ) TriggerError.DPAD_IME_NOT_SELECTED -> fixError(KMError.DpadTriggerImeNotSelected) - TriggerError.FLOATING_BUTTON_DELETED -> {} TriggerError.FLOATING_BUTTONS_NOT_PURCHASED -> fixError( - PurchasingError.ProductNotPurchased( + ProductNotPurchased( ProductId.FLOATING_BUTTONS, ), ) TriggerError.PURCHASE_VERIFICATION_FAILED -> purchasingManager.refresh() + TriggerError.SYSTEM_BRIDGE_DISCONNECTED -> fixError(SystemBridgeError.Disconnected) + TriggerError.EVDEV_DEVICE_NOT_FOUND, + TriggerError.FLOATING_BUTTON_DELETED, + TriggerError.SYSTEM_BRIDGE_UNSUPPORTED, + -> {} } } - override fun getAppName(packageName: String): KMResult = packageManagerAdapter.getAppName(packageName) + override fun getAppName(packageName: String): KMResult = + packageManagerAdapter.getAppName(packageName) - override fun getAppIcon(packageName: String): KMResult = packageManagerAdapter.getAppIcon(packageName) + override fun getAppIcon(packageName: String): KMResult = + packageManagerAdapter.getAppIcon(packageName) override fun getInputMethodLabel(imeId: String): KMResult = - inputMethodAdapter.getInfoById(imeId).then { Success(it.label) } + inputMethodAdapter.getInfoById(imeId).then { + Success(it.label) + } override suspend fun fixError(error: KMError) { when (error) { @@ -175,9 +206,11 @@ class DisplayKeyMapUseCaseImpl @Inject constructor( } KMError.NoCompatibleImeEnabled -> keyMapperImeHelper.enableCompatibleInputMethods() - is SystemError.ImeDisabled -> inputMethodAdapter.enableIme(error.ime.id) - is SystemError.PermissionDenied -> permissionAdapter.request(error.permission) - is KMError.ShizukuNotStarted -> packageManagerAdapter.openApp(ShizukuUtils.SHIZUKU_PACKAGE) + is ImeDisabled -> switchImeInterface.enableIme(error.ime.id) + is PermissionDenied -> permissionAdapter.request(error.permission) + is KMError.ShizukuNotStarted -> packageManagerAdapter.openApp( + ShizukuUtils.SHIZUKU_PACKAGE, + ) is KMError.CantDetectKeyEventsInPhoneCall -> { if (!keyMapperImeHelper.isCompatibleImeEnabled()) { keyMapperImeHelper.enableCompatibleInputMethods() @@ -191,6 +224,19 @@ class DisplayKeyMapUseCaseImpl @Inject constructor( } } + is SystemBridgeError.Disconnected -> navigationProvider.navigate( + "fix_system_bridge", + NavDestination.ProMode, + ) + + is KMError.DpadTriggerImeNotSelected -> { + if (keyMapperImeHelper.isCompatibleImeEnabled()) { + keyMapperImeHelper.chooseCompatibleInputMethod() + } else { + keyMapperImeHelper.enableCompatibleInputMethods() + } + } + else -> Unit } } @@ -215,8 +261,6 @@ interface DisplayKeyMapUseCase : val triggerErrorSnapshot: Flow suspend fun isFloatingButtonsPurchased(): Boolean suspend fun fixTriggerError(error: TriggerError) - val showTriggerKeyboardIconExplanation: Flow - fun neverShowTriggerKeyboardIconExplanation() override val showDeviceDescriptors: Flow fun neverShowDpadImeSetupError() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableKeyMapsUseCase.kt new file mode 100644 index 0000000000..f42428f823 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableKeyMapsUseCase.kt @@ -0,0 +1,28 @@ +package io.github.sds100.keymapper.base.keymaps + +import io.github.sds100.keymapper.data.repositories.KeyMapRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class EnableKeyMapsUseCaseImpl @Inject constructor(private val keyMapRepository: KeyMapRepository) : + EnableKeyMapsUseCase { + + override fun enable(uid: String) { + keyMapRepository.enableById(uid) + } + + override fun toggle(uid: String) { + keyMapRepository.toggleById(uid) + } + + override fun disable(uid: String) { + keyMapRepository.disableById(uid) + } +} + +interface EnableKeyMapsUseCase { + fun enable(uid: String) + fun toggle(uid: String) + fun disable(uid: String) +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/FingerprintGesturesSupportedUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/FingerprintGesturesSupportedUseCase.kt index 84b2792f31..5a53aee7f3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/FingerprintGesturesSupportedUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/FingerprintGesturesSupportedUseCase.kt @@ -3,10 +3,10 @@ package io.github.sds100.keymapper.base.keymaps import android.os.Build import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map @Singleton class FingerprintGesturesSupportedUseCaseImpl @Inject constructor( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/GetDefaultKeyMapOptionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/GetDefaultKeyMapOptionsUseCase.kt index 11e3b89199..89fa437515 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/GetDefaultKeyMapOptionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/GetDefaultKeyMapOptionsUseCase.kt @@ -3,13 +3,16 @@ package io.github.sds100.keymapper.base.keymaps import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.PreferenceDefaults import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -class GetDefaultKeyMapOptionsUseCaseImpl( +@Singleton +class GetDefaultKeyMapOptionsUseCaseImpl @Inject constructor( coroutineScope: CoroutineScope, preferenceRepository: PreferenceRepository, ) : GetDefaultKeyMapOptionsUseCase { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt index c0e05384aa..dbfee5352f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt @@ -8,14 +8,14 @@ import io.github.sds100.keymapper.base.actions.canBeHeldDown import io.github.sds100.keymapper.base.constraints.ConstraintEntityMapper import io.github.sds100.keymapper.base.constraints.ConstraintModeEntityMapper import io.github.sds100.keymapper.base.constraints.ConstraintState -import io.github.sds100.keymapper.base.keymaps.detection.KeyMapController +import io.github.sds100.keymapper.base.detection.KeyMapAlgorithm import io.github.sds100.keymapper.base.trigger.Trigger import io.github.sds100.keymapper.base.trigger.TriggerEntityMapper import io.github.sds100.keymapper.base.trigger.TriggerKey import io.github.sds100.keymapper.data.entities.FloatingButtonEntityWithLayout import io.github.sds100.keymapper.data.entities.KeyMapEntity -import kotlinx.serialization.Serializable import java.util.UUID +import kotlinx.serialization.Serializable @Serializable data class KeyMap( @@ -37,21 +37,28 @@ data class KeyMap( val vibrateDuration: Int? get() = trigger.vibrateDuration - fun isRepeatingActionsAllowed(): Boolean = KeyMapController.performActionOnDown(trigger) + fun isRepeatingActionsAllowed(): Boolean = KeyMapAlgorithm.performActionOnDown(trigger) - fun isChangingActionRepeatRateAllowed(action: Action): Boolean = action.repeat && isRepeatingActionsAllowed() + fun isChangingActionRepeatRateAllowed(action: Action): Boolean = + action.repeat && isRepeatingActionsAllowed() - fun isChangingActionRepeatDelayAllowed(action: Action): Boolean = action.repeat && isRepeatingActionsAllowed() + fun isChangingActionRepeatDelayAllowed(action: Action): Boolean = + action.repeat && isRepeatingActionsAllowed() - fun isHoldingDownActionAllowed(action: Action): Boolean = KeyMapController.performActionOnDown(trigger) && action.data.canBeHeldDown() + fun isHoldingDownActionAllowed(action: Action): Boolean = + KeyMapAlgorithm.performActionOnDown(trigger) && action.data.canBeHeldDown() - fun isHoldingDownActionBeforeRepeatingAllowed(action: Action): Boolean = action.repeat && action.holdDown + fun isHoldingDownActionBeforeRepeatingAllowed(action: Action): Boolean = + action.repeat && action.holdDown - fun isChangingRepeatModeAllowed(action: Action): Boolean = action.repeat && isRepeatingActionsAllowed() + fun isChangingRepeatModeAllowed(action: Action): Boolean = + action.repeat && isRepeatingActionsAllowed() - fun isChangingRepeatLimitAllowed(action: Action): Boolean = action.repeat && isRepeatingActionsAllowed() + fun isChangingRepeatLimitAllowed(action: Action): Boolean = + action.repeat && isRepeatingActionsAllowed() - fun isStopHoldingDownActionWhenTriggerPressedAgainAllowed(action: Action): Boolean = action.holdDown && !action.repeat + fun isStopHoldingDownActionWhenTriggerPressedAgainAllowed(action: Action): Boolean = + action.holdDown && !action.repeat fun isDelayBeforeNextActionAllowed(): Boolean = actionList.isNotEmpty() } @@ -67,7 +74,7 @@ fun KeyMap.requiresImeKeyEventForwarding(): Boolean { actionList.any { it.data is ActionData.AnswerCall || it.data is ActionData.EndCall } val hasVolumeKeys = trigger.keys - .mapNotNull { it as? io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey } + .mapNotNull { it as? io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey } .any { it.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || it.keyCode == KeyEvent.KEYCODE_VOLUME_UP @@ -83,7 +90,7 @@ fun KeyMap.requiresImeKeyEventForwarding(): Boolean { * is incoming. */ fun KeyMap.requiresImeKeyEventForwardingInPhoneCall(triggerKey: TriggerKey): Boolean { - if (triggerKey !is io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey) { + if (triggerKey !is io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey) { return false } @@ -91,7 +98,7 @@ fun KeyMap.requiresImeKeyEventForwardingInPhoneCall(triggerKey: TriggerKey): Boo actionList.any { it.data is ActionData.AnswerCall || it.data is ActionData.EndCall } val hasVolumeKeys = trigger.keys - .mapNotNull { it as? io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey } + .mapNotNull { it as? io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey } .any { it.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || it.keyCode == KeyEvent.KEYCODE_VOLUME_UP diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt index e2a06d926d..6d78c9bce2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt @@ -114,18 +114,6 @@ private fun Loaded( Spacer(Modifier.height(8.dp)) - if (state.showScreenOffTrigger) { - CheckBoxText( - modifier = Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - text = stringResource(R.string.flag_detect_triggers_screen_off), - isChecked = state.screenOffTrigger, - onCheckedChange = callback::onScreenOffTriggerChanged, - ) - Spacer(Modifier.height(8.dp)) - } - CheckBoxText( modifier = Modifier .padding(horizontal = 8.dp) @@ -149,6 +137,8 @@ private fun Loaded( } if (state.showVibrateDuration) { + val vibrateDurationMin = SliderMinimums.VIBRATION_DURATION + val vibrateDurationMax = SliderMaximums.VIBRATION_DURATION SliderOptionText( modifier = Modifier .fillMaxWidth() @@ -158,7 +148,7 @@ private fun Loaded( value = state.vibrateDuration.toFloat(), valueText = { "${it.toInt()} ms" }, onValueChange = { callback.onVibrateDurationChanged(it.toInt()) }, - valueRange = SliderMinimums.VIBRATION_DURATION.toFloat()..SliderMaximums.VIBRATION_DURATION.toFloat(), + valueRange = vibrateDurationMin.toFloat()..vibrateDurationMax.toFloat(), stepSize = SliderStepSizes.VIBRATION_DURATION, ) Spacer(Modifier.height(8.dp)) @@ -177,6 +167,8 @@ private fun Loaded( } if (state.showLongPressDelay) { + val longPressDelayMin = SliderMinimums.TRIGGER_LONG_PRESS_DELAY + val longPressDelayMax = SliderMaximums.TRIGGER_LONG_PRESS_DELAY SliderOptionText( modifier = Modifier .fillMaxWidth() @@ -186,13 +178,15 @@ private fun Loaded( value = state.longPressDelay.toFloat(), valueText = { "${it.toInt()} ms" }, onValueChange = { callback.onLongPressDelayChanged(it.toInt()) }, - valueRange = SliderMinimums.TRIGGER_LONG_PRESS_DELAY.toFloat()..SliderMaximums.TRIGGER_LONG_PRESS_DELAY.toFloat(), + valueRange = longPressDelayMin.toFloat()..longPressDelayMax.toFloat(), stepSize = SliderStepSizes.TRIGGER_LONG_PRESS_DELAY, ) Spacer(Modifier.height(8.dp)) } if (state.showDoublePressDelay) { + val doublePressDelayMin = SliderMinimums.TRIGGER_DOUBLE_PRESS_DELAY + val doublePressDelayMax = SliderMaximums.TRIGGER_DOUBLE_PRESS_DELAY SliderOptionText( modifier = Modifier .fillMaxWidth() @@ -202,13 +196,17 @@ private fun Loaded( value = state.doublePressDelay.toFloat(), valueText = { "${it.toInt()} ms" }, onValueChange = { callback.onDoublePressDelayChanged(it.toInt()) }, - valueRange = SliderMinimums.TRIGGER_DOUBLE_PRESS_DELAY.toFloat()..SliderMaximums.TRIGGER_DOUBLE_PRESS_DELAY.toFloat(), + valueRange = doublePressDelayMin.toFloat()..doublePressDelayMax.toFloat(), stepSize = SliderStepSizes.TRIGGER_DOUBLE_PRESS_DELAY, ) Spacer(Modifier.height(8.dp)) } if (state.showSequenceTriggerTimeout) { + val sequenceTriggerTimeoutMin = + SliderMinimums.TRIGGER_SEQUENCE_TRIGGER_TIMEOUT.toFloat() + val sequenceTriggerTimeoutMax = + SliderMaximums.TRIGGER_SEQUENCE_TRIGGER_TIMEOUT.toFloat() SliderOptionText( modifier = Modifier .fillMaxWidth() @@ -218,7 +216,7 @@ private fun Loaded( value = state.sequenceTriggerTimeout.toFloat(), valueText = { "${it.toInt()} ms" }, onValueChange = { callback.onSequenceTriggerTimeoutChanged(it.toInt()) }, - valueRange = SliderMinimums.TRIGGER_SEQUENCE_TRIGGER_TIMEOUT.toFloat()..SliderMaximums.TRIGGER_SEQUENCE_TRIGGER_TIMEOUT.toFloat(), + valueRange = sequenceTriggerTimeoutMin..sequenceTriggerTimeoutMax, stepSize = SliderStepSizes.TRIGGER_SEQUENCE_TRIGGER_TIMEOUT, ) Spacer(Modifier.height(8.dp)) @@ -303,7 +301,9 @@ private fun TriggerFromOtherAppsSection( ) { Icon( imageVector = Icons.Rounded.ContentCopy, - contentDescription = stringResource(R.string.flag_trigger_from_other_apps_copy_uid), + contentDescription = stringResource( + R.string.flag_trigger_from_other_apps_copy_uid, + ), ) } } @@ -338,7 +338,11 @@ private fun TriggerFromOtherAppsSection( uriHandler.openUriSafe(ctx, intentGuideUrl) }, ) { - Text(text = stringResource(R.string.button_open_trigger_keymap_from_intent_guide)) + Text( + text = stringResource( + R.string.button_open_trigger_keymap_from_intent_guide, + ), + ) } } } @@ -352,7 +356,6 @@ interface KeyMapOptionsCallback { fun onVibrateDurationChanged(duration: Int) = run { } fun onVibrateChanged(checked: Boolean) = run { } fun onLongPressDoubleVibrationChanged(checked: Boolean) = run { } - fun onScreenOffTriggerChanged(checked: Boolean) = run { } fun onShowToastChanged(checked: Boolean) = run { } fun onTriggerFromOtherAppsChanged(checked: Boolean) = run {} fun onCreateShortcutClick() = run { } @@ -388,9 +391,6 @@ private fun Preview() { showLongPressDoubleVibration = true, longPressDoubleVibration = false, - showScreenOffTrigger = true, - screenOffTrigger = false, - triggerFromOtherApps = true, keyMapUid = "00000-00000-00000-0000000000000000000000000000000000", isLauncherShortcutButtonEnabled = false, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/PauseKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/PauseKeyMapsUseCase.kt index dd05855e78..9143f129a8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/PauseKeyMapsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/PauseKeyMapsUseCase.kt @@ -4,11 +4,11 @@ import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.system.media.MediaAdapter import io.github.sds100.keymapper.system.ringtones.RingtoneAdapter +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton @Singleton class PauseKeyMapsUseCaseImpl @Inject constructor( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ShortcutModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ShortcutModel.kt index 3d964bcf9a..1a2d446285 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ShortcutModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ShortcutModel.kt @@ -2,8 +2,4 @@ package io.github.sds100.keymapper.base.keymaps import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo -data class ShortcutModel( - val icon: ComposeIconInfo, - val text: String, - val data: T, -) +data class ShortcutModel(val icon: ComposeIconInfo, val text: String, val data: T) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ShortcutRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ShortcutRow.kt index 256a1781f8..02d9078fff 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ShortcutRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ShortcutRow.kt @@ -26,7 +26,6 @@ import androidx.compose.ui.unit.dp import com.google.accompanist.drawablepainter.rememberDrawablePainter import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme -import io.github.sds100.keymapper.base.trigger.TriggerKeyShortcut import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.drawable @@ -76,7 +75,7 @@ fun ShortcutRow( } @Composable -private fun ShortcutButton( +fun ShortcutButton( modifier: Modifier = Modifier, onClick: () -> Unit, text: String, @@ -114,8 +113,10 @@ private fun PreviewVector() { shortcuts = setOf( ShortcutModel( icon = ComposeIconInfo.Vector(Icons.Rounded.Fingerprint), - text = stringResource(R.string.trigger_key_shortcut_add_fingerprint_gesture), - data = TriggerKeyShortcut.FINGERPRINT_GESTURE, + text = stringResource( + R.string.trigger_key_shortcut_add_fingerprint_gesture, + ), + data = "", ), ), ) @@ -135,8 +136,10 @@ private fun PreviewDrawable() { shortcuts = setOf( ShortcutModel( icon = ComposeIconInfo.Drawable(icon), - text = stringResource(R.string.trigger_key_shortcut_add_fingerprint_gesture), - data = TriggerKeyShortcut.FINGERPRINT_GESTURE, + text = stringResource( + R.string.trigger_key_shortcut_add_fingerprint_gesture, + ), + data = "", ), ), ) @@ -157,7 +160,7 @@ private fun PreviewMultipleLines() { ShortcutModel( icon = ComposeIconInfo.Drawable(icon), text = "Line 1\nLine 2\nLine 3", - data = TriggerKeyShortcut.FINGERPRINT_GESTURE, + data = "", ), ), ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/DetectScreenOffKeyEventsController.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/DetectScreenOffKeyEventsController.kt deleted file mode 100644 index bd56ca6ef0..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/detection/DetectScreenOffKeyEventsController.kt +++ /dev/null @@ -1,134 +0,0 @@ -package io.github.sds100.keymapper.base.keymaps.detection - -import android.view.InputDevice -import android.view.KeyEvent -import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.common.utils.valueOrNull -import io.github.sds100.keymapper.system.devices.DevicesAdapter -import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import io.github.sds100.keymapper.system.inputevents.InputEventUtils -import io.github.sds100.keymapper.system.inputevents.MyKeyEvent -import io.github.sds100.keymapper.system.root.SuAdapter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import timber.log.Timber - -class DetectScreenOffKeyEventsController( - private val suAdapter: SuAdapter, - private val devicesAdapter: DevicesAdapter, - private val onKeyEvent: suspend (event: MyKeyEvent) -> Unit, -) { - - companion object { - private const val REGEX_GET_DEVICE_LOCATION = "/.*(?=:)" - private const val REGEX_KEY_EVENT_ACTION = "(?<= )(DOWN|UP)" - } - - private var job: Job? = null - - /** - * @return whether it successfully started listening. - */ - fun startListening(scope: CoroutineScope): Boolean { - try { - job = scope.launch(Dispatchers.IO) { - val devicesInputStream = - suAdapter.getCommandOutput("getevent -i").valueOrNull() ?: return@launch - - val getEventDevices: String = devicesInputStream.bufferedReader().readText() - devicesInputStream.close() - - val deviceLocationToDeviceMap = mutableMapOf() - - val inputDevices = - devicesAdapter.connectedInputDevices.first { it is State.Data } as State.Data - - inputDevices.data.forEach { device -> - val deviceLocation = - getDeviceLocation(getEventDevices, device.name) ?: return@forEach - - deviceLocationToDeviceMap[deviceLocation] = device - } - - val deviceLocationRegex = Regex(REGEX_GET_DEVICE_LOCATION) - val actionRegex = Regex(REGEX_KEY_EVENT_ACTION) - - // use -q option to not initially output the list of devices - val inputStream = - suAdapter.getCommandOutput("getevent -lq").valueOrNull() ?: return@launch - - var line: String? - - while (inputStream.bufferedReader().readLine() - .also { line = it } != null && - isActive - ) { - line ?: continue - - InputEventUtils.GET_EVENT_LABEL_TO_KEYCODE.forEach { (label, keyCode) -> - if (line!!.contains(label)) { - val deviceLocation = - deviceLocationRegex.find(line!!)?.value ?: return@forEach - - val device = deviceLocationToDeviceMap[deviceLocation] ?: return@forEach - - val actionString = actionRegex.find(line!!)?.value ?: return@forEach - - when (actionString) { - "UP" -> { - onKeyEvent.invoke( - MyKeyEvent( - keyCode = keyCode, - action = KeyEvent.ACTION_UP, - device = device, - scanCode = 0, - metaState = 0, - repeatCount = 0, - source = InputDevice.SOURCE_UNKNOWN, - ), - ) - } - - "DOWN" -> { - onKeyEvent.invoke( - MyKeyEvent( - keyCode = keyCode, - action = KeyEvent.ACTION_DOWN, - device = device, - scanCode = 0, - metaState = 0, - repeatCount = 0, - source = InputDevice.SOURCE_UNKNOWN, - ), - ) - } - } - } - } - } - - inputStream.close() - } - } catch (e: Exception) { - Timber.e(e) - job?.cancel() - return false - } - - return true - } - - fun stopListening() { - job?.cancel() - job = null - } - - private fun getDeviceLocation(getEventDeviceOutput: String, deviceName: String): String? { - val regex = Regex("(/.*)(?=(\\n.*){5}\"$deviceName\")") - return regex.find(getEventDeviceOutput)?.value - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/DisplayLogUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/DisplayLogUseCase.kt index 0ffde83f82..fee1623fc8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/logging/DisplayLogUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/logging/DisplayLogUseCase.kt @@ -2,18 +2,19 @@ package io.github.sds100.keymapper.base.logging import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.utils.ui.ResourceProvider -import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.common.utils.ifIsData -import io.github.sds100.keymapper.common.utils.mapData +import io.github.sds100.keymapper.data.entities.LogEntryEntity import io.github.sds100.keymapper.data.repositories.LogRepository import io.github.sds100.keymapper.system.clipboard.ClipboardAdapter import io.github.sds100.keymapper.system.files.FileAdapter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import javax.inject.Inject class DisplayLogUseCaseImpl @Inject constructor( private val repository: LogRepository, @@ -21,41 +22,43 @@ class DisplayLogUseCaseImpl @Inject constructor( private val clipboardAdapter: ClipboardAdapter, private val fileAdapter: FileAdapter, ) : DisplayLogUseCase { - override val log: Flow>> = repository.log - .map { state -> - state.mapData { entityList -> entityList.map { LogEntryEntityMapper.fromEntity(it) } } - } + private val dateFormat = SimpleDateFormat("MM/dd HH:mm:ss.SSS", Locale.getDefault()) + private val severityString: Map = mapOf( + LogEntryEntity.SEVERITY_ERROR to "ERROR", + LogEntryEntity.SEVERITY_WARNING to "WARN", + LogEntryEntity.SEVERITY_INFO to "INFO", + LogEntryEntity.SEVERITY_DEBUG to "DEBUG", + ) + + override val log: Flow> = repository.log + .map { entityList -> entityList.map { LogEntryEntityMapper.fromEntity(it) } } .flowOn(Dispatchers.Default) override fun clearLog() { repository.deleteAll() } - override suspend fun copyToClipboard(entryId: Set) { - repository.log.first().ifIsData { logEntries -> - val logText = LogUtils.createLogText(logEntries.filter { it.id in entryId }) + override suspend fun copyToClipboard() { + val logEntries = repository.log.first() + val logText = createLogText(logEntries) - clipboardAdapter.copy( - label = resourceProvider.getString(R.string.clip_key_mapper_log), - logText, - ) - } + clipboardAdapter.copy( + label = resourceProvider.getString(R.string.clip_key_mapper_log), + logText, + ) } - override suspend fun saveToFile(uri: String, entryId: Set) { - val file = fileAdapter.getFileFromUri(uri) - - repository.log.first().ifIsData { logEntries -> - val logText = LogUtils.createLogText(logEntries.filter { it.id in entryId }) + private fun createLogText(logEntries: List): String { + return logEntries.joinToString(separator = "\n") { entry -> + val date = dateFormat.format(Date(entry.time)) - file.outputStream()!!.bufferedWriter().use { it.write(logText) } + return@joinToString "$date ${severityString[entry.severity]} ${entry.message}" } } } interface DisplayLogUseCase { - val log: Flow>> + val log: Flow> fun clearLog() - suspend fun copyToClipboard(entryId: Set) - suspend fun saveToFile(uri: String, entryId: Set) + suspend fun copyToClipboard() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/KeyMapperLoggingTree.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/KeyMapperLoggingTree.kt index 861b6522df..2e8efcaec9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/logging/KeyMapperLoggingTree.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/logging/KeyMapperLoggingTree.kt @@ -5,6 +5,8 @@ import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.entities.LogEntryEntity import io.github.sds100.keymapper.data.repositories.LogRepository import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import java.util.Calendar +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow @@ -17,8 +19,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import timber.log.Timber -import java.util.Calendar -import javax.inject.Inject class KeyMapperLoggingTree @Inject constructor( private val coroutineScope: CoroutineScope, @@ -44,8 +44,12 @@ class KeyMapperLoggingTree @Inject constructor( } override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { - // error and info logs should always log even if the user setting is turned off - if (!logEverything.value && priority != Log.ERROR && priority != Log.INFO) { + // error, warn, and info logs should always log even if the user setting is turned off + if (!logEverything.value && + priority != Log.ERROR && + priority != Log.WARN && + priority != Log.INFO + ) { return } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogEntry.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogEntry.kt index 3d3d4a941b..e2996afdbe 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogEntry.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogEntry.kt @@ -1,8 +1,3 @@ package io.github.sds100.keymapper.base.logging -data class LogEntry( - val id: Int, - val time: Long, - val severity: LogSeverity, - val message: String, -) +data class LogEntry(val id: Int, val time: Long, val severity: LogSeverity, val message: String) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogEntryEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogEntryEntityMapper.kt index 0e3d6f301d..ccb11093d3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogEntryEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogEntryEntityMapper.kt @@ -8,6 +8,7 @@ object LogEntryEntityMapper { LogSeverity.ERROR -> LogEntryEntity.SEVERITY_ERROR LogSeverity.DEBUG -> LogEntryEntity.SEVERITY_DEBUG LogSeverity.INFO -> LogEntryEntity.SEVERITY_INFO + LogSeverity.WARNING -> LogEntryEntity.SEVERITY_WARNING } return LogEntryEntity( @@ -23,6 +24,7 @@ object LogEntryEntityMapper { LogEntryEntity.SEVERITY_ERROR -> LogSeverity.ERROR LogEntryEntity.SEVERITY_DEBUG -> LogSeverity.DEBUG LogEntryEntity.SEVERITY_INFO -> LogSeverity.INFO + LogEntryEntity.SEVERITY_WARNING -> LogSeverity.WARNING else -> LogSeverity.DEBUG } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogEntryListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogEntryListItem.kt deleted file mode 100644 index 5dcd1551d8..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogEntryListItem.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.github.sds100.keymapper.base.logging - -import io.github.sds100.keymapper.base.utils.ui.TintType - -data class LogEntryListItem( - val id: Int, - val time: String, - val textTint: TintType, - val message: String, - val isSelected: Boolean, -) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogFragment.kt deleted file mode 100644 index 5c84b9c89e..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogFragment.kt +++ /dev/null @@ -1,182 +0,0 @@ -package io.github.sds100.keymapper.base.logging - -import android.content.Intent -import android.os.Bundle -import android.view.View -import android.view.ViewTreeObserver -import androidx.activity.result.contract.ActivityResultContracts.CreateDocument -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.airbnb.epoxy.EpoxyRecyclerView -import com.airbnb.epoxy.TypedEpoxyController -import com.michaelflisar.dragselectrecyclerview.DragSelectTouchListener -import com.michaelflisar.dragselectrecyclerview.DragSelectionProcessor -import dagger.hilt.android.AndroidEntryPoint -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.databinding.FragmentSimpleRecyclerviewBinding -import io.github.sds100.keymapper.base.logEntry -import io.github.sds100.keymapper.base.utils.ui.SimpleRecyclerViewFragment -import io.github.sds100.keymapper.base.utils.ui.launchRepeatOnLifecycle -import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.system.files.FileUtils -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest - -@AndroidEntryPoint -class LogFragment : SimpleRecyclerViewFragment() { - - private val viewModel: LogViewModel by viewModels() - - override val listItems: Flow>> - get() = viewModel.listItems - - override val appBarMenu: Int = R.menu.menu_log - override var isAppBarVisible = true - - private val recyclerViewController by lazy { RecyclerViewController() } - - private val saveLogToFileLauncher = - registerForActivityResult(CreateDocument(FileUtils.MIME_TYPE_TEXT)) { - it ?: return@registerForActivityResult - - viewModel.onPickFileToSaveTo(it.toString()) - - val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - - requireContext().contentResolver.takePersistableUriPermission(it, takeFlags) - } - - private lateinit var dragSelectTouchListener: DragSelectTouchListener - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - getBottomAppBar(binding)?.setOnMenuItemClickListener { menuItem -> - viewModel.onMenuItemClick(menuItem.itemId) - true - } - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.pickFileToSaveTo.collectLatest { - saveLogToFileLauncher.launch(LogUtils.createLogFileName()) - } - } - } - - override fun subscribeUi(binding: FragmentSimpleRecyclerviewBinding) { - super.subscribeUi(binding) - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.appBarState.collectLatest { appBarState -> - when (appBarState) { - LogAppBarState.MULTI_SELECTING -> { - binding.appBar.setNavigationIcon(R.drawable.ic_outline_clear_24) - } - - LogAppBarState.NORMAL -> { - binding.appBar.setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) - } - } - } - } - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.goBack.collectLatest { - findNavController().navigateUp() - } - } - - val dragSelectionProcessor = DragSelectionProcessor(viewModel.dragSelectionHandler) - .withMode(DragSelectionProcessor.Mode.Simple) - - dragSelectTouchListener = DragSelectTouchListener() - .withSelectListener(dragSelectionProcessor) - - binding.epoxyRecyclerView.setController(recyclerViewController) - } - - override fun onBackPressed() { - viewModel.onBackPressed() - } - - override fun populateList(recyclerView: EpoxyRecyclerView, listItems: List) { - recyclerViewController.setData(listItems) - } - - private inner class RecyclerViewController : TypedEpoxyController>() { - private var scrollToBottom = false - private var scrolledToBottomInitially = false - private var recyclerView: RecyclerView? = null - - init { - addModelBuildListener { - currentData?.also { currentData -> - if (!scrolledToBottomInitially) { - recyclerView?.viewTreeObserver?.addOnGlobalLayoutListener(object : - ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - recyclerView?.scrollToPosition(currentData.size - 1) - recyclerView?.viewTreeObserver?.removeOnGlobalLayoutListener(this) - } - }) - - scrolledToBottomInitially = true - } else if (scrollToBottom) { - recyclerView?.smoothScrollToPosition(currentData.size) - } - } - } - } - - override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { - this.recyclerView = recyclerView - super.onAttachedToRecyclerView(recyclerView) - } - - override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { - this.recyclerView = null - super.onDetachedFromRecyclerView(recyclerView) - } - - override fun buildModels(data: List?) { - if (data == null) { - return - } - - if (recyclerView?.scrollState != RecyclerView.SCROLL_STATE_SETTLING) { - // only automatically scroll to the bottom if the recyclerview is already scrolled to the button - val layoutManager = recyclerView?.layoutManager as LinearLayoutManager? - - if (layoutManager != null) { - val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() - - if (lastVisibleItemPosition == RecyclerView.NO_POSITION) { - scrollToBottom = false - } else { - scrollToBottom = lastVisibleItemPosition == layoutManager.itemCount - 1 - } - } - } - - data.forEachIndexed { index, model -> - logEntry { - id(model.id) - model(model) - - onClick { _ -> - viewModel.onListItemClick(model.id) - } - - onLongClick { _ -> - dragSelectTouchListener.startDragSelection(index) - true - } - } - } - } - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogListItem.kt new file mode 100644 index 0000000000..1709b82cdd --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogListItem.kt @@ -0,0 +1,8 @@ +package io.github.sds100.keymapper.base.logging + +data class LogListItem( + val id: Int, + val time: String, + val severity: LogSeverity, + val message: String, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogScreen.kt new file mode 100644 index 0000000000..13e7fbf58e --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogScreen.kt @@ -0,0 +1,209 @@ +package io.github.sds100.keymapper.base.logging + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +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 + +@Composable +fun LogScreen( + modifier: Modifier = Modifier, + viewModel: LogViewModel = hiltViewModel(), + onBackClick: () -> Unit, +) { + val log = viewModel.log.collectAsStateWithLifecycle().value + + LogScreen( + modifier = modifier, + onBackClick = onBackClick, + onCopyToClipboardClick = viewModel::onCopyToClipboardClick, + onClearLogClick = viewModel::onClearLogClick, + content = { + Content( + modifier = Modifier.fillMaxSize(), + logListItems = log, + ) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LogScreen( + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + onCopyToClipboardClick: () -> Unit = {}, + onClearLogClick: () -> Unit = {}, + content: @Composable () -> Unit, +) { + Scaffold( + modifier = modifier.displayCutoutPadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_pref_view_and_share_log)) }, + actions = { + OutlinedButton( + modifier = Modifier.padding(horizontal = 16.dp), + onClick = onClearLogClick, + ) { + Text(stringResource(R.string.action_clear_log)) + } + }, + ) + }, + bottomBar = { + BottomAppBar { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.action_go_back), + ) + } + Spacer(Modifier.weight(1f)) + IconButton(onClick = onCopyToClipboardClick) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = stringResource(R.string.action_copy_log), + ) + } + } + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + content() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Content(modifier: Modifier = Modifier, logListItems: List) { + val listState = rememberLazyListState() + + // Scroll to the bottom when a new item is added + LaunchedEffect(logListItems) { + if (logListItems.isNotEmpty()) { + listState.animateScrollToItem(logListItems.size - 1) + } + } + + Column(modifier = modifier) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + ) { + items(logListItems, key = { it.id }) { item -> + val color = when (item.severity) { + LogSeverity.ERROR -> MaterialTheme.colorScheme.error + LogSeverity.WARNING -> LocalCustomColorsPalette.current.orange + LogSeverity.INFO -> LocalCustomColorsPalette.current.green + else -> LocalContentColor.current + } + + Row { + Text( + text = item.time, + color = color, + style = MaterialTheme.typography.bodySmall, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = item.message, + color = color, + style = MaterialTheme.typography.bodySmall, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + LogScreen( + content = { + Content( + logListItems = listOf( + LogListItem(1, "12:34:56.789", LogSeverity.INFO, "This is an info message"), + LogListItem( + 2, + "12:34:57.123", + LogSeverity.WARNING, + "This is a warning message", + ), + LogListItem( + 3, + "12:34:58.456", + LogSeverity.ERROR, + "This is an error message. It is a bit long to see how it overflows inside the available space.", + ), + LogListItem(4, "12:34:59.000", LogSeverity.INFO, "Another info message"), + LogListItem( + 5, + "12:35:00.000", + LogSeverity.ERROR, + "Error recording trigger", + ), + LogListItem(6, "12:35:01.000", LogSeverity.WARNING, "I am a warning"), + LogListItem(7, "12:35:02.000", LogSeverity.INFO, "I am some info..."), + LogListItem(8, "12:35:03.000", LogSeverity.INFO, "This more info"), + ), + ) + }, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogSeverity.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogSeverity.kt index e90e7993da..91d594e028 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogSeverity.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogSeverity.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.base.logging enum class LogSeverity { ERROR, + WARNING, INFO, DEBUG, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogUtils.kt deleted file mode 100644 index 93b19ffaad..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogUtils.kt +++ /dev/null @@ -1,27 +0,0 @@ -package io.github.sds100.keymapper.base.logging - -import io.github.sds100.keymapper.data.entities.LogEntryEntity -import io.github.sds100.keymapper.system.files.FileUtils -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -object LogUtils { - val DATE_FORMAT - get() = SimpleDateFormat("MM/dd HH:mm:ss.SSS", Locale.getDefault()) - - fun createLogFileName(): String { - val formattedDate = FileUtils.createFileDate() - return "key_mapper_log_$formattedDate.txt" - } - - fun createLogText(logEntries: List): String { - val dateFormat = DATE_FORMAT - - return logEntries.joinToString(separator = "\n") { entry -> - val date = dateFormat.format(Date(entry.time)) - - return@joinToString "$date ${entry.message}" - } - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogViewModel.kt index 61959f6990..b40264e249 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/logging/LogViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/logging/LogViewModel.kt @@ -1,204 +1,48 @@ package io.github.sds100.keymapper.base.logging +import android.annotation.SuppressLint import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.michaelflisar.dragselectrecyclerview.DragSelectionProcessor import dagger.hilt.android.lifecycle.HiltViewModel -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.DialogModel -import io.github.sds100.keymapper.base.utils.ui.DialogProvider -import io.github.sds100.keymapper.base.utils.ui.MultiSelectProvider -import io.github.sds100.keymapper.base.utils.ui.ResourceProvider -import io.github.sds100.keymapper.base.utils.ui.SelectionState -import io.github.sds100.keymapper.base.utils.ui.TintType -import io.github.sds100.keymapper.base.utils.ui.showDialog -import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.common.utils.ifIsData -import io.github.sds100.keymapper.common.utils.mapData -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow +import java.text.SimpleDateFormat +import java.util.Locale +import javax.inject.Inject import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import java.util.Date -import javax.inject.Inject @HiltViewModel -class LogViewModel @Inject constructor( - private val useCase: DisplayLogUseCase, - resourceProvider: ResourceProvider, - dialogProvider: DialogProvider, -) : ViewModel(), - DialogProvider by dialogProvider, - ResourceProvider by resourceProvider { - private val multiSelectProvider: MultiSelectProvider = MultiSelectProvider() - - private val _listItems = MutableStateFlow>>(State.Loading) - val listItems = _listItems.asStateFlow() - - private val dateFormat = LogUtils.DATE_FORMAT - - private val _pickFileToSaveTo = MutableSharedFlow() - val pickFileToSaveTo = _pickFileToSaveTo.asSharedFlow() - - val appBarState: StateFlow = multiSelectProvider.state - .map { selectionState -> - when (selectionState) { - is SelectionState.Selecting -> LogAppBarState.MULTI_SELECTING - else -> LogAppBarState.NORMAL +class LogViewModel @Inject constructor(private val displayLogUseCase: DisplayLogUseCase) : + ViewModel() { + @SuppressLint("ConstantLocale") + private val dateFormat = SimpleDateFormat("MM/dd HH:mm:ss.SSS", Locale.getDefault()) + + val log: StateFlow> = displayLogUseCase.log + .map { list -> + list.map { + LogListItem( + id = it.id, + time = dateFormat.format(it.time), + message = it.message, + severity = it.severity, + ) } } - .stateIn(viewModelScope, SharingStarted.Lazily, LogAppBarState.NORMAL) - - private val showShortMessages = MutableStateFlow(true) - - private val _goBack = MutableSharedFlow() - val goBack = _goBack.asSharedFlow() - - val dragSelectionHandler = object : DragSelectionProcessor.ISelectionHandler { - override fun getSelection(): MutableSet = multiSelectProvider.getSelectedIds().map { it.toInt() }.toMutableSet() - - override fun isSelected(index: Int): Boolean { - listItems.value.ifIsData { - val id = it.getOrNull(index)?.id ?: return false - - return multiSelectProvider.isSelected(id.toString()) - } - - return false - } - - override fun updateSelection( - start: Int, - end: Int, - isSelected: Boolean, - calledFromOnStart: Boolean, - ) { - listItems.value.ifIsData { listItems -> - val selectedListItems = listItems.slice(start..end) - val selectedIds = selectedListItems.map { it.id.toString() }.toTypedArray() - - if (calledFromOnStart) { - multiSelectProvider.startSelecting() - } - - if (isSelected) { - multiSelectProvider.select(*selectedIds) - } else { - multiSelectProvider.deselect(*selectedIds) - } - } - } - } - - init { - combine( - useCase.log, - showShortMessages, - multiSelectProvider.state, - ) { log, showShortMessages, selectionState -> - _listItems.value = log.mapData { logEntries -> - logEntries.map { entry -> - val isSelected = if (selectionState is SelectionState.Selecting) { - selectionState.selectedIds.contains(entry.id.toString()) - } else { - false - } - - createListItem(entry, showShortMessages, isSelected) - } - } - }.flowOn(Dispatchers.Default).launchIn(viewModelScope) - } - - fun onMenuItemClick(itemId: Int) { - viewModelScope.launch { - when (itemId) { - R.id.action_clear -> useCase.clearLog() - R.id.action_copy -> { - useCase.copyToClipboard(getSelectedLogEntries()) - showDialog("copied", DialogModel.Toast(getString(R.string.toast_copied_log))) - } - - R.id.action_short_messages -> { - showShortMessages.value = !showShortMessages.value - } - - R.id.action_save -> - _pickFileToSaveTo.emit(Unit) - } - } - } - - fun onListItemClick(id: Int) { - multiSelectProvider.toggleSelection(id.toString()) - } - - fun onBackPressed() { - if (multiSelectProvider.isSelecting()) { - multiSelectProvider.stopSelecting() - } else { - viewModelScope.launch { - _goBack.emit(Unit) - } - } - } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) - fun onPickFileToSaveTo(uri: String) { + fun onCopyToClipboardClick() { viewModelScope.launch { - useCase.saveToFile(uri, getSelectedLogEntries()) - } - } - - private suspend fun getSelectedLogEntries(): Set = if (multiSelectProvider.isSelecting()) { - multiSelectProvider.getSelectedIds().map { it.toInt() }.toSet() - } else { - val logState = useCase.log.first() - - if (logState is State.Data) { - logState.data.map { it.id }.toSet() - } else { - emptySet() + displayLogUseCase.copyToClipboard() } } - private fun createListItem( - logEntry: LogEntry, - shortMessage: Boolean, - isSelected: Boolean, - ): LogEntryListItem { - val textTint = if (logEntry.severity == LogSeverity.ERROR) { - TintType.Error - } else { - TintType.OnSurface - } - - val message: String = if (shortMessage) { - logEntry.message.split(',').getOrElse(0) { logEntry.message } - } else { - logEntry.message - } - - return LogEntryListItem( - id = logEntry.id, - time = dateFormat.format(Date(logEntry.time)), - textTint = textTint, - message = message, - isSelected = isSelected, - ) + fun onClearLogClick() { + displayLogUseCase.clearLog() } } - -enum class LogAppBarState { - MULTI_SELECTING, - NORMAL, -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/logging/ShareLogcatUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/logging/ShareLogcatUseCase.kt new file mode 100644 index 0000000000..4565769d8b --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/logging/ShareLogcatUseCase.kt @@ -0,0 +1,49 @@ +package io.github.sds100.keymapper.base.logging + +import android.content.Context +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.base.utils.ShareUtils +import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.then +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 io.github.sds100.keymapper.system.shell.ShellAdapter +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@ViewModelScoped +class ShareLogcatUseCaseImpl @Inject constructor( + @ApplicationContext private val ctx: Context, + private val fileAdapter: FileAdapter, + private val shellAdapter: ShellAdapter, + private val buildConfigProvider: BuildConfigProvider, +) : ShareLogcatUseCase { + + override suspend fun share(): KMResult { + val fileName = "logs/logcat_${FileUtils.createFileDate()}.txt" + + return withContext(Dispatchers.IO) { + val file: IFile = fileAdapter.getPrivateFile(fileName) + file.createFile() + + val command = "logcat -d -f ${file.path}" + + shellAdapter.execute(command).then { + val publicUri = fileAdapter.getPublicUriForPrivateFile(file) + + ShareUtils.shareFile(ctx, publicUri.toUri(), buildConfigProvider.packageName) + Success(Unit) + } + } + } +} + +interface ShareLogcatUseCase { + suspend fun share(): KMResult +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTapTarget.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTapTarget.kt index a65de34e57..21f6769ee2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTapTarget.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTapTarget.kt @@ -1,34 +1,6 @@ package io.github.sds100.keymapper.base.onboarding -import androidx.annotation.StringRes -import io.github.sds100.keymapper.base.R - -enum class OnboardingTapTarget( - @StringRes val titleRes: Int, - @StringRes val messageRes: Int, -) { - CREATE_KEY_MAP( - titleRes = R.string.tap_target_create_key_map_title, - messageRes = R.string.tap_target_create_key_map_message, - ), - - RECORD_TRIGGER( - titleRes = R.string.tap_target_record_trigger_title, - messageRes = R.string.tap_target_record_trigger_message, - ), - - ADVANCED_TRIGGERS( - titleRes = R.string.tap_target_advanced_triggers_title, - messageRes = R.string.tap_target_advanced_triggers_message, - ), - - CHOOSE_ACTION( - titleRes = R.string.tap_target_choose_action_title, - messageRes = R.string.tap_target_choose_action_message, - ), - - CHOOSE_CONSTRAINT( - titleRes = R.string.tap_target_choose_constraint_title, - messageRes = R.string.tap_target_choose_constraint_message, - ), +enum class OnboardingTapTarget { + CREATE_KEY_MAP, + CHOOSE_ACTION, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTipDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTipDelegate.kt new file mode 100644 index 0000000000..7b950dab51 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTipDelegate.kt @@ -0,0 +1,346 @@ +package io.github.sds100.keymapper.base.onboarding + +import android.os.Build +import android.view.KeyEvent +import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.Action +import io.github.sds100.keymapper.base.actions.ActionData +import io.github.sds100.keymapper.base.actions.ConfigActionsUseCase +import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCase +import io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey +import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey +import io.github.sds100.keymapper.base.trigger.Trigger +import io.github.sds100.keymapper.base.trigger.TriggerMode +import io.github.sds100.keymapper.base.utils.navigation.NavDestination +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.common.utils.dataOrNull +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.data.utils.PrefDelegate +import io.github.sds100.keymapper.system.inputevents.KeyEventUtils +import javax.inject.Inject +import javax.inject.Named +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch + +@ViewModelScoped +class OnboardingTipDelegateImpl @Inject constructor( + @Named("viewmodel") + private val viewModelScope: CoroutineScope, + private val preferenceRepository: PreferenceRepository, + private val configTriggerUseCase: ConfigTriggerUseCase, + private val configActionsUseCase: ConfigActionsUseCase, + resourceProvider: ResourceProvider, + navigationProvider: NavigationProvider, +) : OnboardingTipDelegate, + NavigationProvider by navigationProvider, + PreferenceRepository by preferenceRepository, + ResourceProvider by resourceProvider { + + companion object { + private const val POWER_BUTTON_EMERGENCY_TIP_ID = "power_button_emergency_tip" + private const val PARALLEL_TRIGGER_TIP_ID = "parallel_trigger_tip" + private const val SEQUENCE_TRIGGER_TIP_ID = "sequence_trigger_tip" + private const val TRIGGER_CONSTRAINTS_TIP_ID = "trigger_constraints_tip" + const val CAPS_LOCK_PRO_MODE_COMPATIBILITY_TIP_ID = "caps_lock_pro_mode_compatibility_tip" + const val VOLUME_BUTTONS_PRO_MODE_TIP_ID = "volume_buttons_pro_mode_tip" + const val SCREEN_PINNING_TIP_ID = "screen_pinning_tip" + const val IME_DETECTION_TIP_ID = "ime_detection_tip" + const val RINGER_MODE_TIP_ID = "ringer_mode_tip" + } + + override val triggerTip: MutableStateFlow = MutableStateFlow(null) + override val actionsTip: MutableStateFlow = MutableStateFlow(null) + + private var shownParallelTriggerOrderExplanation: Boolean by PrefDelegate( + Keys.shownParallelTriggerOrderExplanation, + false, + ) + + private var shownSequenceTriggerExplanation: Boolean by PrefDelegate( + Keys.shownSequenceTriggerExplanation, + false, + ) + + private var shownTriggerConstraintsTip: Boolean by PrefDelegate( + Keys.shownTriggerConstraintsTip, + false, + ) + + private var shownCapsLockProModeTip: Boolean by PrefDelegate( + Keys.shownCapsLockProModeTip, + false, + ) + + private var shownVolumeButtonsProModeTip: Boolean by PrefDelegate( + Keys.shownVolumeButtonsProModeTip, + false, + ) + + private var shownScreenPinningTip: Boolean by PrefDelegate( + Keys.shownScreenPinningTip, + false, + ) + + private var shownImeDetectionTip: Boolean by PrefDelegate( + Keys.shownTriggerKeyboardIconExplanation, + false, + ) + + private var shownRingerModeTip: Boolean by PrefDelegate( + Keys.shownRingerModeTip, + false, + ) + + init { + viewModelScope.launch { + configTriggerUseCase.keyMap + .mapNotNull { it.dataOrNull()?.trigger } + .collect { trigger -> + onCollectTrigger(trigger) + } + } + + viewModelScope.launch { + configActionsUseCase.keyMap + .mapNotNull { it.dataOrNull()?.actionList } + .collect { actionList -> + onCollectActions(actionList) + } + } + } + + override fun onTriggerTipDismissClick() { + val currentTip = triggerTip.value + + currentTip?.let { neverShowTipAgain(it.id) } + + triggerTip.value = null + } + + override fun onActionTipDismissClick() { + val currentTip = actionsTip.value + + when (currentTip?.id) { + RINGER_MODE_TIP_ID -> { + shownRingerModeTip = true + } + } + + actionsTip.value = null + } + + override fun onTipButtonClick(tipId: String) { + when (tipId) { + RINGER_MODE_TIP_ID -> { + viewModelScope.launch { + navigate("ringer_mode_tip_pro_mode", NavDestination.ProMode) + } + } + + VOLUME_BUTTONS_PRO_MODE_TIP_ID -> { + viewModelScope.launch { + navigate("volume_buttons_pro_mode_tip", NavDestination.ProMode) + } + } + } + } + + private fun onCollectTrigger(trigger: Trigger) { + val showPowerButtonEmergencyTip = trigger.keys.any { + it is KeyCodeTriggerKey && + KeyEventUtils.isPowerButtonKey( + it.keyCode, + it.scanCode ?: -1, + ) + } + + val hasCapsLockKey = + trigger.keys.any { + it is KeyEventTriggerKey && it.keyCode == KeyEvent.KEYCODE_CAPS_LOCK + } + trigger.keys.any { + it is KeyEventTriggerKey && + ( + it.keyCode == KeyEvent.KEYCODE_VOLUME_UP || + it.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN + ) + } + + val hasBackKey = + trigger.keys.any { it is KeyEventTriggerKey && it.keyCode == KeyEvent.KEYCODE_BACK } + val hasImeKey = trigger.keys.any { it is KeyEventTriggerKey && it.requiresIme } + + when { + showPowerButtonEmergencyTip -> { + val tipModel = OnboardingTipModel( + id = POWER_BUTTON_EMERGENCY_TIP_ID, + title = getString(R.string.pro_mode_emergency_tip_title), + message = getString(R.string.pro_mode_emergency_tip_text), + isDismissable = false, + ) + + triggerTip.value = tipModel + } + + trigger.mode is TriggerMode.Parallel && !shownParallelTriggerOrderExplanation -> { + val tipModel = OnboardingTipModel( + id = PARALLEL_TRIGGER_TIP_ID, + title = getString(R.string.tip_parallel_trigger_title), + message = getString(R.string.dialog_message_parallel_trigger_order), + isDismissable = true, + ) + + triggerTip.value = tipModel + } + + trigger.mode is TriggerMode.Sequence && !shownSequenceTriggerExplanation -> { + val tipModel = OnboardingTipModel( + id = SEQUENCE_TRIGGER_TIP_ID, + title = getString(R.string.tip_sequence_trigger_title), + message = getString(R.string.dialog_message_sequence_trigger_explanation), + isDismissable = true, + ) + + triggerTip.value = tipModel + } + + // DISABLE UNTIL PRO MODE IS STABLE +// hasVolumeKey && !shownVolumeButtonsProModeTip -> { +// val tip = OnboardingTipModel( +// id = VOLUME_BUTTONS_PRO_MODE_TIP_ID, +// title = getString(R.string.tip_volume_buttons_pro_mode_title), +// message = getString(R.string.tip_volume_buttons_pro_mode_text), +// isDismissable = true, +// buttonText = getString(R.string.tip_volume_buttons_pro_mode_button), +// ) +// triggerTip.value = tip +// } + + hasCapsLockKey && !shownCapsLockProModeTip -> { + val tip = OnboardingTipModel( + id = CAPS_LOCK_PRO_MODE_COMPATIBILITY_TIP_ID, + title = getString(R.string.tip_caps_lock_pro_mode_title), + message = getString(R.string.tip_caps_lock_pro_mode_text), + isDismissable = true, + buttonText = getString(R.string.tip_caps_lock_pro_mode_button), + ) + triggerTip.value = tip + } + + hasBackKey && !shownScreenPinningTip -> { + val tip = OnboardingTipModel( + id = SCREEN_PINNING_TIP_ID, + title = getString(R.string.tip_screen_pinning_title), + message = getString(R.string.tip_screen_pinning_text), + isDismissable = true, + ) + triggerTip.value = tip + } + + hasImeKey && !shownImeDetectionTip -> { + val tip = OnboardingTipModel( + id = IME_DETECTION_TIP_ID, + title = getString(R.string.tip_ime_detection_title), + message = getString(R.string.tip_ime_detection_text), + isDismissable = true, + ) + triggerTip.value = tip + } + + // Adding a constraint should be the lowest priority + trigger.keys.isNotEmpty() && !shownTriggerConstraintsTip -> { + val tipModel = OnboardingTipModel( + id = TRIGGER_CONSTRAINTS_TIP_ID, + title = getString(R.string.trigger_constraints_tip_title), + message = getString(R.string.trigger_constraints_tip_text), + isDismissable = true, + ) + + triggerTip.value = tipModel + } + + else -> { + triggerTip.value = null + } + } + } + + override fun neverShowTipAgain(tipId: String) { + when (tipId) { + PARALLEL_TRIGGER_TIP_ID -> { + shownParallelTriggerOrderExplanation = true + } + + SEQUENCE_TRIGGER_TIP_ID -> { + shownSequenceTriggerExplanation = true + } + + TRIGGER_CONSTRAINTS_TIP_ID -> { + shownTriggerConstraintsTip = true + } + + CAPS_LOCK_PRO_MODE_COMPATIBILITY_TIP_ID -> { + shownCapsLockProModeTip = true + } + + VOLUME_BUTTONS_PRO_MODE_TIP_ID -> { + shownVolumeButtonsProModeTip = true + } + + SCREEN_PINNING_TIP_ID -> { + shownScreenPinningTip = true + } + + IME_DETECTION_TIP_ID -> { + shownImeDetectionTip = true + } + + // POWER_BUTTON_EMERGENCY_TIP_ID doesn't need preference setting as it's non-dismissable + } + } + + private fun onCollectActions(actionList: List) { + val hasRingerModeAction = actionList.any { action -> + when (action.data) { + is ActionData.Volume.SetRingerMode, + is ActionData.Volume.CycleRingerMode, + is ActionData.Volume.CycleVibrateRing, + -> true + + else -> false + } + } + + if (hasRingerModeAction && + !shownRingerModeTip && + Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API + ) { + val tip = OnboardingTipModel( + id = RINGER_MODE_TIP_ID, + title = getString(R.string.tip_ringer_mode_title), + message = getString(R.string.tip_ringer_mode_text), + isDismissable = true, + buttonText = getString(R.string.tip_ringer_mode_button), + ) + actionsTip.value = tip + } + } +} + +interface OnboardingTipDelegate { + val triggerTip: StateFlow + val actionsTip: StateFlow + + fun onTriggerTipDismissClick() + fun onActionTipDismissClick() + fun onTipButtonClick(tipId: String) + fun neverShowTipAgain(tipId: String) +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTipModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTipModel.kt new file mode 100644 index 0000000000..541ba87e25 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTipModel.kt @@ -0,0 +1,9 @@ +package io.github.sds100.keymapper.base.onboarding + +data class OnboardingTipModel( + val id: String, + val title: String, + val message: String, + val isDismissable: Boolean, + val buttonText: String? = null, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingUseCase.kt index d01fa193b0..fef578271f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingUseCase.kt @@ -1,36 +1,22 @@ package io.github.sds100.keymapper.base.onboarding import androidx.datastore.preferences.core.Preferences -import io.github.sds100.keymapper.base.actions.ActionData -import io.github.sds100.keymapper.base.actions.canUseImeToPerform -import io.github.sds100.keymapper.base.actions.canUseShizukuToPerform -import io.github.sds100.keymapper.base.purchasing.ProductId -import io.github.sds100.keymapper.base.purchasing.PurchasingManager -import io.github.sds100.keymapper.base.system.inputmethod.KeyMapperImeHelper import io.github.sds100.keymapper.base.utils.VersionHelper import io.github.sds100.keymapper.common.BuildConfigProvider -import io.github.sds100.keymapper.common.utils.KMResult -import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.common.utils.handle import io.github.sds100.keymapper.data.Keys -import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.repositories.KeyMapRepository import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.data.utils.PrefDelegate -import io.github.sds100.keymapper.system.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.files.FileAdapter import io.github.sds100.keymapper.system.leanback.LeanbackAdapter import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter -import io.github.sds100.keymapper.system.shizuku.ShizukuUtils +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import javax.inject.Inject -import javax.inject.Singleton @Singleton class OnboardingUseCaseImpl @Inject constructor( @@ -39,8 +25,6 @@ class OnboardingUseCaseImpl @Inject constructor( private val leanbackAdapter: LeanbackAdapter, private val shizukuAdapter: ShizukuAdapter, private val permissionAdapter: PermissionAdapter, - private val packageManagerAdapter: PackageManagerAdapter, - private val purchasingManager: PurchasingManager, private val keyMapRepository: KeyMapRepository, private val buildConfigProvider: BuildConfigProvider, ) : PreferenceRepository by settingsRepository, @@ -48,40 +32,6 @@ class OnboardingUseCaseImpl @Inject constructor( override var shownAppIntro by PrefDelegate(Keys.shownAppIntro, false) - override suspend fun showInstallGuiKeyboardPrompt(action: ActionData): Boolean { - val acknowledged = settingsRepository.get(Keys.acknowledgedGuiKeyboard).first() - val isGuiKeyboardInstalled = - packageManagerAdapter.isAppInstalled(KeyMapperImeHelper.KEY_MAPPER_GUI_IME_PACKAGE) - - val isShizukuInstalled = shizukuAdapter.isInstalled.value - - return (acknowledged == null || !acknowledged) && - !isGuiKeyboardInstalled && - !isShizukuInstalled && - action.canUseImeToPerform() - } - - override suspend fun showInstallShizukuPrompt(action: ActionData): Boolean = !shizukuAdapter.isInstalled.value && - ShizukuUtils.isRecommendedForSdkVersion() && - action.canUseShizukuToPerform() - - override fun neverShowGuiKeyboardPromptsAgain() { - settingsRepository.set(Keys.acknowledgedGuiKeyboard, true) - } - - override var shownParallelTriggerOrderExplanation by PrefDelegate( - Keys.shownParallelTriggerOrderExplanation, - false, - ) - override var shownSequenceTriggerExplanation by PrefDelegate( - Keys.shownSequenceTriggerExplanation, - false, - ) - override var shownKeyCodeToScanCodeTriggerExplanation by PrefDelegate( - Keys.shownKeyCodeToScanCodeTriggerExplanation, - false, - ) - override val showWhatsNew = get(Keys.lastInstalledVersionCodeHomeScreen) .map { (it ?: -1) < buildConfigProvider.versionCode } @@ -89,9 +39,10 @@ class OnboardingUseCaseImpl @Inject constructor( set(Keys.lastInstalledVersionCodeHomeScreen, buildConfigProvider.versionCode) } - override fun getWhatsNewText(): String = with(fileAdapter.openAsset("whats-new.txt").bufferedReader()) { - readText() - } + override fun getWhatsNewText(): String = + with(fileAdapter.openAsset("whats-new.txt").bufferedReader()) { + readText() + } override var approvedFloatingButtonFeaturePrompt by PrefDelegate( Keys.approvedFloatingButtonFeaturePrompt, @@ -130,19 +81,6 @@ class OnboardingUseCaseImpl @Inject constructor( override val showShizukuAppIntroSlide: Boolean get() = shizukuAdapter.isInstalled.value - override val showNoKeysDetectedBottomSheet: Flow = - settingsRepository.get(Keys.neverShowNoKeysRecordedError).map { neverShow -> - if (neverShow == null) { - true - } else { - !neverShow - } - } - - override fun neverShowNoKeysRecordedBottomSheet() { - settingsRepository.set(Keys.neverShowNoKeysRecordedError, true) - } - override val hasViewedAdvancedTriggers: Flow = get(Keys.viewedAdvancedTriggers).map { it ?: false } @@ -153,32 +91,7 @@ class OnboardingUseCaseImpl @Inject constructor( override fun showTapTarget(tapTarget: OnboardingTapTarget): Flow { val shownKey = getTapTargetKey(tapTarget) - if (tapTarget == OnboardingTapTarget.ADVANCED_TRIGGERS) { - return combine( - settingsRepository.get(shownKey).map { it ?: false }, - purchasingManager.purchases.filterIsInstance>>>(), - keyMapRepository.keyMapList.filterIsInstance>>(), - ) { isShown, purchases, keyMapList -> - // Only show the tap target for advanced triggers if it has not already been shown - // and the user has not made any purchases. Also, the user must have saved a working key map - // as a heuristic for they actually interacted with Key Mapper a bit before - // pushing them to paid features. - !isShown && - keyMapList.data.any { it.trigger.keys.isNotEmpty() && it.actionList.isNotEmpty() } && - purchases.data.handle( - onSuccess = { it.isEmpty() }, - onError = { false }, - ) - } - } else { - return combine( - settingsRepository.get(shownKey).map { it ?: false }, - settingsRepository.get(Keys.skipTapTargetTutorial).map { it ?: false }, - keyMapRepository.keyMapList.filterIsInstance>>(), - ) { isShown, skipTapTarget, keyMapList -> - showTutorialTapTarget(tapTarget, isShown, skipTapTarget, keyMapList.data) - } - } + return settingsRepository.get(shownKey).map { isShown -> !(isShown ?: false) } } override fun completedTapTarget(tapTarget: OnboardingTapTarget) { @@ -187,71 +100,17 @@ class OnboardingUseCaseImpl @Inject constructor( } private fun getTapTargetKey(tapTarget: OnboardingTapTarget): Preferences.Key { - val key = when (tapTarget) { - OnboardingTapTarget.CREATE_KEY_MAP -> Keys.shownTapTargetCreateKeyMap - OnboardingTapTarget.RECORD_TRIGGER -> Keys.shownTapTargetRecordTrigger - OnboardingTapTarget.ADVANCED_TRIGGERS -> Keys.shownTapTargetAdvancedTriggers - OnboardingTapTarget.CHOOSE_ACTION -> Keys.shownTapTargetChooseAction - OnboardingTapTarget.CHOOSE_CONSTRAINT -> Keys.shownTapTargetChooseConstraint - } - return key - } - - /** - * Whether to show a tutorial tap target. This will try to determine whether the user - * has interacted with each feature before by checking the key maps they've created (if any). - * E.g if they have no key maps with actions then show a tap target highlighting the action tab - * when they create a key map. - */ - private fun showTutorialTapTarget( - tapTarget: OnboardingTapTarget, - isShown: Boolean, - skipTutorial: Boolean, - keyMapList: List, - ): Boolean { - if (isShown) { - return false - } - - if (skipTutorial) { - return false - } - return when (tapTarget) { - OnboardingTapTarget.CREATE_KEY_MAP -> keyMapList.isEmpty() - OnboardingTapTarget.RECORD_TRIGGER -> keyMapList.all { it.trigger.keys.isEmpty() } - OnboardingTapTarget.CHOOSE_ACTION -> keyMapList.all { it.actionList.isEmpty() } - OnboardingTapTarget.CHOOSE_CONSTRAINT -> keyMapList.all { it.constraintList.isEmpty() } - else -> throw IllegalArgumentException("This is not a tutorial tap target: $tapTarget") + OnboardingTapTarget.CHOOSE_ACTION -> Keys.shownTapTargetChooseAction + OnboardingTapTarget.CREATE_KEY_MAP -> Keys.shownTapTargetCreateKeyMap } } - - override fun skipTapTargetOnboarding() { - settingsRepository.set(Keys.skipTapTargetTutorial, true) - } } interface OnboardingUseCase { var shownAppIntro: Boolean - /** - * @return whether to prompt the user to install the Key Mapper GUI Keyboard after adding - * this action - */ - suspend fun showInstallGuiKeyboardPrompt(action: ActionData): Boolean - - /** - * @return whether to prompt the user to install Shizuku after adding - * this action - */ - suspend fun showInstallShizukuPrompt(action: ActionData): Boolean - fun isTvDevice(): Boolean - fun neverShowGuiKeyboardPromptsAgain() - - var shownParallelTriggerOrderExplanation: Boolean - var shownSequenceTriggerExplanation: Boolean - var shownKeyCodeToScanCodeTriggerExplanation: Boolean val showFloatingButtonFeatureNotification: Flow fun showedFloatingButtonFeatureNotification() @@ -265,13 +124,9 @@ interface OnboardingUseCase { val showShizukuAppIntroSlide: Boolean - val showNoKeysDetectedBottomSheet: Flow - fun neverShowNoKeysRecordedBottomSheet() - val hasViewedAdvancedTriggers: Flow fun viewedAdvancedTriggers() fun showTapTarget(tapTarget: OnboardingTapTarget): Flow fun completedTapTarget(tapTarget: OnboardingTapTarget) - fun skipTapTargetOnboarding() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/SetupAccessibilityServiceDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/SetupAccessibilityServiceDelegate.kt new file mode 100644 index 0000000000..14e91a9416 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/SetupAccessibilityServiceDelegate.kt @@ -0,0 +1,90 @@ +package io.github.sds100.keymapper.base.onboarding + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.github.sds100.keymapper.base.system.accessibility.ControlAccessibilityServiceUseCase +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.utils.AccessibilityServiceError +import io.github.sds100.keymapper.common.utils.firstBlocking +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow + +sealed class AccessibilityServiceDialog { + data class EnableService(val isRestrictedSetting: Boolean) : AccessibilityServiceDialog() + data object RestartService : AccessibilityServiceDialog() + data object CantFindSettings : AccessibilityServiceDialog() +} + +@Singleton +class SetupAccessibilityServiceDelegateImpl @Inject constructor( + private val useCase: ControlAccessibilityServiceUseCase, + resourceProvider: ResourceProvider, +) : SetupAccessibilityServiceDelegate, + ResourceProvider by resourceProvider { + + var dialogState: AccessibilityServiceDialog? by mutableStateOf(null) + + override val accessibilityServiceState: Flow = useCase.serviceState + + override fun showFixAccessibilityServiceDialog(error: AccessibilityServiceError) { + dialogState = when (error) { + AccessibilityServiceError.Disabled -> { + val isRestricted = useCase.isRestrictedSetting() + AccessibilityServiceDialog.EnableService(isRestricted) + } + + AccessibilityServiceError.Crashed -> AccessibilityServiceDialog.RestartService + } + } + + override fun showEnableAccessibilityServiceDialog() { + val state = accessibilityServiceState.firstBlocking() + + if (state == AccessibilityServiceState.DISABLED) { + val isRestricted = useCase.isRestrictedSetting() + dialogState = AccessibilityServiceDialog.EnableService(isRestricted) + } else if (state == AccessibilityServiceState.CRASHED) { + dialogState = AccessibilityServiceDialog.RestartService + } + } + + fun onStartServiceClick() { + if (!useCase.startService()) { + dialogState = AccessibilityServiceDialog.CantFindSettings + } else { + dialogState = null + } + } + + fun onRestartServiceClick() { + if (!useCase.restartService()) { + dialogState = AccessibilityServiceDialog.CantFindSettings + } else { + dialogState = null + } + } + + fun onCancelClick() { + dialogState = null + } + + fun onIgnoreCrashedClick() { + useCase.acknowledgeCrashed() + dialogState = null + } + + override fun showCantFindAccessibilitySettingsDialog() { + dialogState = AccessibilityServiceDialog.CantFindSettings + } +} + +interface SetupAccessibilityServiceDelegate { + val accessibilityServiceState: Flow + + fun showFixAccessibilityServiceDialog(error: AccessibilityServiceError) + fun showEnableAccessibilityServiceDialog() + fun showCantFindAccessibilitySettingsDialog() +} 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 new file mode 100644 index 0000000000..53cb6b84ea --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/SetupAccessibilityServiceDialog.kt @@ -0,0 +1,256 @@ +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 +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +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.openUriSafe + +@Composable +fun HandleAccessibilityServiceDialogs(delegate: SetupAccessibilityServiceDelegateImpl) { + val uriHandler = LocalUriHandler.current + val context = LocalContext.current + + when (val dialog = delegate.dialogState) { + is AccessibilityServiceDialog.EnableService -> { + EnableAccessibilityServiceDialog( + modifier = Modifier, + isRestrictedSetting = dialog.isRestrictedSetting, + onDismissRequest = delegate::onCancelClick, + onEnableClick = delegate::onStartServiceClick, + ) + } + + 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, + ) + } + + is AccessibilityServiceDialog.CantFindSettings -> { + val adbGuideUrl = stringResource(R.string.url_cant_find_accessibility_settings_issue) + CantFindAccessibilitySettingsDialog( + modifier = Modifier, + onDismissRequest = delegate::onCancelClick, + onOpenGuide = { + uriHandler.openUriSafe(context, adbGuideUrl) + delegate.onCancelClick() + }, + ) + } + + null -> {} + } +} + +@Composable +private fun EnableAccessibilityServiceDialog( + modifier: Modifier = Modifier, + isRestrictedSetting: Boolean, + onDismissRequest: () -> Unit, + onEnableClick: () -> Unit, +) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + title = { + Text(stringResource(R.string.dialog_title_accessibility_service_explanation)) + }, + text = { + Column { + if (isRestrictedSetting) { + RestrictedSettingText() + Spacer(modifier = Modifier.height(16.dp)) + } + + Text( + stringResource(R.string.dialog_message_accessibility_service_explanation), + style = MaterialTheme.typography.bodyMedium, + ) + } + }, + confirmButton = { + TextButton(onClick = onEnableClick) { + Text(stringResource(R.string.enable)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.neg_cancel)) + } + }, + ) +} + +@Composable +private fun RestartAccessibilityServiceDialog( + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, + onRestartClick: () -> Unit, + onDontKillMyAppClick: () -> Unit, + onIgnoreClick: () -> Unit, +) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + title = { + Text(stringResource(R.string.dialog_title_key_mapper_crashed)) + }, + text = { + Text( + stringResource(R.string.dialog_message_key_mapper_crashed), + style = MaterialTheme.typography.bodyMedium, + ) + }, + 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)) + } + } + }, + dismissButton = { + TextButton(onClick = onIgnoreClick) { + Text(stringResource(R.string.dialog_button_read_dont_kill_my_app_no)) + } + }, + ) +} + +@Composable +private fun CantFindAccessibilitySettingsDialog( + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, + onOpenGuide: () -> Unit, +) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismissRequest, + title = { + Text(stringResource(R.string.dialog_title_cant_find_accessibility_settings_page)) + }, + text = { + Text( + stringResource(R.string.dialog_message_cant_find_accessibility_settings_page), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onOpenGuide) { + Text(stringResource(R.string.pos_start_service_with_adb_guide)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.neg_cancel)) + } + }, + ) +} + +@Composable +private fun RestrictedSettingText(modifier: Modifier = Modifier) { + val messageText = stringResource(R.string.dialog_restricted_setting_message) + val linkText = stringResource(R.string.dialog_restricted_setting_link_text) + val restrictedSettingUrl = stringResource(R.string.url_restricted_setting) + + val annotatedString = buildAnnotatedString { + append(messageText) + append(" ") + + pushLink(LinkAnnotation.Url(restrictedSettingUrl)) + withStyle( + style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ), + ) { + append(linkText) + } + pop() + } + + Text( + modifier = modifier, + text = annotatedString, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + ) +} + +@Preview +@Composable +private fun EnableAccessibilityServiceDialogPreview() { + KeyMapperTheme { + EnableAccessibilityServiceDialog( + isRestrictedSetting = false, + onDismissRequest = {}, + onEnableClick = {}, + ) + } +} + +@Preview +@Composable +private fun EnableAccessibilityServiceDialogRestrictedPreview() { + KeyMapperTheme { + EnableAccessibilityServiceDialog( + isRestrictedSetting = true, + onDismissRequest = {}, + onEnableClick = {}, + ) + } +} + +@Preview +@Composable +private fun RestartAccessibilityServiceDialogPreview() { + KeyMapperTheme { + RestartAccessibilityServiceDialog( + onDismissRequest = {}, + onRestartClick = {}, + onDontKillMyAppClick = {}, + onIgnoreClick = {}, + ) + } +} + +@Preview +@Composable +private fun CantFindAccessibilitySettingsDialogPreview() { + KeyMapperTheme { + CantFindAccessibilitySettingsDialog( + onDismissRequest = {}, + onOpenGuide = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/TipCard.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/TipCard.kt new file mode 100644 index 0000000000..3956cf757f --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/TipCard.kt @@ -0,0 +1,129 @@ +package io.github.sds100.keymapper.base.onboarding + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.compose.KeyMapperTheme + +@Composable +fun TipCard( + modifier: Modifier = Modifier, + title: String, + message: String, + buttonText: String? = null, + isDismissable: Boolean = true, + color: Color = MaterialTheme.colorScheme.tertiary, + onDismiss: () -> Unit = {}, + onButtonClick: () -> Unit = {}, +) { + OutlinedCard( + modifier = modifier, + border = BorderStroke(1.dp, color), + elevation = CardDefaults.elevatedCardElevation(), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.padding(start = 16.dp, end = 48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Info, + contentDescription = null, + tint = color, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = message, + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + if (buttonText != null) { + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + modifier = Modifier + .padding(horizontal = 16.dp) + .align(Alignment.End), + onClick = onButtonClick, + colors = ButtonDefaults.textButtonColors(contentColor = color), + ) { + Text(buttonText) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + } + + if (isDismissable) { + IconButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(4.dp), + onClick = onDismiss, + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = "Dismiss", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Preview +@Composable +private fun TipCardPreview() { + KeyMapperTheme { + TipCard( + title = "Tip Title", + message = """ + This is a helpful tip message that explains something important to the user. + It can be multiple lines long and provides useful information. + """.trimIndent(), + buttonText = "Button", + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeScreen.kt new file mode 100644 index 0000000000..f35cea093f --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeScreen.kt @@ -0,0 +1,762 @@ +package io.github.sds100.keymapper.base.promode + +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +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.rounded.Check +import androidx.compose.material.icons.rounded.Checklist +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Numbers +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.WarningAmber +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +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 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.OptionsHeaderRow +import io.github.sds100.keymapper.base.utils.ui.compose.SwitchPreferenceCompose +import io.github.sds100.keymapper.base.utils.ui.compose.icons.FakeShizuku +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcon +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons +import io.github.sds100.keymapper.common.utils.State + +@Composable +fun ProModeScreen(modifier: Modifier = Modifier, viewModel: ProModeViewModel) { + val proModeWarningState by viewModel.warningState.collectAsStateWithLifecycle() + val proModeSetupState by viewModel.setupState.collectAsStateWithLifecycle() + val autoStartBootEnabled by viewModel.autoStartBootEnabled.collectAsStateWithLifecycle() + + ProModeScreen( + modifier = modifier, + onBackClick = viewModel::onBackClick, + onHelpClick = { viewModel.showInfoCard() }, + showHelpIcon = !viewModel.showInfoCard, + ) { + Content( + warningState = proModeWarningState, + setupState = proModeSetupState, + showInfoCard = viewModel.showInfoCard, + onInfoCardDismiss = { viewModel.hideInfoCard() }, + onWarningButtonClick = viewModel::onWarningButtonClick, + onStopServiceClick = viewModel::onStopServiceClick, + onShizukuButtonClick = viewModel::onShizukuButtonClick, + onRootButtonClick = viewModel::onRootButtonClick, + onSetupWithKeyMapperClick = viewModel::onSetupWithKeyMapperClick, + onRequestNotificationPermissionClick = viewModel::onRequestNotificationPermissionClick, + autoStartAtBoot = autoStartBootEnabled, + onAutoStartAtBootToggled = { viewModel.onAutoStartBootToggled() }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProModeScreen( + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + onHelpClick: () -> Unit = {}, + showHelpIcon: Boolean = false, + content: @Composable () -> Unit, +) { + Scaffold( + modifier = modifier.displayCutoutPadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.pro_mode_app_bar_title)) }, + actions = { + AnimatedVisibility( + visible = showHelpIcon, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + IconButton(onClick = onHelpClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.HelpOutline, + contentDescription = stringResource( + R.string.pro_mode_info_card_show_content_description, + ), + ) + } + } + }, + ) + }, + bottomBar = { + BottomAppBar { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.action_go_back), + ) + } + } + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + content() + } + } +} + +@Composable +private fun Content( + modifier: Modifier = Modifier, + warningState: ProModeWarningState, + setupState: State, + showInfoCard: Boolean, + onInfoCardDismiss: () -> Unit = {}, + onWarningButtonClick: () -> Unit = {}, + onShizukuButtonClick: () -> Unit = {}, + onStopServiceClick: () -> Unit = {}, + onRootButtonClick: () -> Unit = {}, + onSetupWithKeyMapperClick: () -> Unit = {}, + onRequestNotificationPermissionClick: () -> Unit = {}, + autoStartAtBoot: Boolean, + onAutoStartAtBootToggled: (Boolean) -> Unit = {}, +) { + Column(modifier = modifier.verticalScroll(rememberScrollState())) { + AnimatedVisibility( + visible = showInfoCard, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + ProModeInfoCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + onDismiss = onInfoCardDismiss, + ) + } + + if (showInfoCard) { + Spacer(modifier = Modifier.height(8.dp)) + } + + WarningCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + state = warningState, + onButtonClick = onWarningButtonClick, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (warningState is ProModeWarningState.Understood) { + when (setupState) { + is State.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + + is State.Data -> { + LoadedContent( + modifier = Modifier.fillMaxWidth(), + state = setupState.data, + onShizukuButtonClick = onShizukuButtonClick, + onStopServiceClick = onStopServiceClick, + onRootButtonClick = onRootButtonClick, + onSetupWithKeyMapperClick = onSetupWithKeyMapperClick, + onRequestNotificationPermissionClick = onRequestNotificationPermissionClick, + autoStartAtBoot = autoStartAtBoot, + onAutoStartAtBootToggled = onAutoStartAtBootToggled, + ) + } + } + } else { + Text( + modifier = Modifier.padding(horizontal = 32.dp), + text = stringResource(R.string.pro_mode_settings_unavailable_text), + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun LoadedContent( + modifier: Modifier, + state: ProModeState, + onRootButtonClick: () -> Unit = {}, + onShizukuButtonClick: () -> Unit, + onStopServiceClick: () -> Unit, + onSetupWithKeyMapperClick: () -> Unit, + onRequestNotificationPermissionClick: () -> Unit = {}, + autoStartAtBoot: Boolean, + onAutoStartAtBootToggled: (Boolean) -> Unit = {}, +) { + Column(modifier) { + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.Checklist, + text = stringResource(R.string.pro_mode_set_up_title), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Show notification permission warning if permission not granted + if (state is ProModeState.Stopped && !state.isNotificationPermissionGranted) { + val text = + stringResource( + R.string.pro_mode_setup_wizard_enable_notification_permission_description, + ) + + SetupCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.errorContainer, + icon = { + Icon( + imageVector = Icons.Rounded.Notifications, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + title = stringResource( + R.string.pro_mode_setup_wizard_enable_notification_permission_title, + ), + content = { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + }, + buttonText = stringResource( + R.string.pro_mode_setup_wizard_enable_notification_permission_button, + ), + onButtonClick = onRequestNotificationPermissionClick, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + when (state) { + ProModeState.Started -> { + ProModeStartedCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + onStopClick = onStopServiceClick, + ) + } + + is ProModeState.Stopped -> { + if (state.isRootGranted) { + SetupCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + color = LocalCustomColorsPalette.current.magiskTeal, + icon = { + Icon( + imageVector = Icons.Rounded.Numbers, + contentDescription = null, + tint = LocalCustomColorsPalette.current.magiskTeal, + ) + }, + title = stringResource(R.string.pro_mode_root_detected_title), + content = { + Text( + text = stringResource(R.string.pro_mode_root_detected_text), + style = MaterialTheme.typography.bodyMedium, + ) + }, + buttonText = stringResource( + R.string.pro_mode_root_detected_button_start_service, + ), + onButtonClick = onRootButtonClick, + enabled = state.isNotificationPermissionGranted, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + val shizukuButtonText: String? = when (state.shizukuSetupState) { + ShizukuSetupState.INSTALLED -> stringResource( + R.string.pro_mode_shizuku_detected_button_start, + ) + ShizukuSetupState.STARTED -> stringResource( + R.string.pro_mode_shizuku_detected_button_request_permission, + ) + ShizukuSetupState.PERMISSION_GRANTED -> stringResource( + R.string.pro_mode_shizuku_detected_button_start_service, + ) + ShizukuSetupState.NOT_FOUND -> null + } + + if (shizukuButtonText != null) { + SetupCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + color = LocalCustomColorsPalette.current.shizukuBlue, + icon = { + Image( + imageVector = KeyMapperIcons.FakeShizuku, + contentDescription = null, + ) + }, + title = stringResource(R.string.pro_mode_shizuku_detected_title), + content = { + Text( + text = stringResource(R.string.pro_mode_shizuku_detected_text), + style = MaterialTheme.typography.bodyMedium, + ) + }, + buttonText = shizukuButtonText, + onButtonClick = onShizukuButtonClick, + enabled = state.isNotificationPermissionGranted, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + val setupKeyMapperText: String = when { + Build.VERSION.SDK_INT < Build.VERSION_CODES.R -> stringResource( + R.string.pro_mode_set_up_with_key_mapper_button_incompatible, + ) + else -> stringResource(R.string.pro_mode_set_up_with_key_mapper_button) + } + + SetupCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + color = MaterialTheme.colorScheme.primaryContainer, + icon = { + Image( + modifier = Modifier.padding(2.dp), + imageVector = KeyMapperIcons.KeyMapperIcon, + contentDescription = null, + ) + }, + title = stringResource(R.string.pro_mode_set_up_with_key_mapper_title), + content = {}, + buttonText = setupKeyMapperText, + onButtonClick = onSetupWithKeyMapperClick, + enabled = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + state.isNotificationPermissionGranted, + ) + } + } + + // Options section + Spacer(modifier = Modifier.height(16.dp)) + + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.Tune, + text = stringResource(R.string.pro_mode_options_title), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + SwitchPreferenceCompose( + modifier = Modifier.padding(horizontal = 8.dp), + title = stringResource(R.string.title_pref_pro_mode_auto_start_at_boot), + text = stringResource(R.string.summary_pref_pro_mode_auto_start_at_boot), + icon = Icons.Rounded.RestartAlt, + isChecked = autoStartAtBoot, + onCheckedChange = onAutoStartAtBootToggled, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Composable +private fun WarningCard( + modifier: Modifier = Modifier, + state: ProModeWarningState, + onButtonClick: () -> Unit = {}, +) { + val borderStroke = if (state is ProModeWarningState.Understood) { + CardDefaults.outlinedCardBorder() + } else { + BorderStroke(1.dp, MaterialTheme.colorScheme.error) + } + + OutlinedCard( + modifier = modifier, + border = borderStroke, + elevation = CardDefaults.elevatedCardElevation(), + ) { + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.padding(horizontal = 16.dp)) { + Icon( + imageVector = Icons.Rounded.WarningAmber, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = stringResource(R.string.pro_mode_warning_title), + style = MaterialTheme.typography.titleMedium, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(R.string.pro_mode_warning_text), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + FilledTonalButton( + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp), + onClick = onButtonClick, + enabled = state is ProModeWarningState.Idle, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + if (state is ProModeWarningState.Understood) { + Icon(imageVector = Icons.Rounded.Check, contentDescription = null) + + Spacer(modifier = Modifier.width(8.dp)) + } + + val text = when (state) { + is ProModeWarningState.CountingDown -> stringResource( + R.string.pro_mode_warning_understand_button_countdown, + state.seconds, + ) + + ProModeWarningState.Idle -> stringResource( + R.string.pro_mode_warning_understand_button_not_completed, + ) + ProModeWarningState.Understood -> stringResource( + R.string.pro_mode_warning_understand_button_completed, + ) + } + + Text(text) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun ProModeStartedCard(modifier: Modifier = Modifier, onStopClick: () -> Unit = {}) { + OutlinedCard(modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.width(16.dp)) + + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = LocalCustomColorsPalette.current.green, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp), + text = stringResource(R.string.pro_mode_service_started), + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(modifier = Modifier.width(16.dp)) + + TextButton( + onClick = onStopClick, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.pro_mode_stop_service_button)) + } + + Spacer(modifier = Modifier.width(16.dp)) + } + } +} + +@Composable +private fun SetupCard( + modifier: Modifier = Modifier, + color: Color, + icon: @Composable () -> Unit, + title: String, + content: @Composable () -> Unit, + buttonText: String, + onButtonClick: () -> Unit = {}, + enabled: Boolean = true, +) { + OutlinedCard(modifier = modifier) { + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.padding(horizontal = 16.dp)) { + Box(Modifier.size(24.dp)) { + icon() + } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + content() + } + + Spacer(modifier = Modifier.height(8.dp)) + + FilledTonalButton( + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp), + onClick = onButtonClick, + enabled = enabled, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = color, + contentColor = LocalCustomColorsPalette.current.contentColorFor(color), + ), + ) { + Text(buttonText) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun ProModeInfoCard(modifier: Modifier = Modifier, onDismiss: () -> Unit = {}) { + OutlinedCard( + modifier = modifier, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary), + elevation = CardDefaults.elevatedCardElevation(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Row { + Icon( + imageVector = Icons.AutoMirrored.Rounded.HelpOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = stringResource(R.string.pro_mode_info_card_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.pro_mode_info_card_description), + style = MaterialTheme.typography.bodyMedium, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = onDismiss, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource( + R.string.pro_mode_info_card_dismiss_content_description, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + ProModeScreen { + Content( + warningState = ProModeWarningState.Understood, + setupState = State.Data( + ProModeState.Stopped( + isRootGranted = false, + shizukuSetupState = ShizukuSetupState.PERMISSION_GRANTED, + isNotificationPermissionGranted = true, + ), + ), + showInfoCard = true, + onInfoCardDismiss = {}, + autoStartAtBoot = false, + onAutoStartAtBootToggled = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewDark() { + KeyMapperTheme(darkTheme = true) { + ProModeScreen { + Content( + warningState = ProModeWarningState.Understood, + setupState = State.Data(ProModeState.Started), + showInfoCard = false, + onInfoCardDismiss = {}, + autoStartAtBoot = true, + onAutoStartAtBootToggled = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewCountingDown() { + KeyMapperTheme { + ProModeScreen { + Content( + warningState = ProModeWarningState.CountingDown( + seconds = 5, + ), + setupState = State.Loading, + showInfoCard = true, + onInfoCardDismiss = {}, + autoStartAtBoot = false, + onAutoStartAtBootToggled = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewStarted() { + KeyMapperTheme { + ProModeScreen { + Content( + warningState = ProModeWarningState.Understood, + setupState = State.Data(ProModeState.Started), + showInfoCard = false, + onInfoCardDismiss = {}, + autoStartAtBoot = false, + onAutoStartAtBootToggled = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewNotificationPermissionNotGranted() { + KeyMapperTheme { + ProModeScreen { + Content( + warningState = ProModeWarningState.Understood, + setupState = State.Data( + ProModeState.Stopped( + isRootGranted = true, + shizukuSetupState = ShizukuSetupState.PERMISSION_GRANTED, + isNotificationPermissionGranted = false, + ), + ), + showInfoCard = false, + onInfoCardDismiss = {}, + autoStartAtBoot = false, + onAutoStartAtBootToggled = {}, + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeSetupScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeSetupScreen.kt new file mode 100644 index 0000000000..8b49b0cd9a --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeSetupScreen.kt @@ -0,0 +1,556 @@ +package io.github.sds100.keymapper.base.promode + +import android.content.res.Configuration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Accessibility +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Build +import androidx.compose.material.icons.rounded.CheckCircleOutline +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +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 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.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.utils.ui.compose.icons.SignalWifiNotConnected +import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupStep + +@Composable +fun ProModeSetupScreen(viewModel: ProModeSetupViewModel) { + val state by viewModel.setupState.collectAsStateWithLifecycle() + + ProModeSetupScreen( + state = state, + onStepButtonClick = viewModel::onStepButtonClick, + onAssistantClick = viewModel::onAssistantClick, + onWatchTutorialClick = { }, + onBackClick = viewModel::onBackClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProModeSetupScreen( + state: State, + onBackClick: () -> Unit = {}, + onStepButtonClick: () -> Unit = {}, + onAssistantClick: () -> Unit = {}, + onWatchTutorialClick: () -> Unit = {}, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.pro_mode_setup_wizard_title)) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(id = R.string.action_go_back), + ) + } + }, + ) + }, + ) { paddingValues -> + when (state) { + State.Loading -> { + Box( + Modifier + .padding(paddingValues) + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + is State.Data -> { + val stepContent = getStepContent(state.data.step) + + // Create animated progress for entrance and updates + val progressAnimatable = remember { Animatable(0f) } + val targetProgress = state.data.stepNumber.toFloat() / (state.data.stepCount) + + // Animate progress when it changes + LaunchedEffect(targetProgress) { + progressAnimatable.animateTo( + targetValue = targetProgress, + animationSpec = tween( + durationMillis = 800, + easing = EaseInOut, + ), + ) + } + + // Animate entrance when screen opens + LaunchedEffect(Unit) { + progressAnimatable.animateTo( + targetValue = targetProgress, + animationSpec = tween( + durationMillis = 1000, + easing = EaseInOut, + ), + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(vertical = 16.dp, horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + progress = { progressAnimatable.value }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource( + R.string.pro_mode_setup_wizard_step_n, + state.data.stepNumber, + state.data.stepCount, + ), + style = MaterialTheme.typography.titleLarge, + ) + Text( + text = stringResource(R.string.pro_mode_app_bar_title), + style = MaterialTheme.typography.titleLarge, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + AssistantCheckBoxRow( + modifier = Modifier.fillMaxWidth(), + isEnabled = state.data.isSetupAssistantButtonEnabled, + isChecked = state.data.isSetupAssistantChecked, + onAssistantClick = onAssistantClick, + ) + + val iconTint = if (state.data.step == SystemBridgeSetupStep.STARTED) { + LocalCustomColorsPalette.current.green + } else { + MaterialTheme.colorScheme.onSurface + } + + StepContent( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 16.dp), + stepContent, + onWatchTutorialClick, + onStepButtonClick, + iconTint = iconTint, + ) + } + } + } + } +} + +@Composable +private fun StepContent( + modifier: Modifier = Modifier, + stepContent: StepContent, + onWatchTutorialClick: () -> Unit, + onButtonClick: () -> Unit, + iconTint: Color = Color.Unspecified, +) { + Column( + modifier, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(64.dp), + imageVector = stepContent.icon, + contentDescription = null, + tint = iconTint, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stepContent.title, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stepContent.message, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Spacer(Modifier.height(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { +// TextButton(onClick = onWatchTutorialClick) { +// Text(text = stringResource(R.string.pro_mode_setup_wizard_watch_tutorial_button)) +// } + Button(onClick = onButtonClick) { + Text(text = stepContent.buttonText) + } + } + } +} + +@Composable +private fun AssistantCheckBoxRow( + modifier: Modifier, + isEnabled: Boolean, + isChecked: Boolean, + onAssistantClick: () -> Unit, +) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.medium, + enabled = isEnabled, + onClick = onAssistantClick, + ) { + val contentColor = if (isEnabled) { + LocalContentColor.current + } else { + LocalContentColor.current.copy(alpha = 0.5f) + } + + CompositionLocalProvider(LocalContentColor provides contentColor) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + enabled = isEnabled, + checked = isChecked, + onCheckedChange = { onAssistantClick() }, + ) + + val text = if (isEnabled) { + stringResource(R.string.pro_mode_setup_wizard_use_assistant_description) + } else { + stringResource( + R.string.pro_mode_setup_wizard_use_assistant_enable_accessibility_service, + ) + } + + Column { + Text( + text = stringResource(R.string.pro_mode_setup_wizard_use_assistant), + style = MaterialTheme.typography.titleMedium, + ) + + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +@Composable +private fun getStepContent(step: SystemBridgeSetupStep): StepContent { + return when (step) { + SystemBridgeSetupStep.ACCESSIBILITY_SERVICE -> StepContent( + title = stringResource( + R.string.pro_mode_setup_wizard_enable_accessibility_service_title, + ), + message = stringResource( + R.string.pro_mode_setup_wizard_enable_accessibility_service_description, + ), + icon = Icons.Rounded.Accessibility, + buttonText = stringResource( + R.string.pro_mode_setup_wizard_enable_accessibility_service_button, + ), + ) + + SystemBridgeSetupStep.NOTIFICATION_PERMISSION -> StepContent( + title = stringResource( + R.string.pro_mode_setup_wizard_enable_notification_permission_title, + ), + message = stringResource( + R.string.pro_mode_setup_wizard_enable_notification_permission_description, + ), + icon = Icons.Rounded.Notifications, + buttonText = stringResource( + R.string.pro_mode_setup_wizard_enable_notification_permission_button, + ), + ) + + SystemBridgeSetupStep.DEVELOPER_OPTIONS -> StepContent( + title = stringResource(R.string.pro_mode_setup_wizard_enable_developer_options_title), + message = stringResource( + R.string.pro_mode_setup_wizard_enable_developer_options_description, + ), + icon = Icons.Rounded.Build, + buttonText = stringResource(R.string.pro_mode_setup_wizard_go_to_settings_button), + ) + + SystemBridgeSetupStep.WIFI_NETWORK -> StepContent( + title = stringResource(R.string.pro_mode_setup_wizard_connect_wifi_title), + message = stringResource(R.string.pro_mode_setup_wizard_connect_wifi_description), + icon = KeyMapperIcons.SignalWifiNotConnected, + buttonText = stringResource(R.string.pro_mode_setup_wizard_go_to_settings_button), + ) + + SystemBridgeSetupStep.WIRELESS_DEBUGGING -> StepContent( + title = stringResource(R.string.pro_mode_setup_wizard_enable_wireless_debugging_title), + message = stringResource( + R.string.pro_mode_setup_wizard_enable_wireless_debugging_description, + ), + icon = Icons.Rounded.BugReport, + buttonText = stringResource(R.string.pro_mode_setup_wizard_go_to_settings_button), + ) + + SystemBridgeSetupStep.ADB_PAIRING -> StepContent( + title = stringResource(R.string.pro_mode_setup_wizard_pair_wireless_debugging_title), + message = stringResource( + R.string.pro_mode_setup_wizard_pair_wireless_debugging_description, + ), + icon = Icons.Rounded.Link, + buttonText = stringResource(R.string.pro_mode_setup_wizard_go_to_settings_button), + ) + + SystemBridgeSetupStep.START_SERVICE -> StepContent( + title = stringResource(R.string.pro_mode_setup_wizard_start_service_title), + message = stringResource(R.string.pro_mode_setup_wizard_start_service_description), + icon = Icons.Rounded.PlayArrow, + buttonText = stringResource(R.string.pro_mode_root_detected_button_start_service), + ) + + SystemBridgeSetupStep.STARTED -> StepContent( + title = stringResource(R.string.pro_mode_setup_wizard_complete_title), + message = stringResource(R.string.pro_mode_setup_wizard_complete_text), + icon = Icons.Rounded.CheckCircleOutline, + buttonText = stringResource(R.string.pro_mode_setup_wizard_complete_button), + ) + } +} + +private data class StepContent( + val title: String, + val message: String, + val icon: ImageVector, + val buttonText: String, +) + +@Preview(name = "Accessibility Service Step") +@Composable +private fun ProModeSetupScreenAccessibilityServicePreview() { + KeyMapperTheme { + ProModeSetupScreen( + state = State.Data( + ProModeSetupState( + stepNumber = 1, + stepCount = 6, + step = SystemBridgeSetupStep.ACCESSIBILITY_SERVICE, + isSetupAssistantChecked = false, + isSetupAssistantButtonEnabled = false, + ), + ), + ) + } +} + +@Preview(name = "Notification Permission Step") +@Composable +private fun ProModeSetupScreenNotificationPermissionPreview() { + KeyMapperTheme { + ProModeSetupScreen( + state = State.Data( + ProModeSetupState( + stepNumber = 2, + stepCount = 6, + step = SystemBridgeSetupStep.NOTIFICATION_PERMISSION, + isSetupAssistantChecked = false, + isSetupAssistantButtonEnabled = true, + ), + ), + ) + } +} + +@Preview(name = "Developer Options Step") +@Composable +private fun ProModeSetupScreenDeveloperOptionsPreview() { + KeyMapperTheme { + ProModeSetupScreen( + state = State.Data( + ProModeSetupState( + stepNumber = 2, + stepCount = 6, + step = SystemBridgeSetupStep.DEVELOPER_OPTIONS, + isSetupAssistantChecked = false, + isSetupAssistantButtonEnabled = true, + ), + ), + ) + } +} + +@Preview(name = "WiFi Network Step") +@Composable +private fun ProModeSetupScreenWifiNetworkPreview() { + KeyMapperTheme { + ProModeSetupScreen( + state = State.Data( + ProModeSetupState( + stepNumber = 3, + stepCount = 6, + step = SystemBridgeSetupStep.WIFI_NETWORK, + isSetupAssistantChecked = false, + isSetupAssistantButtonEnabled = true, + ), + ), + ) + } +} + +@Preview(name = "Wireless Debugging Step") +@Composable +private fun ProModeSetupScreenWirelessDebuggingPreview() { + KeyMapperTheme { + ProModeSetupScreen( + state = State.Data( + ProModeSetupState( + stepNumber = 4, + stepCount = 6, + step = SystemBridgeSetupStep.WIRELESS_DEBUGGING, + isSetupAssistantChecked = false, + isSetupAssistantButtonEnabled = true, + ), + ), + ) + } +} + +@Preview(name = "ADB Pairing Step", widthDp = 400, heightDp = 400) +@Composable +private fun ProModeSetupScreenAdbPairingPreview() { + KeyMapperTheme { + ProModeSetupScreen( + state = State.Data( + ProModeSetupState( + stepNumber = 5, + stepCount = 6, + step = SystemBridgeSetupStep.ADB_PAIRING, + isSetupAssistantChecked = true, + isSetupAssistantButtonEnabled = true, + ), + ), + ) + } +} + +@Preview(name = "Start Service Step", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ProModeSetupScreenStartServicePreview() { + KeyMapperTheme { + ProModeSetupScreen( + state = State.Data( + ProModeSetupState( + stepNumber = 6, + stepCount = 6, + step = SystemBridgeSetupStep.START_SERVICE, + isSetupAssistantChecked = true, + isSetupAssistantButtonEnabled = true, + ), + ), + ) + } +} + +@Preview(name = "Started", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ProModeSetupScreenStartedPreview() { + KeyMapperTheme { + ProModeSetupScreen( + state = State.Data( + ProModeSetupState( + stepNumber = 8, + stepCount = 8, + step = SystemBridgeSetupStep.STARTED, + isSetupAssistantChecked = true, + isSetupAssistantButtonEnabled = true, + ), + ), + ) + } +} + +@Preview(name = "Loading", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun ProModeSetupScreenLoadingPreview() { + KeyMapperTheme { + ProModeSetupScreen( + state = State.Loading, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeSetupViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeSetupViewModel.kt new file mode 100644 index 0000000000..d246ed0b8f --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeSetupViewModel.kt @@ -0,0 +1,94 @@ +package io.github.sds100.keymapper.base.promode + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.dataOrNull +import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupStep +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class ProModeSetupViewModel @Inject constructor( + private val useCase: SystemBridgeSetupUseCase, + navigationProvider: NavigationProvider, + resourceProvider: ResourceProvider, +) : ViewModel(), + NavigationProvider by navigationProvider, + ResourceProvider by resourceProvider { + val setupState: StateFlow> = + combine(useCase.nextSetupStep, useCase.isSetupAssistantEnabled, ::buildState).stateIn( + viewModelScope, + SharingStarted.Eagerly, + State.Loading, + ) + + fun onStepButtonClick() { + // Do not check the latest value in the use case because there is significant latency + // when it is checking whether it is paired + val currentStep = setupState.value.dataOrNull()?.step ?: return + + when (currentStep) { + SystemBridgeSetupStep.ACCESSIBILITY_SERVICE -> useCase.enableAccessibilityService() + SystemBridgeSetupStep.NOTIFICATION_PERMISSION -> useCase.requestNotificationPermission() + SystemBridgeSetupStep.DEVELOPER_OPTIONS -> useCase.enableDeveloperOptions() + SystemBridgeSetupStep.WIFI_NETWORK -> useCase.connectWifiNetwork() + SystemBridgeSetupStep.WIRELESS_DEBUGGING -> useCase.enableWirelessDebugging() + SystemBridgeSetupStep.ADB_PAIRING -> useCase.pairWirelessAdb() + SystemBridgeSetupStep.START_SERVICE -> viewModelScope.launch { + useCase.startSystemBridgeWithAdb() + } + SystemBridgeSetupStep.STARTED -> viewModelScope.launch { popBackStack() } + } + } + + fun onBackClick() { + viewModelScope.launch { + popBackStack() + } + } + + fun onAssistantClick() { + useCase.toggleSetupAssistant() + } + + private fun buildState( + step: SystemBridgeSetupStep, + isSetupAssistantUserEnabled: Boolean, + ): State.Data { + // Uncheck the setup assistant if the accessibility service is disabled since it is + // required for the setup assistant to work + val isSetupAssistantChecked = if (step == SystemBridgeSetupStep.ACCESSIBILITY_SERVICE) { + false + } else { + isSetupAssistantUserEnabled + } + + return State.Data( + ProModeSetupState( + stepNumber = step.stepIndex + 1, + stepCount = SystemBridgeSetupStep.entries.size, + step = step, + isSetupAssistantChecked = isSetupAssistantChecked, + isSetupAssistantButtonEnabled = + step != SystemBridgeSetupStep.ACCESSIBILITY_SERVICE && + step != SystemBridgeSetupStep.STARTED, + ), + ) + } +} + +data class ProModeSetupState( + val stepNumber: Int, + val stepCount: Int, + val step: SystemBridgeSetupStep, + val isSetupAssistantChecked: Boolean, + val isSetupAssistantButtonEnabled: Boolean, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeViewModel.kt new file mode 100644 index 0000000000..8a20e3c561 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/ProModeViewModel.kt @@ -0,0 +1,186 @@ +package io.github.sds100.keymapper.base.promode + +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.navigation.NavDestination +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate +import io.github.sds100.keymapper.base.utils.ui.DialogProvider +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.utils.State +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class ProModeViewModel @Inject constructor( + private val useCase: SystemBridgeSetupUseCase, + resourceProvider: ResourceProvider, + dialogProvider: DialogProvider, + navigationProvider: NavigationProvider, +) : ViewModel(), + ResourceProvider by resourceProvider, + DialogProvider by dialogProvider, + NavigationProvider by navigationProvider { + + companion object { + private const val WARNING_COUNT_DOWN_SECONDS = 5 + } + + @OptIn(ExperimentalCoroutinesApi::class) + val warningState: StateFlow = + useCase.isWarningUnderstood + .flatMapLatest { isUnderstood -> createWarningStateFlow(isUnderstood) } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + ProModeWarningState.CountingDown( + WARNING_COUNT_DOWN_SECONDS, + ), + ) + + val setupState: StateFlow> = + combine( + useCase.isSystemBridgeConnected, + useCase.isRootGranted, + useCase.shizukuSetupState, + useCase.isNotificationPermissionGranted, + ::buildSetupState, + ).stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) + + val autoStartBootEnabled: StateFlow = + useCase.isAutoStartBootEnabled + .stateIn(viewModelScope, SharingStarted.Eagerly, false) + + var showInfoCard by mutableStateOf(!useCase.isInfoDismissed()) + private set + + fun hideInfoCard() { + showInfoCard = false + // Save that they've dismissed the card so it is not shown by default the next + // time they visit the PRO mode page. + useCase.dismissInfo() + } + + fun showInfoCard() { + showInfoCard = true + } + + private fun createWarningStateFlow(isUnderstood: Boolean): Flow = + if (isUnderstood) { + flowOf(ProModeWarningState.Understood) + } else { + flow { + repeat(WARNING_COUNT_DOWN_SECONDS) { + emit(ProModeWarningState.CountingDown(WARNING_COUNT_DOWN_SECONDS - it)) + delay(1000L) + } + + emit(ProModeWarningState.Idle) + } + } + + fun onWarningButtonClick() { + useCase.onUnderstoodWarning() + } + + fun onStopServiceClick() { + useCase.stopSystemBridge() + } + + fun onRootButtonClick() { + useCase.startSystemBridgeWithRoot() + } + + fun onShizukuButtonClick() { + viewModelScope.launch { + val shizukuState = useCase.shizukuSetupState.first() + when (shizukuState) { + ShizukuSetupState.NOT_FOUND -> { + // Do nothing + } + + ShizukuSetupState.INSTALLED -> { + useCase.openShizukuApp() + } + + ShizukuSetupState.STARTED -> { + useCase.requestShizukuPermission() + } + + ShizukuSetupState.PERMISSION_GRANTED -> { + useCase.startSystemBridgeWithShizuku() + } + } + } + } + + fun onBackClick() { + viewModelScope.launch { + popBackStack() + } + } + + fun onSetupWithKeyMapperClick() { + viewModelScope.launch { + navigate("setup_pro_mode_with_key_mapper", NavDestination.ProModeSetup) + } + } + + fun onRequestNotificationPermissionClick() { + useCase.requestNotificationPermission() + } + + fun onAutoStartBootToggled() { + useCase.toggleAutoStartBoot() + } + + private fun buildSetupState( + isSystemBridgeConnected: Boolean, + isRootGranted: Boolean, + shizukuSetupState: ShizukuSetupState, + isNotificationPermissionGranted: Boolean, + ): State { + if (isSystemBridgeConnected) { + return State.Data(ProModeState.Started) + } else { + return State.Data( + ProModeState.Stopped( + isRootGranted = isRootGranted, + shizukuSetupState = shizukuSetupState, + isNotificationPermissionGranted = isNotificationPermissionGranted, + ), + ) + } + } +} + +sealed class ProModeWarningState { + data class CountingDown(val seconds: Int) : ProModeWarningState() + data object Idle : ProModeWarningState() + data object Understood : ProModeWarningState() +} + +sealed class ProModeState { + data class Stopped( + val isRootGranted: Boolean, + val shizukuSetupState: ShizukuSetupState, + val isNotificationPermissionGranted: Boolean, + ) : ProModeState() + + data object Started : ProModeState() +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/ShizukuSetupState.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/ShizukuSetupState.kt new file mode 100644 index 0000000000..1c9814a519 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/ShizukuSetupState.kt @@ -0,0 +1,8 @@ +package io.github.sds100.keymapper.base.promode + +enum class ShizukuSetupState { + NOT_FOUND, + INSTALLED, + STARTED, + PERMISSION_GRANTED, +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeAutoStarter.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeAutoStarter.kt new file mode 100644 index 0000000000..9d73d14af0 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeAutoStarter.kt @@ -0,0 +1,326 @@ +package io.github.sds100.keymapper.base.promode + +import android.os.Build +import android.os.SystemClock +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import io.github.sds100.keymapper.base.BaseMainActivity +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.system.notifications.NotificationController.Companion.CHANNEL_SETUP_ASSISTANT +import io.github.sds100.keymapper.base.system.notifications.NotificationController.Companion.ID_SYSTEM_BRIDGE_STATUS +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.notifications.KMNotificationAction +import io.github.sds100.keymapper.common.utils.Constants +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.sysbridge.BuildConfig +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupController +import io.github.sds100.keymapper.system.network.NetworkAdapter +import io.github.sds100.keymapper.system.notifications.NotificationAdapter +import io.github.sds100.keymapper.system.notifications.NotificationModel +import io.github.sds100.keymapper.system.permissions.Permission +import io.github.sds100.keymapper.system.permissions.PermissionAdapter +import io.github.sds100.keymapper.system.root.SuAdapter +import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import timber.log.Timber + +/** + * This class handles auto starting the system bridge when Key Mapper is launched and when + * the System Bridge is killed not due to the user. + */ +@RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) +@Singleton +class SystemBridgeAutoStarter @Inject constructor( + private val coroutineScope: CoroutineScope, + private val suAdapter: SuAdapter, + private val shizukuAdapter: ShizukuAdapter, + private val connectionManager: SystemBridgeConnectionManager, + private val setupController: SystemBridgeSetupController, + private val preferences: PreferenceRepository, + private val networkAdapter: NetworkAdapter, + private val permissionAdapter: PermissionAdapter, + private val notificationAdapter: NotificationAdapter, + private val resourceProvider: ResourceProvider, +) : ResourceProvider by resourceProvider { + enum class AutoStartType { + ADB, + SHIZUKU, + ROOT, + } + + // Use flatMapLatest so that any calls to ADB are only done if strictly necessary. + @OptIn(ExperimentalCoroutinesApi::class) + private val autoStartTypeFlow: Flow = + suAdapter.isRootGranted.flatMapLatest { isRooted -> + if (isRooted) { + flowOf(AutoStartType.ROOT) + } else { + shizukuAdapter.isStarted.flatMapLatest { isShizukuStarted -> + if (isShizukuStarted) { + flowOf(AutoStartType.SHIZUKU) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val isAdbAutoStartAllowed = combine( + permissionAdapter.isGrantedFlow(Permission.WRITE_SECURE_SETTINGS), + networkAdapter.isWifiConnected, + ) { isWriteSecureSettingsGranted, isWifiConnected -> + isWriteSecureSettingsGranted && + isWifiConnected && + setupController.isAdbPaired() + } + + isAdbAutoStartAllowed.distinctUntilChanged() + .map { isAdbAutoStartAllowed -> + if (isAdbAutoStartAllowed) AutoStartType.ADB else null + }.filterNotNull() + } else { + flowOf(null) + } + } + } + } + + /** + * This emits values when the system bridge needs restarting after it being killed. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private val restartFlow: Flow = + connectionManager.connectionState.flatMapLatest { connectionState -> + // Do not autostart if it is connected or it was killed from the user + if (connectionState !is SystemBridgeConnectionState.Disconnected || + connectionState.isExpected + ) { + flowOf(null) + } else { + // Do not autostart if the system bridge was killed shortly after. + // This prevents infinite loops happening. + if (lastAutoStartTime != null && + connectionState.time - lastAutoStartTime!! < 30000 + ) { + Timber.w( + "Not auto starting the system bridge because it was last auto started less than 30 secs ago", + ) + showSystemBridgeKilledNotification( + getString(R.string.system_bridge_died_notification_not_restarting_text), + ) + flowOf(null) + } else { + autoStartTypeFlow + } + } + } + + private var lastAutoStartTime: Long? = null + + /** + * This must only be called once in the application lifecycle + */ + @OptIn(FlowPreview::class) + fun init() { + coroutineScope.launch { + // The Key Mapper process may not necessarily be started on boot due to the + // on boot receiver so assume if it is started within a minute of boot that + // it should be auto started. + val isBoot = SystemClock.uptimeMillis() < 60000 + + if (isBoot) { + handleAutoStartOnBoot() + } else if (BuildConfig.DEBUG) { + Timber.i("Auto starting system bridge because debug build") + autoStartTypeFlow.first()?.let { autoStart(it) } + } else { + handleAutoStartFromPreVersion4() + } + + // Only start collecting the restart flow after potentially auto starting it for the first time. + restartFlow + .distinctUntilChanged() // Must come before the filterNotNull + .filterNotNull() + .collectLatest { type -> + autoStart(type) + } + } + } + + private suspend fun handleAutoStartOnBoot() { + // Do not autostart if the device was force rebooted. This may be a sign that PRO mode + // was broken and the user was trying to reset it. + val isCleanShutdown = preferences.get(Keys.isCleanShutdown).map { it ?: false }.first() + + Timber.i( + "SystemBridgeAutoStarter init: isBoot=true, isCleanShutdown=$isCleanShutdown", + ) + + // Reset the value after reading it. + preferences.set(Keys.isCleanShutdown, false) + + val isBootAutoStartEnabled = preferences.get(Keys.isProModeAutoStartBootEnabled) + .map { it ?: PreferenceDefaults.PRO_MODE_AUTOSTART_BOOT } + .first() + + // Wait 5 seconds for the system bridge to potentially connect itself to Key Mapper + // before starting it. + delay(5000) + + val connectionState = connectionManager.connectionState.value + + if (isCleanShutdown && + isBootAutoStartEnabled && + connectionState !is SystemBridgeConnectionState.Connected + ) { + val autoStartType = autoStartTypeFlow.first() + + if (autoStartType != null) { + autoStart(autoStartType) + } + } + } + + private suspend fun handleAutoStartFromPreVersion4() { + val isFirstTime = preferences.get(Keys.handledRootToProModeUpgrade).first() == null + + if (isFirstTime && suAdapter.isRootGranted.value) { + Timber.i( + "Auto starting system bridge because upgraded from pre version 4.0 and was rooted", + ) + + autoStart(AutoStartType.ROOT) + preferences.set(Keys.handledRootToProModeUpgrade, true) + } + } + + private suspend fun autoStart(type: AutoStartType) { + if (isSystemBridgeEmergencyKilled()) { + Timber.w( + "Not auto starting the system bridge because it was emergency killed by the user", + ) + return + } + + lastAutoStartTime = SystemClock.elapsedRealtime() + + when (type) { + AutoStartType.ADB -> { + Timber.i("Auto starting system bridge with ADB") + showAutoStartNotification( + getString( + R.string.pro_mode_setup_notification_auto_start_system_bridge_adb_text, + ), + ) + + setupController.autoStartWithAdb() + } + + AutoStartType.SHIZUKU -> { + Timber.i("Auto starting system bridge with Shizuku") + showAutoStartNotification( + getString( + R.string.pro_mode_setup_notification_auto_start_system_bridge_shizuku_text, + ), + ) + connectionManager.startWithShizuku() + } + + AutoStartType.ROOT -> { + Timber.i("Auto starting system bridge with root") + showAutoStartNotification( + getString( + R.string.pro_mode_setup_notification_auto_start_system_bridge_root_text, + ), + ) + connectionManager.startWithRoot() + } + } + + // Wait 30 seconds for it to start, and if not then show failed notification. + try { + withTimeout(30000L) { + connectionManager.connectionState.first { + it is SystemBridgeConnectionState.Connected + } + } + } catch (_: TimeoutCancellationException) { + showAutoStartFailedNotification() + } + } + + private suspend fun isSystemBridgeEmergencyKilled(): Boolean { + return preferences.get(Keys.isSystemBridgeEmergencyKilled).first() == true + } + + private fun showSystemBridgeKilledNotification(text: String) { + val model = NotificationModel( + id = ID_SYSTEM_BRIDGE_STATUS, + channel = CHANNEL_SETUP_ASSISTANT, + title = getString(R.string.system_bridge_died_notification_title), + text = text, + icon = R.drawable.pro_mode, + showOnLockscreen = true, + onGoing = false, + priority = NotificationCompat.PRIORITY_MAX, + autoCancel = true, + onClickAction = KMNotificationAction.Activity.MainActivity( + BaseMainActivity.ACTION_START_SYSTEM_BRIDGE, + ), + bigTextStyle = true, + ) + notificationAdapter.showNotification(model) + } + + private fun showAutoStartNotification(text: String) { + val model = NotificationModel( + id = ID_SYSTEM_BRIDGE_STATUS, + title = getString(R.string.pro_mode_setup_notification_auto_start_system_bridge_title), + text = text, + channel = CHANNEL_SETUP_ASSISTANT, + icon = R.drawable.pro_mode, + priority = NotificationCompat.PRIORITY_MAX, + onGoing = true, + showIndeterminateProgress = true, + showOnLockscreen = false, + ) + + notificationAdapter.showNotification(model) + } + + private fun showAutoStartFailedNotification() { + val model = NotificationModel( + id = ID_SYSTEM_BRIDGE_STATUS, + title = getString( + R.string.pro_mode_setup_notification_start_system_bridge_failed_title, + ), + text = getString(R.string.pro_mode_setup_notification_start_system_bridge_failed_text), + channel = CHANNEL_SETUP_ASSISTANT, + icon = R.drawable.pro_mode, + onGoing = false, + showOnLockscreen = false, + autoCancel = true, + priority = NotificationCompat.PRIORITY_MAX, + onClickAction = KMNotificationAction.Activity.MainActivity( + BaseMainActivity.ACTION_START_SYSTEM_BRIDGE, + ), + ) + + notificationAdapter.showNotification(model) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupAssistantController.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupAssistantController.kt new file mode 100644 index 0000000000..d7e0ae42db --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupAssistantController.kt @@ -0,0 +1,392 @@ +package io.github.sds100.keymapper.base.promode + +import android.app.ActivityManager +import android.os.Build +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.getSystemService +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.github.sds100.keymapper.base.BaseMainActivity +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.system.accessibility.BaseAccessibilityService +import io.github.sds100.keymapper.base.system.accessibility.findNodeRecursively +import io.github.sds100.keymapper.base.system.notifications.ManageNotificationsUseCase +import io.github.sds100.keymapper.base.system.notifications.NotificationController +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.KeyMapperClassProvider +import io.github.sds100.keymapper.common.notifications.KMNotificationAction +import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.common.utils.onFailure +import io.github.sds100.keymapper.common.utils.onSuccess +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.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupController +import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupStep +import io.github.sds100.keymapper.system.notifications.NotificationChannelModel +import io.github.sds100.keymapper.system.notifications.NotificationModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import timber.log.Timber + +@Suppress("KotlinConstantConditions") +@RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) +class SystemBridgeSetupAssistantController @AssistedInject constructor( + @Assisted + private val coroutineScope: CoroutineScope, + @Assisted + private val accessibilityService: BaseAccessibilityService, + private val manageNotifications: ManageNotificationsUseCase, + private val setupController: SystemBridgeSetupController, + private val preferenceRepository: PreferenceRepository, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val keyMapperClassProvider: KeyMapperClassProvider, + resourceProvider: ResourceProvider, +) : ResourceProvider by resourceProvider { + @AssistedFactory + interface Factory { + fun create( + coroutineScope: CoroutineScope, + accessibilityService: BaseAccessibilityService, + ): SystemBridgeSetupAssistantController + } + + companion object { + /** + * The max time to spend searching for an accessibility node. + */ + const val INTERACTION_TIMEOUT = 10000L + + private val PAIRING_CODE_REGEX = Regex("^\\d{6}$") + private val IPV4_REGEX = + Regex( + "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", + ) + } + + private enum class InteractionStep { + // Do not automatically turn on the wireless debugging switch. When the user turns it on, + // Key Mapper will automatically pair. + PAIR_DEVICE, + } + + private val activityManager: ActivityManager = accessibilityService.getSystemService()!! + + private val isInteractive: StateFlow = + preferenceRepository.get(Keys.isProModeInteractiveSetupAssistantEnabled) + .map { it ?: PreferenceDefaults.PRO_MODE_INTERACTIVE_SETUP_ASSISTANT } + .stateIn( + coroutineScope, + SharingStarted.Eagerly, + PreferenceDefaults.PRO_MODE_INTERACTIVE_SETUP_ASSISTANT, + ) + + private var interactionStep: InteractionStep? = null + + /** + * This job will wait for the interaction timeout and then + * ask the user to do the steps manually if it failed to do them automatically. + */ + private var interactionTimeoutJob: Job? = null + + // Store the pairing code so only one request to pair is sent per pairing code. + private var foundPairingCode: String? = null + + fun onServiceConnected() { + createNotificationChannel() + + coroutineScope.launch { + setupController.setupAssistantStep.collect { step -> + if (step == null) { + stopInteracting() + dismissNotification() + } else { + startSetupStep(step) + } + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + coroutineScope.launch { + manageNotifications.onNotificationTextInput + .filter { + it.intentAction == + KMNotificationAction.IntentAction.PAIRING_CODE_REPLY + } + .collect { textInput -> + Timber.i("Receive pairing code text input: $textInput") + + val pairingCode: String = textInput.text.trim() + onPairingCodeFound(pairingCode) + } + } + } + } + + private fun createNotificationChannel() { + val notificationChannel = NotificationChannelModel( + id = NotificationController.Companion.CHANNEL_SETUP_ASSISTANT, + name = getString(R.string.pro_mode_setup_assistant_notification_channel), + importance = NotificationManagerCompat.IMPORTANCE_MAX, + ) + manageNotifications.createChannel(notificationChannel) + } + + fun teardown() { + dismissNotification() + stopInteracting() + } + + fun onAccessibilityEvent(event: AccessibilityEvent) { + // Do not do anything if there is no node to find. + if (interactionStep == null) { + return + } + + // Do not do anything if the interactive setup assistant is disabled + if (!isInteractive.value) { + return + } + + if (event.eventType == AccessibilityEvent.TYPE_WINDOWS_CHANGED) { + val step = interactionStep ?: return + val rootNode = accessibilityService.rootInActiveWindow ?: return + + if (rootNode.packageName != "com.android.settings") { + return + } + + doInteractiveStep(step, rootNode) + } + } + + private fun doInteractiveStep(step: InteractionStep, rootNode: AccessibilityNodeInfo) { + when (step) { + InteractionStep.PAIR_DEVICE -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + doPairingInteractiveStep(rootNode) + } + } + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun doPairingInteractiveStep(rootNode: AccessibilityNodeInfo) { + val pairingCodeText = findPairingCodeText(rootNode) + + if (pairingCodeText == null) { + clickPairWithCodeButton(rootNode) + } else { + val pairingCode = pairingCodeText.trim() + + coroutineScope.launch { + onPairingCodeFound(pairingCode) + } + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private suspend fun onPairingCodeFound(pairingCode: String) { + // Only pair once per pairing code. + if (foundPairingCode == pairingCode) { + return + } + + foundPairingCode = pairingCode + + Timber.i("Pairing code found $pairingCode. Pairing ADB...") + setupController.pairWirelessAdb(pairingCode).onSuccess { + onPairingSuccess() + }.onFailure { + Timber.e("Failed to pair with wireless ADB: $it") + stopInteracting() + + showNotification( + getString(R.string.pro_mode_setup_notification_invalid_pairing_code_title), + getString(R.string.pro_mode_setup_notification_invalid_pairing_code_text), + actions = listOf( + KMNotificationAction.RemoteInput.PairingCode to + getString(R.string.pro_mode_setup_notification_action_input_pairing_code), + ), + ) + } + } + + private suspend fun onPairingSuccess() { + setupController.startWithAdb() + + stopInteracting() + + val isStarted = try { + withTimeout(10000L) { + systemBridgeConnectionManager.connectionState + .filterIsInstance() + .first() + } + + true + } catch (_: TimeoutCancellationException) { + false + } + + if (isStarted) { + getKeyMapperAppTask()?.moveToFront() + } else { + Timber.e("Failed to start system bridge after pairing.") + showNotification( + getString(R.string.pro_mode_setup_notification_start_system_bridge_failed_title), + getString(R.string.pro_mode_setup_notification_start_system_bridge_failed_text), + onClickAction = KMNotificationAction.Activity.MainActivity( + BaseMainActivity.ACTION_START_SYSTEM_BRIDGE, + ), + ) + } + } + + private fun clickPairWithCodeButton(rootNode: AccessibilityNodeInfo) { + rootNode + .findNodeRecursively { it.className == "androidx.recyclerview.widget.RecyclerView" } + ?.takeIf { recyclerView -> + // There are many settings screens with RecyclerViews so make sure + // the correct page is showing before clicking. It is not as simple + // as checking the words on the screen due to different languages. + val ipAddressPortText: CharSequence? = + runCatching { + // RecyclerView -> LinearLayout -> RelativeLayout -> TextView + recyclerView.getChild(1).getChild(0).getChild(1) + }.getOrNull()?.text + + val ipText = ipAddressPortText?.split(":")?.firstOrNull() + ipText != null && IPV4_REGEX.matches(ipText) + } + ?.runCatching { getChild(3) } + ?.getOrNull() + ?.performAction(AccessibilityNodeInfo.ACTION_CLICK) + } + + private fun showNotification( + title: String, + text: String, + onClickAction: KMNotificationAction? = null, + actions: List> = emptyList(), + ) { + val notification = NotificationModel( + // Use the same notification id for all so they overwrite each other. + id = NotificationController.Companion.ID_SETUP_ASSISTANT, + channel = NotificationController.Companion.CHANNEL_SETUP_ASSISTANT, + title = title, + text = text, + icon = R.drawable.pro_mode, + onGoing = false, + showOnLockscreen = false, + autoCancel = true, + onClickAction = onClickAction, + bigTextStyle = true, + // Must not be silent so it is shown as a heads up notification + silent = false, + actions = actions, + ) + manageNotifications.show(notification) + } + + private fun dismissNotification() { + manageNotifications.dismiss(NotificationController.Companion.ID_SETUP_ASSISTANT) + } + + private fun findPairingCodeText(rootNode: AccessibilityNodeInfo): String? { + return rootNode.findNodeRecursively { + it.text != null && PAIRING_CODE_REGEX.matches(it.text) + }?.text?.toString() + } + + private fun startSetupStep(step: SystemBridgeSetupStep) { + Timber.i("Starting setup assistant step: $step") + when (step) { + SystemBridgeSetupStep.DEVELOPER_OPTIONS -> { + showNotification( + getString(R.string.pro_mode_setup_notification_tap_build_number_title), + getString(R.string.pro_mode_setup_notification_tap_build_number_text), + ) + } + + SystemBridgeSetupStep.ADB_PAIRING -> { + showNotification( + getString(R.string.pro_mode_setup_notification_pairing_title), + getString(R.string.pro_mode_setup_notification_pairing_text), + ) + + interactionStep = InteractionStep.PAIR_DEVICE + } + + else -> return // Do not start interaction timeout job + } + + startInteractionTimeoutJob() + } + + private fun startInteractionTimeoutJob() { + interactionTimeoutJob?.cancel() + interactionTimeoutJob = coroutineScope.launch { + delay(INTERACTION_TIMEOUT) + + if (interactionStep == InteractionStep.PAIR_DEVICE) { + Timber.i("Interaction timed out. Asking user to input pairing code manually.") + + showNotification( + title = getString( + R.string.pro_mode_setup_notification_pairing_button_not_found_title, + ), + text = getString( + R.string.pro_mode_setup_notification_pairing_button_not_found_text, + ), + actions = listOf( + KMNotificationAction.RemoteInput.PairingCode to + getString( + R.string.pro_mode_setup_notification_action_input_pairing_code, + ), + ), + ) + + // Give the user 30 seconds to input the pairing code and then dismiss the notification. + delay(30000) + } + + dismissNotification() + + interactionStep = null + } + } + + private fun stopInteracting() { + interactionStep = null + interactionTimeoutJob?.cancel() + interactionTimeoutJob = null + } + + private fun getKeyMapperAppTask(): ActivityManager.AppTask? { + val task = activityManager.appTasks + .firstOrNull { + it.taskInfo.topActivity?.className == + keyMapperClassProvider.getMainActivity().name + } + + return task + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt new file mode 100644 index 0000000000..d75044ae03 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/promode/SystemBridgeSetupUseCase.kt @@ -0,0 +1,256 @@ +package io.github.sds100.keymapper.base.promode + +import android.os.Build +import androidx.annotation.RequiresApi +import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.common.utils.firstBlocking +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.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupController +import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupStep +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState +import io.github.sds100.keymapper.system.network.NetworkAdapter +import io.github.sds100.keymapper.system.permissions.Permission +import io.github.sds100.keymapper.system.permissions.PermissionAdapter +import io.github.sds100.keymapper.system.root.SuAdapter +import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +@RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) +@ViewModelScoped +class SystemBridgeSetupUseCaseImpl @Inject constructor( + private val preferences: PreferenceRepository, + private val suAdapter: SuAdapter, + private val systemBridgeSetupController: SystemBridgeSetupController, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val shizukuAdapter: ShizukuAdapter, + private val permissionAdapter: PermissionAdapter, + private val accessibilityServiceAdapter: AccessibilityServiceAdapter, + private val networkAdapter: NetworkAdapter, +) : SystemBridgeSetupUseCase { + override val isWarningUnderstood: Flow = + preferences.get(Keys.isProModeWarningUnderstood).map { it ?: false } + + private val isAdbAutoStartAllowed: Flow = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + combine( + permissionAdapter.isGrantedFlow(Permission.WRITE_SECURE_SETTINGS), + networkAdapter.isWifiConnected, + ) { isWriteSecureSettingsGranted, isWifiConnected -> + isWriteSecureSettingsGranted && + isWifiConnected && + systemBridgeSetupController.isAdbPaired() + }.flowOn(Dispatchers.IO) + } else { + flowOf(false) + } + + override fun onUnderstoodWarning() { + preferences.set(Keys.isProModeWarningUnderstood, true) + } + + override val isSetupAssistantEnabled: Flow = + preferences.get(Keys.isProModeInteractiveSetupAssistantEnabled).map { + it ?: PreferenceDefaults.PRO_MODE_INTERACTIVE_SETUP_ASSISTANT + } + + override fun toggleSetupAssistant() { + preferences.update(Keys.isProModeInteractiveSetupAssistantEnabled) { + if (it == null) { + !PreferenceDefaults.PRO_MODE_INTERACTIVE_SETUP_ASSISTANT + } else { + !it + } + } + } + + override val isSystemBridgeConnected: Flow = + systemBridgeConnectionManager.connectionState + .map { it is SystemBridgeConnectionState.Connected } + + override val isNotificationPermissionGranted: Flow = + permissionAdapter.isGrantedFlow(Permission.POST_NOTIFICATIONS) + + @OptIn(ExperimentalCoroutinesApi::class) + @RequiresApi(Build.VERSION_CODES.R) + override val nextSetupStep: Flow = + isSystemBridgeConnected.flatMapLatest { isConnected -> + if (isConnected) { + flowOf(SystemBridgeSetupStep.STARTED) + } else { + isAdbAutoStartAllowed.flatMapLatest { isAdbAutoStartAllowed -> + if (isAdbAutoStartAllowed) { + flowOf(SystemBridgeSetupStep.START_SERVICE) + } else { + combine( + accessibilityServiceAdapter.state, + isNotificationPermissionGranted, + systemBridgeSetupController.isDeveloperOptionsEnabled, + networkAdapter.isWifiConnected, + systemBridgeSetupController.isWirelessDebuggingEnabled, + ::getNextStep, + ) + } + } + } + } + + override val isRootGranted: Flow = suAdapter.isRootGranted + + override val shizukuSetupState: Flow = combine( + shizukuAdapter.isInstalled, + shizukuAdapter.isStarted, + permissionAdapter.isGrantedFlow(Permission.SHIZUKU), + ) { isInstalled, isStarted, isPermissionGranted -> + when { + isPermissionGranted -> ShizukuSetupState.PERMISSION_GRANTED + isStarted -> ShizukuSetupState.STARTED + isInstalled -> ShizukuSetupState.INSTALLED + else -> ShizukuSetupState.NOT_FOUND + } + } + + override fun openShizukuApp() { + shizukuAdapter.openShizukuApp() + } + + override fun requestShizukuPermission() { + permissionAdapter.request(Permission.SHIZUKU) + } + + override fun requestNotificationPermission() { + permissionAdapter.request(Permission.POST_NOTIFICATIONS) + } + + override fun stopSystemBridge() { + systemBridgeConnectionManager.stopSystemBridge() + } + + override fun enableAccessibilityService() { + accessibilityServiceAdapter.start() + } + + override fun enableDeveloperOptions() { + systemBridgeSetupController.enableDeveloperOptions() + } + + override fun connectWifiNetwork() { + networkAdapter.connectWifiNetwork() + } + + override fun enableWirelessDebugging() { + systemBridgeSetupController.enableWirelessDebugging() + } + + @RequiresApi(Build.VERSION_CODES.R) + override fun pairWirelessAdb() { + systemBridgeSetupController.launchPairingAssistant() + } + + override fun startSystemBridgeWithRoot() { + preferences.set(Keys.isSystemBridgeEmergencyKilled, false) + systemBridgeSetupController.startWithRoot() + } + + override fun startSystemBridgeWithShizuku() { + preferences.set(Keys.isSystemBridgeEmergencyKilled, false) + systemBridgeSetupController.startWithShizuku() + } + + override suspend fun startSystemBridgeWithAdb() { + preferences.set(Keys.isSystemBridgeEmergencyKilled, false) + if (isAdbAutoStartAllowed.first()) { + systemBridgeSetupController.autoStartWithAdb() + } else { + systemBridgeSetupController.startWithAdb() + } + } + + override fun isInfoDismissed(): Boolean { + return preferences.get(Keys.isProModeInfoDismissed).map { it ?: false }.firstBlocking() + } + + override fun dismissInfo() { + preferences.set(Keys.isProModeInfoDismissed, true) + } + + override val isAutoStartBootEnabled: Flow = + preferences.get(Keys.isProModeAutoStartBootEnabled) + .map { it ?: PreferenceDefaults.PRO_MODE_AUTOSTART_BOOT } + + override fun toggleAutoStartBoot() { + preferences.update(Keys.isProModeAutoStartBootEnabled) { + !(it ?: PreferenceDefaults.PRO_MODE_AUTOSTART_BOOT) + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun getNextStep( + accessibilityServiceState: AccessibilityServiceState, + isNotificationPermissionGranted: Boolean, + isDeveloperOptionsEnabled: Boolean, + isWifiConnected: Boolean, + isWirelessDebuggingEnabled: Boolean, + ): SystemBridgeSetupStep { + return when { + accessibilityServiceState != AccessibilityServiceState.ENABLED -> + SystemBridgeSetupStep.ACCESSIBILITY_SERVICE + !isNotificationPermissionGranted -> SystemBridgeSetupStep.NOTIFICATION_PERMISSION + !isDeveloperOptionsEnabled -> SystemBridgeSetupStep.DEVELOPER_OPTIONS + !isWifiConnected -> SystemBridgeSetupStep.WIFI_NETWORK + !isWirelessDebuggingEnabled -> SystemBridgeSetupStep.WIRELESS_DEBUGGING + isWirelessDebuggingEnabled -> SystemBridgeSetupStep.ADB_PAIRING + else -> SystemBridgeSetupStep.START_SERVICE + } + } +} + +interface SystemBridgeSetupUseCase { + val isWarningUnderstood: Flow + fun onUnderstoodWarning() + + fun isInfoDismissed(): Boolean + fun dismissInfo() + + val isAutoStartBootEnabled: Flow + fun toggleAutoStartBoot() + + val isSetupAssistantEnabled: Flow + fun toggleSetupAssistant() + + val isSystemBridgeConnected: Flow + val nextSetupStep: Flow + + val isRootGranted: Flow + + val shizukuSetupState: Flow + fun openShizukuApp() + fun requestShizukuPermission() + + val isNotificationPermissionGranted: Flow + fun requestNotificationPermission() + + fun stopSystemBridge() + fun enableAccessibilityService() + fun enableDeveloperOptions() + fun connectWifiNetwork() + fun enableWirelessDebugging() + fun pairWirelessAdb() + fun startSystemBridgeWithRoot() + fun startSystemBridgeWithShizuku() + suspend fun startSystemBridgeWithAdb() +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/purchasing/PurchasingManager.kt b/base/src/main/java/io/github/sds100/keymapper/base/purchasing/PurchasingManager.kt index 2ddfb3f0f7..bd95640f59 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/purchasing/PurchasingManager.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/purchasing/PurchasingManager.kt @@ -10,6 +10,7 @@ interface PurchasingManager { val purchases: Flow>>> suspend fun launchPurchasingFlow(product: ProductId): KMResult suspend fun getProductPrice(product: ProductId): KMResult + suspend fun getMetadata(): KMResult> suspend fun isPurchased(product: ProductId): KMResult fun refresh() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/reroutekeyevents/RerouteKeyEventsController.kt b/base/src/main/java/io/github/sds100/keymapper/base/reroutekeyevents/RerouteKeyEventsController.kt deleted file mode 100644 index 05331b8032..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/reroutekeyevents/RerouteKeyEventsController.kt +++ /dev/null @@ -1,130 +0,0 @@ -package io.github.sds100.keymapper.base.reroutekeyevents - -import android.view.KeyEvent -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import io.github.sds100.keymapper.base.system.inputmethod.ImeInputEventInjector -import io.github.sds100.keymapper.common.utils.InputEventType -import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import io.github.sds100.keymapper.system.inputevents.MyKeyEvent -import io.github.sds100.keymapper.system.inputmethod.InputKeyModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -/** - * This is used for the feature created in issue #618 to fix the device IDs of key events - * on Android 11. There was a bug in the system where enabling an accessibility service - * would reset the device ID of key events to -1. - */ -class RerouteKeyEventsController @AssistedInject constructor( - @Assisted - private val coroutineScope: CoroutineScope, - @Assisted - private val keyMapperImeMessenger: ImeInputEventInjector, - private val useCaseFactory: RerouteKeyEventsUseCaseImpl.Factory, -) { - @AssistedFactory - interface Factory { - fun create( - coroutineScope: CoroutineScope, - keyMapperImeMessenger: ImeInputEventInjector, - ): RerouteKeyEventsController - } - - private val useCase = useCaseFactory.create(keyMapperImeMessenger) - - /** - * The job of the key that should be repeating. This should be a down key event for the last - * key that has been pressed down. - * The old job should be cancelled whenever the key has been released - * or a new key has been pressed down - */ - private var repeatJob: Job? = null - - fun onKeyEvent(event: MyKeyEvent): Boolean { - if (!useCase.shouldRerouteKeyEvent(event.device?.descriptor)) { - return false - } - - return when (event.action) { - KeyEvent.ACTION_DOWN -> onKeyDown( - event.keyCode, - event.device, - event.metaState, - event.scanCode, - ) - - KeyEvent.ACTION_UP -> onKeyUp( - event.keyCode, - event.device, - event.metaState, - event.scanCode, - ) - - else -> false - } - } - - /** - * @return whether to consume the key event. - */ - private fun onKeyDown( - keyCode: Int, - device: InputDeviceInfo?, - metaState: Int, - scanCode: Int = 0, - ): Boolean { - val inputKeyModel = InputKeyModel( - keyCode = keyCode, - inputType = InputEventType.DOWN, - metaState = metaState, - deviceId = device?.id ?: 0, - scanCode = scanCode, - repeat = 0, - ) - - useCase.inputKeyEvent(inputKeyModel) - - repeatJob?.cancel() - - repeatJob = coroutineScope.launch { - delay(400) - - var repeatCount = 1 - - while (isActive) { - useCase.inputKeyEvent(inputKeyModel.copy(repeat = repeatCount)) - delay(50) - repeatCount++ - } - } - - return true - } - - private fun onKeyUp( - keyCode: Int, - device: InputDeviceInfo?, - metaState: Int, - scanCode: Int = 0, - ): Boolean { - repeatJob?.cancel() - - val inputKeyModel = InputKeyModel( - keyCode = keyCode, - inputType = InputEventType.UP, - metaState = metaState, - deviceId = device?.id ?: 0, - scanCode = scanCode, - repeat = 0, - ) - - useCase.inputKeyEvent(inputKeyModel) - - return true - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/reroutekeyevents/RerouteKeyEventsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/reroutekeyevents/RerouteKeyEventsUseCase.kt deleted file mode 100644 index 83e7af7e95..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/reroutekeyevents/RerouteKeyEventsUseCase.kt +++ /dev/null @@ -1,76 +0,0 @@ -package io.github.sds100.keymapper.base.reroutekeyevents - -import android.os.Build -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import io.github.sds100.keymapper.base.system.inputmethod.ImeInputEventInjector -import io.github.sds100.keymapper.base.system.inputmethod.KeyMapperImeHelper -import io.github.sds100.keymapper.common.BuildConfigProvider -import io.github.sds100.keymapper.common.utils.firstBlocking -import io.github.sds100.keymapper.data.Keys -import io.github.sds100.keymapper.data.repositories.PreferenceRepository -import io.github.sds100.keymapper.system.inputmethod.InputKeyModel -import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking - -/** - * This is used for the feature created in issue #618 to fix the device IDs of key events - * on Android 11. There was a bug in the system where enabling an accessibility service - * would reset the device ID of key events to -1. - */ -class RerouteKeyEventsUseCaseImpl @AssistedInject constructor( - @Assisted - private val keyMapperImeMessenger: ImeInputEventInjector, - private val inputMethodAdapter: InputMethodAdapter, - private val preferenceRepository: PreferenceRepository, - private val buildConfigProvider: BuildConfigProvider, -) : RerouteKeyEventsUseCase { - - @AssistedFactory - interface Factory { - fun create( - keyMapperImeMessenger: ImeInputEventInjector, - ): RerouteKeyEventsUseCaseImpl - } - - private val rerouteKeyEvents = - preferenceRepository.get(Keys.rerouteKeyEvents).map { it ?: false } - - private val devicesToRerouteKeyEvents = - preferenceRepository.get(Keys.devicesToRerouteKeyEvents).map { it ?: emptyList() } - - private val imeHelper by lazy { - KeyMapperImeHelper( - inputMethodAdapter, - buildConfigProvider.packageName, - ) - } - - override fun shouldRerouteKeyEvent(descriptor: String?): Boolean { - if (Build.VERSION.SDK_INT != Build.VERSION_CODES.R) { - return false - } - - return rerouteKeyEvents.firstBlocking() && - imeHelper.isCompatibleImeChosen() && - ( - descriptor != null && - devicesToRerouteKeyEvents.firstBlocking() - .contains(descriptor) - ) - } - - override fun inputKeyEvent(keyModel: InputKeyModel) { - // It is safe to run the ime injector on the main thread. - runBlocking { - keyMapperImeMessenger.inputKeyEvent(keyModel) - } - } -} - -interface RerouteKeyEventsUseCase { - fun shouldRerouteKeyEvent(descriptor: String?): Boolean - fun inputKeyEvent(keyModel: InputKeyModel) -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/Android11BugWorkaroundSettingsFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/Android11BugWorkaroundSettingsFragment.kt deleted file mode 100644 index f82b8f2e84..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/Android11BugWorkaroundSettingsFragment.kt +++ /dev/null @@ -1,192 +0,0 @@ -package io.github.sds100.keymapper.base.settings - -import android.os.Bundle -import android.view.View -import androidx.lifecycle.Lifecycle -import androidx.preference.Preference -import androidx.preference.SwitchPreference -import androidx.preference.isEmpty -import dagger.hilt.android.AndroidEntryPoint -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.ChooseAppStoreModel -import io.github.sds100.keymapper.base.utils.ui.DialogModel -import io.github.sds100.keymapper.base.utils.ui.drawable -import io.github.sds100.keymapper.base.utils.ui.launchRepeatOnLifecycle -import io.github.sds100.keymapper.base.utils.ui.showDialog -import io.github.sds100.keymapper.base.utils.ui.str -import io.github.sds100.keymapper.base.utils.ui.viewLifecycleScope -import io.github.sds100.keymapper.data.Keys -import io.github.sds100.keymapper.system.leanback.LeanbackUtils -import io.github.sds100.keymapper.system.url.UrlUtils -import kotlinx.coroutines.flow.collectLatest - -@AndroidEntryPoint -class Android11BugWorkaroundSettingsFragment : BaseSettingsFragment() { - - companion object { - private const val KEY_ENABLE_COMPATIBLE_IME = "pref_key_enable_compatible_ime" - private const val KEY_CHOSE_COMPATIBLE_IME = "pref_key_chose_compatible_ime" - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceManager.preferenceDataStore = viewModel.sharedPrefsDataStoreWrapper - addPreferencesFromResource(R.xml.preferences_empty) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewLifecycleScope.launchWhenResumed { - if (preferenceScreen.isEmpty()) { - populatePreferenceScreen() - } - } - } - - private fun populatePreferenceScreen() = preferenceScreen.apply { - val isTvDevice = LeanbackUtils.isTvDevice(requireContext()) - - SwitchPreference(requireContext()).apply { - key = Keys.rerouteKeyEvents.name - setDefaultValue(false) - - setTitle(R.string.title_pref_reroute_keyevents) - setSummary(R.string.summary_pref_reroute_keyevents) - isSingleLineTitle = false - - addPreference(this) - } - - Preference(requireContext()).apply { - setTitle(R.string.title_pref_devices_to_reroute_keyevents_guide) - setOnPreferenceClickListener { - UrlUtils.openUrl( - requireContext(), - str(R.string.url_android_11_bug_reset_id_work_around_setting_guide), - ) - - true - } - - addPreference(this) - } - - Preference(requireContext()).apply { - if (isTvDevice) { - setTitle(R.string.title_pref_devices_to_reroute_keyevents_install_leanback_keyboard) - } else { - setTitle(R.string.title_pref_devices_to_reroute_keyevents_install_gui_keyboard) - } - - isSingleLineTitle = false - - setOnPreferenceClickListener { - viewLifecycleScope.launchWhenResumed { - if (isTvDevice) { - val chooseAppStoreDialog = DialogModel.ChooseAppStore( - title = getString(R.string.dialog_title_choose_download_leanback_keyboard), - message = getString(R.string.dialog_message_choose_download_leanback_keyboard), - model = ChooseAppStoreModel( - githubLink = getString(R.string.url_github_keymapper_leanback_keyboard), - ), - negativeButtonText = str(R.string.neg_cancel), - ) - - viewModel.showDialog("download_leanback_ime", chooseAppStoreDialog) - } else { - val chooseAppStoreDialog = DialogModel.ChooseAppStore( - title = getString(R.string.dialog_title_choose_download_gui_keyboard), - message = getString(R.string.dialog_message_choose_download_gui_keyboard), - model = ChooseAppStoreModel( - playStoreLink = getString(R.string.url_play_store_keymapper_gui_keyboard), - fdroidLink = getString(R.string.url_fdroid_keymapper_gui_keyboard), - githubLink = getString(R.string.url_github_keymapper_gui_keyboard), - ), - negativeButtonText = str(R.string.neg_cancel), - ) - - viewModel.showDialog("download_gui_keyboard", chooseAppStoreDialog) - } - } - - true - } - - addPreference(this) - } - - Preference(requireContext()).apply { - key = KEY_ENABLE_COMPATIBLE_IME - - if (isTvDevice) { - setTitle(R.string.title_pref_devices_to_reroute_keyevents_enable_ime_leanback) - } else { - setTitle(R.string.title_pref_devices_to_reroute_keyevents_enable_ime_gui) - } - - isSingleLineTitle = false - - setOnPreferenceClickListener { - viewModel.onEnableCompatibleImeClick() - - true - } - - addPreference(this) - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.isCompatibleImeEnabled.collectLatest { isCompatibleImeEnabled -> - icon = if (isCompatibleImeEnabled) { - drawable(R.drawable.ic_outline_check_circle_outline_24) - } else { - drawable(R.drawable.ic_baseline_error_outline_24) - } - } - } - } - - Preference(requireContext()).apply { - key = KEY_CHOSE_COMPATIBLE_IME - setTitle(R.string.title_pref_devices_to_reroute_keyevents_choose_ime) - isSingleLineTitle = false - setOnPreferenceClickListener { - viewModel.onChooseCompatibleImeClick() - - true - } - - addPreference(this) - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.isCompatibleImeChosen.collectLatest { isCompatibleImeChosen -> - icon = if (isCompatibleImeChosen) { - drawable(R.drawable.ic_outline_check_circle_outline_24) - } else { - drawable(R.drawable.ic_baseline_error_outline_24) - } - } - } - } - - addPreference( - SettingsUtils.createChooseDevicesPreference( - requireContext(), - viewModel, - Keys.devicesToRerouteKeyEvents, - R.string.title_pref_devices_to_reroute_keyevents_choose_devices, - ), - ) - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.rerouteKeyEvents.collectLatest { enabled -> - for (i in 0 until preferenceCount) { - getPreference(i).apply { - if (this.key != Keys.rerouteKeyEvents.name) { - this.isVisible = enabled - } - } - } - } - } - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/AutomaticChangeImeSettingsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/AutomaticChangeImeSettingsScreen.kt new file mode 100644 index 0000000000..69460ef987 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/AutomaticChangeImeSettingsScreen.kt @@ -0,0 +1,223 @@ +package io.github.sds100.keymapper.base.settings + +import android.os.Build +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.rememberScrollState +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.Notifications +import androidx.compose.material.icons.rounded.Devices +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.utils.ui.compose.OptionPageButton +import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow +import io.github.sds100.keymapper.base.utils.ui.compose.SwitchPreferenceCompose + +@Composable +fun AutomaticChangeImeSettingsScreen(modifier: Modifier = Modifier, viewModel: SettingsViewModel) { + val state by viewModel.automaticChangeImeSettingsState.collectAsStateWithLifecycle() + val snackbarHostState = SnackbarHostState() + + AutomaticChangeImeSettingsScreen( + modifier, + onBackClick = viewModel::onBackClick, + snackbarHostState = snackbarHostState, + ) { + Content( + state = state, + onShowToastWhenAutoChangingImeToggled = + viewModel::onShowToastWhenAutoChangingImeToggled, + onChangeImeOnInputFocusToggled = viewModel::onChangeImeOnInputFocusToggled, + onChangeImeOnDeviceConnectToggled = viewModel::onChangeImeOnDeviceConnectToggled, + onDevicesThatChangeImeClick = viewModel::onDevicesThatChangeImeClick, + onToggleKeyboardOnToggleKeymapsToggled = + viewModel::onToggleKeyboardOnToggleKeymapsToggled, + onShowToggleKeyboardNotificationClick = + viewModel::onShowToggleKeyboardNotificationClick, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AutomaticChangeImeSettingsScreen( + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + snackbarHostState: SnackbarHostState = SnackbarHostState(), + content: @Composable () -> Unit, +) { + Scaffold( + modifier = modifier.displayCutoutPadding(), + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_pref_automatically_change_ime)) }, + ) + }, + bottomBar = { + BottomAppBar { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.action_go_back), + ) + } + } + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + content() + } + } +} + +@Composable +private fun Content( + modifier: Modifier = Modifier, + state: AutomaticChangeImeSettingsState, + onShowToastWhenAutoChangingImeToggled: (Boolean) -> Unit = { }, + onChangeImeOnInputFocusToggled: (Boolean) -> Unit = { }, + onChangeImeOnDeviceConnectToggled: (Boolean) -> Unit = { }, + onDevicesThatChangeImeClick: () -> Unit = { }, + onToggleKeyboardOnToggleKeymapsToggled: (Boolean) -> Unit = { }, + onShowToggleKeyboardNotificationClick: () -> Unit = { }, +) { + Column( + modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + SwitchPreferenceCompose( + title = stringResource(R.string.title_pref_auto_change_ime_on_input_focus), + text = stringResource(R.string.summary_pref_auto_change_ime_on_input_focus), + icon = Icons.Rounded.SwapHoriz, + isChecked = state.changeImeOnInputFocus, + onCheckedChange = onChangeImeOnInputFocusToggled, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + SwitchPreferenceCompose( + title = stringResource(R.string.title_pref_auto_change_ime_on_connection), + text = stringResource(R.string.summary_pref_auto_change_ime_on_connection), + icon = Icons.Rounded.SwapHoriz, + isChecked = state.changeImeOnDeviceConnect, + onCheckedChange = onChangeImeOnDeviceConnectToggled, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OptionPageButton( + title = stringResource(R.string.title_pref_automatically_change_ime_choose_devices), + text = stringResource(R.string.summary_pref_automatically_change_ime_choose_devices), + icon = Icons.Rounded.Devices, + onClick = onDevicesThatChangeImeClick, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + SwitchPreferenceCompose( + title = stringResource(R.string.title_pref_toggle_keyboard_on_toggle_keymaps), + text = stringResource(R.string.summary_pref_toggle_keyboard_on_toggle_keymaps), + icon = Icons.Rounded.SwapHoriz, + isChecked = state.toggleKeyboardOnToggleKeymaps, + onCheckedChange = onToggleKeyboardOnToggleKeymapsToggled, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OptionsHeaderRow( + modifier = Modifier.fillMaxWidth(), + icon = Icons.Outlined.Notifications, + text = stringResource(R.string.settings_section_notifications), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + SwitchPreferenceCompose( + title = stringResource(R.string.title_pref_show_toast_when_auto_changing_ime), + text = stringResource(R.string.summary_pref_show_toast_when_auto_changing_ime), + icon = Icons.Rounded.Notifications, + isChecked = state.showToastWhenAutoChangingIme, + onCheckedChange = onShowToastWhenAutoChangingImeToggled, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + OptionPageButton( + title = stringResource(R.string.title_pref_show_toggle_keyboard_notification), + text = stringResource(R.string.summary_pref_show_toggle_keyboard_notification), + icon = Icons.Rounded.Notifications, + onClick = onShowToggleKeyboardNotificationClick, + ) + } else { + // For older Android versions, this would be a switch but since we're targeting newer versions + // we'll just show the notification settings button + OptionPageButton( + title = stringResource(R.string.title_pref_show_toggle_keyboard_notification), + text = stringResource(R.string.summary_pref_show_toggle_keyboard_notification), + icon = Icons.Rounded.Notifications, + onClick = onShowToggleKeyboardNotificationClick, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + AutomaticChangeImeSettingsScreen(modifier = Modifier.fillMaxSize(), onBackClick = {}) { + Content( + state = AutomaticChangeImeSettingsState(), + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/AutomaticallyChangeImeSettings.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/AutomaticallyChangeImeSettings.kt deleted file mode 100644 index a52487e9f3..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/AutomaticallyChangeImeSettings.kt +++ /dev/null @@ -1,136 +0,0 @@ -package io.github.sds100.keymapper.base.settings - -import android.os.Build -import android.os.Bundle -import android.view.View -import androidx.annotation.RequiresApi -import androidx.preference.Preference -import androidx.preference.SwitchPreferenceCompat -import androidx.preference.isEmpty -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.system.notifications.NotificationController -import io.github.sds100.keymapper.base.utils.ui.viewLifecycleScope -import io.github.sds100.keymapper.data.Keys -import io.github.sds100.keymapper.data.PreferenceDefaults -import io.github.sds100.keymapper.system.notifications.NotificationUtils - -class AutomaticallyChangeImeSettings : BaseSettingsFragment() { - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceManager.preferenceDataStore = viewModel.sharedPrefsDataStoreWrapper - addPreferencesFromResource(R.xml.preferences_empty) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewLifecycleScope.launchWhenResumed { - if (preferenceScreen.isEmpty()) { - populatePreferenceScreen() - } - } - } - - private fun populatePreferenceScreen() = preferenceScreen.apply { - // show on-screen messages when changing keyboards - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.showToastWhenAutoChangingIme.name - - setDefaultValue(PreferenceDefaults.SHOW_TOAST_WHEN_AUTO_CHANGE_IME) - isSingleLineTitle = false - setTitle(R.string.title_pref_show_toast_when_auto_changing_ime) - - addPreference(this) - } - - // automatically change ime on input focus - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.changeImeOnInputFocus.name - - setDefaultValue(PreferenceDefaults.CHANGE_IME_ON_INPUT_FOCUS) - isSingleLineTitle = false - setTitle(R.string.title_pref_auto_change_ime_on_input_focus) - setSummary(R.string.summary_pref_auto_change_ime_on_input_focus) - - addPreference(this) - } - - // automatically change the keyboard when a bluetooth device (dis)connects - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.changeImeOnDeviceConnect.name - setDefaultValue(false) - - isSingleLineTitle = false - setTitle(R.string.title_pref_auto_change_ime_on_connection) - setSummary(R.string.summary_pref_auto_change_ime_on_connection) - - addPreference(this) - } - - addPreference( - SettingsUtils.createChooseDevicesPreference( - requireContext(), - viewModel, - Keys.devicesThatChangeIme, - ), - ) - - // toggle keyboard when toggling key maps - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.toggleKeyboardOnToggleKeymaps.name - setDefaultValue(false) - - isSingleLineTitle = false - setTitle(R.string.title_pref_toggle_keyboard_on_toggle_keymaps) - setSummary(R.string.summary_pref_toggle_keyboard_on_toggle_keymaps) - - addPreference(this) - } - - // toggle keyboard notification - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // show a preference linking to the notification management screen - Preference(requireContext()).apply { - key = Keys.showToggleKeyboardNotification.name - - setTitle(R.string.title_pref_show_toggle_keyboard_notification) - isSingleLineTitle = false - setSummary(R.string.summary_pref_show_toggle_keyboard_notification) - - setOnPreferenceClickListener { - onToggleKeyboardNotificationClick() - - true - } - - addPreference(this) - } - } else { - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.showToggleKeyboardNotification.name - setDefaultValue(true) - - setTitle(R.string.title_pref_show_toggle_keyboard_notification) - isSingleLineTitle = false - setSummary(R.string.summary_pref_show_toggle_keyboard_notification) - - addPreference(this) - } - } - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun onToggleKeyboardNotificationClick() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - !viewModel.isNotificationPermissionGranted() - ) { - viewModel.requestNotificationsPermission() - return - } - - NotificationUtils.openChannelSettings( - ctx = requireContext(), - channelId = NotificationController.CHANNEL_TOGGLE_KEYBOARD, - ) - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/BaseSettingsFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/BaseSettingsFragment.kt deleted file mode 100644 index 8bdfdbcbec..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/BaseSettingsFragment.kt +++ /dev/null @@ -1,60 +0,0 @@ -package io.github.sds100.keymapper.base.settings - -import android.os.Bundle -import android.view.View -import androidx.activity.addCallback -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updatePadding -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.findNavController -import androidx.preference.PreferenceFragmentCompat -import com.google.android.material.bottomappbar.BottomAppBar -import dagger.hilt.android.AndroidEntryPoint -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.str -import io.github.sds100.keymapper.system.url.UrlUtils - -@AndroidEntryPoint -abstract class BaseSettingsFragment : PreferenceFragmentCompat() { - - protected val viewModel: SettingsViewModel by viewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> - val insets = - insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime()) - v.updatePadding(insets.left, insets.top, insets.right, insets.bottom) - WindowInsetsCompat.CONSUMED - } - - requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { - onBackPressed() - } - - view.findViewById(R.id.appBar).apply { - replaceMenu(R.menu.menu_settings) - - setNavigationOnClickListener { - onBackPressed() - } - - setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_help -> { - UrlUtils.openUrl(requireContext(), str(R.string.url_settings_guide)) - true - } - - else -> false - } - } - } - } - - private fun onBackPressed() { - findNavController().navigateUp() - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/ConfigSettingsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/ConfigSettingsUseCase.kt index bc67b9be08..baf703f049 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/ConfigSettingsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/ConfigSettingsUseCase.kt @@ -4,49 +4,60 @@ import androidx.datastore.preferences.core.Preferences import io.github.sds100.keymapper.base.actions.sound.SoundFileInfo 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 import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.utils.InputDeviceInfo import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.then 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.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.devices.DevicesAdapter -import io.github.sds100.keymapper.system.devices.InputDeviceInfo import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter +import io.github.sds100.keymapper.system.notifications.NotificationAdapter import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuUtils +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.map -import javax.inject.Inject class ConfigSettingsUseCaseImpl @Inject constructor( private val preferences: PreferenceRepository, private val permissionAdapter: PermissionAdapter, private val inputMethodAdapter: InputMethodAdapter, + private val switchImeInterface: SwitchImeInterface, private val soundsManager: SoundsManager, private val suAdapter: SuAdapter, private val packageManagerAdapter: PackageManagerAdapter, private val shizukuAdapter: ShizukuAdapter, private val devicesAdapter: DevicesAdapter, private val buildConfigProvider: BuildConfigProvider, + private val notificationAdapter: NotificationAdapter, ) : ConfigSettingsUseCase { private val imeHelper by lazy { KeyMapperImeHelper( + switchImeInterface, inputMethodAdapter, buildConfigProvider.packageName, ) } - override val isRootGranted: Flow = suAdapter.isGranted + override val theme: Flow = + preferences.get(Keys.darkTheme).map { it ?: PreferenceDefaults.DARK_THEME }.map { value -> + Theme.entries.single { it.value == value.toInt() } + } + + override val isRootGranted: Flow = suAdapter.isRootGranted override val isWriteSecureSettingsGranted: Flow = channelFlow { send(permissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS)) @@ -72,9 +83,6 @@ class ConfigSettingsUseCaseImpl @Inject constructor( } } - override val rerouteKeyEvents: Flow = - preferences.get(Keys.rerouteKeyEvents).map { it ?: false } - override val isCompatibleImeChosen: Flow = inputMethodAdapter.chosenIme.map { imeHelper.isCompatibleImeChosen() } @@ -90,16 +98,18 @@ class ConfigSettingsUseCaseImpl @Inject constructor( imeHelper.enableCompatibleInputMethods() } - override suspend fun chooseCompatibleIme(): KMResult = imeHelper.chooseCompatibleInputMethod() + override suspend fun chooseCompatibleIme(): KMResult = + imeHelper.chooseCompatibleInputMethod().then { inputMethodAdapter.getInfoById(it) } - override suspend fun showImePicker(): KMResult<*> = inputMethodAdapter.showImePicker(fromForeground = true) + override suspend fun showImePicker(): KMResult<*> = + inputMethodAdapter.showImePicker(fromForeground = true) override fun getPreference(key: Preferences.Key) = preferences.get(key) override fun setPreference(key: Preferences.Key, value: T?) = preferences.set(key, value) override val automaticBackupLocation = - preferences.get(Keys.automaticBackupLocation).map { it ?: "" } + preferences.get(Keys.automaticBackupLocation) override fun setAutomaticBackupLocation(uri: String) { preferences.set(Keys.automaticBackupLocation, uri) @@ -162,7 +172,12 @@ class ConfigSettingsUseCaseImpl @Inject constructor( permissionAdapter.request(Permission.POST_NOTIFICATIONS) } - override fun isNotificationsPermissionGranted(): Boolean = permissionAdapter.isGranted(Permission.POST_NOTIFICATIONS) + override fun requestRootPermission() { + suAdapter.requestPermission() + } + + override fun isNotificationsPermissionGranted(): Boolean = + permissionAdapter.isGranted(Permission.POST_NOTIFICATIONS) override fun getSoundFiles(): List = soundsManager.soundFiles.value @@ -175,24 +190,30 @@ class ConfigSettingsUseCaseImpl @Inject constructor( override fun resetAllSettings() { preferences.deleteAll() } + + override fun openNotificationChannelSettings(channelId: String) { + notificationAdapter.openChannelSettings(channelId) + } } interface ConfigSettingsUseCase { fun getPreference(key: Preferences.Key): Flow fun setPreference(key: Preferences.Key, value: T?) - val automaticBackupLocation: Flow + + val theme: Flow + val automaticBackupLocation: Flow fun setAutomaticBackupLocation(uri: String) fun disableAutomaticBackup() val isRootGranted: Flow - val isWriteSecureSettingsGranted: Flow + fun requestRootPermission() + val isWriteSecureSettingsGranted: Flow val isShizukuInstalled: Flow val isShizukuStarted: Flow val isShizukuPermissionGranted: Flow fun downloadShizuku() - fun openShizukuApp() - val rerouteKeyEvents: Flow + fun openShizukuApp() val isCompatibleImeChosen: Flow val isCompatibleImeEnabled: Flow suspend fun enableCompatibleIme() @@ -204,17 +225,18 @@ interface ConfigSettingsUseCase { val defaultRepeatDelay: Flow val defaultSequenceTriggerTimeout: Flow val defaultVibrateDuration: Flow - val defaultRepeatRate: Flow + val defaultRepeatRate: Flow fun getSoundFiles(): List fun deleteSoundFiles(uid: List) fun resetDefaultMappingOptions() fun requestWriteSecureSettingsPermission() fun requestNotificationsPermission() fun isNotificationsPermissionGranted(): Boolean + fun openNotificationChannelSettings(channelId: String) + fun requestShizukuPermission() val connectedInputDevices: StateFlow>> - fun resetAllSettings() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/DefaultOptionsSettingsFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/DefaultOptionsSettingsFragment.kt deleted file mode 100644 index 051ba20271..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/DefaultOptionsSettingsFragment.kt +++ /dev/null @@ -1,206 +0,0 @@ -package io.github.sds100.keymapper.base.settings - -import android.os.Bundle -import android.view.View -import androidx.lifecycle.Lifecycle -import androidx.preference.Preference -import androidx.preference.SeekBarPreference -import androidx.preference.isEmpty -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.SliderMaximums -import io.github.sds100.keymapper.base.utils.ui.SliderMinimums -import io.github.sds100.keymapper.base.utils.ui.launchRepeatOnLifecycle -import io.github.sds100.keymapper.base.utils.ui.viewLifecycleScope -import io.github.sds100.keymapper.data.Keys -import io.github.sds100.keymapper.data.PreferenceDefaults -import kotlinx.coroutines.flow.collectLatest - -class DefaultOptionsSettingsFragment : BaseSettingsFragment() { - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceManager.preferenceDataStore = viewModel.sharedPrefsDataStoreWrapper - addPreferencesFromResource(R.xml.preferences_empty) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewLifecycleScope.launchWhenResumed { - if (preferenceScreen.isEmpty()) { - populatePreferenceScreen() - } - } - - // these must all start after the preference screen has been populated so that findPreference works. - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.defaultLongPressDelay.collectLatest { value -> - val preference = findPreference(Keys.defaultLongPressDelay.name) - ?: return@collectLatest - - if (preference.value != value) { - preference.value = value - } - } - } - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.defaultDoublePressDelay.collectLatest { value -> - val preference = - findPreference(Keys.defaultDoublePressDelay.name) - ?: return@collectLatest - - if (preference.value != value) { - preference.value = value - } - } - } - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.defaultSequenceTriggerTimeout.collectLatest { value -> - val preference = - findPreference(Keys.defaultSequenceTriggerTimeout.name) - ?: return@collectLatest - - if (preference.value != value) { - preference.value = value - } - } - } - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.defaultRepeatRate.collectLatest { value -> - val preference = findPreference(Keys.defaultRepeatRate.name) - ?: return@collectLatest - - if (preference.value != value) { - preference.value = value - } - } - } - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.defaultRepeatDelay.collectLatest { value -> - val preference = findPreference(Keys.defaultRepeatDelay.name) - ?: return@collectLatest - - if (preference.value != value) { - preference.value = value - } - } - } - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.defaultVibrateDuration.collectLatest { value -> - val preference = findPreference(Keys.defaultVibrateDuration.name) - ?: return@collectLatest - - if (preference.value != value) { - preference.value = value - } - } - } - } - - private fun populatePreferenceScreen() = preferenceScreen.apply { - // long press delay - SeekBarPreference(requireContext()).apply { - key = Keys.defaultLongPressDelay.name - setDefaultValue(PreferenceDefaults.LONG_PRESS_DELAY) - - setTitle(R.string.title_pref_long_press_delay) - isSingleLineTitle = false - setSummary(R.string.summary_pref_long_press_delay) - min = SliderMinimums.TRIGGER_LONG_PRESS_DELAY - max = 5000 - showSeekBarValue = true - - addPreference(this) - } - - // double press delay - SeekBarPreference(requireContext()).apply { - key = Keys.defaultDoublePressDelay.name - setDefaultValue(PreferenceDefaults.DOUBLE_PRESS_DELAY) - - setTitle(R.string.title_pref_double_press_delay) - isSingleLineTitle = false - setSummary(R.string.summary_pref_double_press_delay) - min = SliderMinimums.TRIGGER_DOUBLE_PRESS_DELAY - max = 5000 - showSeekBarValue = true - - addPreference(this) - } - - // vibration duration - SeekBarPreference(requireContext()).apply { - key = Keys.defaultVibrateDuration.name - setDefaultValue(PreferenceDefaults.VIBRATION_DURATION) - - setTitle(R.string.title_pref_vibration_duration) - isSingleLineTitle = false - setSummary(R.string.summary_pref_vibration_duration) - min = SliderMinimums.VIBRATION_DURATION - max = 1000 - showSeekBarValue = true - - addPreference(this) - } - - // repeat delay - SeekBarPreference(requireContext()).apply { - key = Keys.defaultRepeatDelay.name - setDefaultValue(PreferenceDefaults.REPEAT_DELAY) - - setTitle(R.string.title_pref_repeat_delay) - isSingleLineTitle = false - setSummary(R.string.summary_pref_repeat_delay) - min = SliderMinimums.ACTION_REPEAT_DELAY - max = SliderMaximums.ACTION_REPEAT_DELAY - showSeekBarValue = true - - addPreference(this) - } - - // repeat rate - SeekBarPreference(requireContext()).apply { - key = Keys.defaultRepeatRate.name - setDefaultValue(PreferenceDefaults.REPEAT_RATE) - - setTitle(R.string.title_pref_repeat_rate) - isSingleLineTitle = false - setSummary(R.string.summary_pref_repeat_rate) - min = SliderMinimums.ACTION_REPEAT_RATE - max = SliderMaximums.ACTION_REPEAT_RATE - showSeekBarValue = true - - addPreference(this) - } - - // sequence trigger timeout - SeekBarPreference(requireContext()).apply { - key = Keys.defaultSequenceTriggerTimeout.name - setDefaultValue(PreferenceDefaults.SEQUENCE_TRIGGER_TIMEOUT) - - setTitle(R.string.title_pref_sequence_trigger_timeout) - isSingleLineTitle = false - setSummary(R.string.summary_pref_sequence_trigger_timeout) - min = SliderMinimums.TRIGGER_SEQUENCE_TRIGGER_TIMEOUT - max = 5000 - showSeekBarValue = true - - addPreference(this) - } - - Preference(requireContext()).apply { - setTitle(R.string.title_pref_reset_defaults) - - setOnPreferenceClickListener { - viewModel.resetDefaultMappingOptions() - true - } - - addPreference(this) - } - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/DefaultOptionsSettingsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/DefaultOptionsSettingsScreen.kt new file mode 100644 index 0000000000..2d807b2042 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/DefaultOptionsSettingsScreen.kt @@ -0,0 +1,234 @@ +package io.github.sds100.keymapper.base.settings + +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.utils.ui.SliderMaximums +import io.github.sds100.keymapper.base.utils.ui.SliderMinimums +import io.github.sds100.keymapper.base.utils.ui.SliderStepSizes +import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText + +@Composable +fun DefaultOptionsSettingsScreen(modifier: Modifier = Modifier, viewModel: SettingsViewModel) { + val state by viewModel.defaultSettingsScreenState.collectAsStateWithLifecycle() + + DefaultOptionsSettingsScreen( + modifier, + onBackClick = viewModel::onBackClick, + ) { + Content( + state = state, + callback = viewModel, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DefaultOptionsSettingsScreen( + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + content: @Composable () -> Unit, +) { + Scaffold( + modifier = modifier.displayCutoutPadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_pref_default_options)) }, + ) + }, + bottomBar = { + BottomAppBar { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.action_go_back), + ) + } + } + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + content() + } + } +} + +@Composable +private fun Content( + modifier: Modifier = Modifier, + state: DefaultSettingsState, + callback: DefaultOptionsSettingsCallback = object : DefaultOptionsSettingsCallback {}, +) { + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth(), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + // Long press delay + val longPressDelayMin = SliderMinimums.TRIGGER_LONG_PRESS_DELAY + val longPressDelayMax = SliderMaximums.TRIGGER_LONG_PRESS_DELAY + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.title_pref_long_press_delay), + defaultValue = state.defaultLongPressDelay.toFloat(), + value = state.longPressDelay.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { callback.onLongPressDelayChanged(it.toInt()) }, + valueRange = longPressDelayMin.toFloat()..longPressDelayMax.toFloat(), + stepSize = SliderStepSizes.TRIGGER_LONG_PRESS_DELAY, + ) + Spacer(Modifier.height(8.dp)) + + // Double press delay + val doublePressDelayMin = SliderMinimums.TRIGGER_DOUBLE_PRESS_DELAY + val doublePressDelayMax = SliderMaximums.TRIGGER_DOUBLE_PRESS_DELAY + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.title_pref_double_press_delay), + defaultValue = state.defaultDoublePressDelay.toFloat(), + value = state.doublePressDelay.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { callback.onDoublePressDelayChanged(it.toInt()) }, + valueRange = doublePressDelayMin.toFloat()..doublePressDelayMax.toFloat(), + stepSize = SliderStepSizes.TRIGGER_DOUBLE_PRESS_DELAY, + ) + Spacer(Modifier.height(8.dp)) + + // Vibrate duration + val vibrateDurationMin = SliderMinimums.VIBRATION_DURATION + val vibrateDurationMax = SliderMaximums.VIBRATION_DURATION + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.title_pref_vibration_duration), + defaultValue = state.defaultVibrateDuration.toFloat(), + value = state.vibrateDuration.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { callback.onVibrateDurationChanged(it.toInt()) }, + valueRange = vibrateDurationMin.toFloat()..vibrateDurationMax.toFloat(), + stepSize = SliderStepSizes.VIBRATION_DURATION, + ) + Spacer(Modifier.height(8.dp)) + + // Repeat delay + val repeatDelayMin = SliderMinimums.ACTION_REPEAT_DELAY + val repeatDelayMax = SliderMaximums.ACTION_REPEAT_DELAY + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.title_pref_repeat_delay), + defaultValue = state.defaultRepeatDelay.toFloat(), + value = state.repeatDelay.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { callback.onRepeatDelayChanged(it.toInt()) }, + valueRange = repeatDelayMin.toFloat()..repeatDelayMax.toFloat(), + stepSize = SliderStepSizes.ACTION_REPEAT_DELAY, + ) + Spacer(Modifier.height(8.dp)) + + // Repeat rate + val repeatRateMin = SliderMinimums.ACTION_REPEAT_RATE + val repeatRateMax = SliderMaximums.ACTION_REPEAT_RATE + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.title_pref_repeat_rate), + defaultValue = state.defaultRepeatRate.toFloat(), + value = state.repeatRate.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { callback.onRepeatRateChanged(it.toInt()) }, + valueRange = repeatRateMin.toFloat()..repeatRateMax.toFloat(), + stepSize = SliderStepSizes.ACTION_REPEAT_RATE, + ) + Spacer(Modifier.height(8.dp)) + + // Sequence trigger timeout + val sequenceTriggerTimeoutMin = SliderMinimums.TRIGGER_SEQUENCE_TRIGGER_TIMEOUT + val sequenceTriggerTimeoutMax = SliderMaximums.TRIGGER_SEQUENCE_TRIGGER_TIMEOUT + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.title_pref_sequence_trigger_timeout), + defaultValue = state.defaultSequenceTriggerTimeout.toFloat(), + value = state.sequenceTriggerTimeout.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { callback.onSequenceTriggerTimeoutChanged(it.toInt()) }, + valueRange = sequenceTriggerTimeoutMin.toFloat()..sequenceTriggerTimeoutMax.toFloat(), + stepSize = SliderStepSizes.TRIGGER_SEQUENCE_TRIGGER_TIMEOUT, + ) + Spacer(Modifier.height(8.dp)) + } +} + +interface DefaultOptionsSettingsCallback { + fun onLongPressDelayChanged(delay: Int) = run { } + fun onDoublePressDelayChanged(delay: Int) = run { } + fun onVibrateDurationChanged(duration: Int) = run { } + fun onRepeatDelayChanged(delay: Int) = run { } + fun onRepeatRateChanged(rate: Int) = run { } + fun onSequenceTriggerTimeoutChanged(timeout: Int) = run { } +} + +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + DefaultOptionsSettingsScreen(modifier = Modifier.fillMaxSize(), onBackClick = {}) { + Content( + state = DefaultSettingsState(), + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/ImePickerSettingsFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/ImePickerSettingsFragment.kt deleted file mode 100644 index 84a58b17ba..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/ImePickerSettingsFragment.kt +++ /dev/null @@ -1,100 +0,0 @@ -package io.github.sds100.keymapper.base.settings - -import android.os.Build -import android.os.Bundle -import android.view.View -import androidx.annotation.RequiresApi -import androidx.preference.Preference -import androidx.preference.SwitchPreferenceCompat -import androidx.preference.isEmpty -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.system.notifications.NotificationController -import io.github.sds100.keymapper.base.utils.ui.viewLifecycleScope -import io.github.sds100.keymapper.data.Keys -import io.github.sds100.keymapper.system.notifications.NotificationUtils - -class ImePickerSettingsFragment : BaseSettingsFragment() { - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceManager.preferenceDataStore = viewModel.sharedPrefsDataStoreWrapper - addPreferencesFromResource(R.xml.preferences_empty) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewLifecycleScope.launchWhenResumed { - if (preferenceScreen.isEmpty()) { - populatePreferenceScreen() - } - } - } - - private fun populatePreferenceScreen() = preferenceScreen.apply { - // show keyboard picker notification - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // show a preference linking to the notification management screen - Preference(requireContext()).apply { - key = Keys.showImePickerNotification.name - - setTitle(R.string.title_pref_show_ime_picker_notification) - isSingleLineTitle = false - setSummary(R.string.summary_pref_show_ime_picker_notification) - - setOnPreferenceClickListener { - onImePickerNotificationClick() - - true - } - - addPreference(this) - } - } else { - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.showImePickerNotification.name - setDefaultValue(false) - - setTitle(R.string.title_pref_show_ime_picker_notification) - isSingleLineTitle = false - setSummary(R.string.summary_pref_show_ime_picker_notification) - - addPreference(this) - } - } - - // auto show keyboard picker - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.showImePickerOnDeviceConnect.name - setDefaultValue(false) - - setTitle(R.string.title_pref_auto_show_ime_picker) - isSingleLineTitle = false - setSummary(R.string.summary_pref_auto_show_ime_picker) - - addPreference(this) - } - - addPreference( - SettingsUtils.createChooseDevicesPreference( - requireContext(), - viewModel, - Keys.devicesThatShowImePicker, - ), - ) - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun onImePickerNotificationClick() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - !viewModel.isNotificationPermissionGranted() - ) { - viewModel.requestNotificationsPermission() - return - } - - NotificationUtils.openChannelSettings( - requireContext(), - NotificationController.CHANNEL_IME_PICKER, - ) - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/MainSettingsFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/MainSettingsFragment.kt deleted file mode 100644 index 072e8cedb7..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/MainSettingsFragment.kt +++ /dev/null @@ -1,543 +0,0 @@ -package io.github.sds100.keymapper.base.settings - -import android.annotation.SuppressLint -import android.content.ActivityNotFoundException -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts.CreateDocument -import androidx.annotation.RequiresApi -import androidx.lifecycle.Lifecycle -import androidx.navigation.fragment.findNavController -import androidx.preference.DropDownPreference -import androidx.preference.Preference -import androidx.preference.PreferenceCategory -import androidx.preference.SwitchPreferenceCompat -import androidx.preference.isEmpty -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.backup.BackupUtils -import io.github.sds100.keymapper.base.system.notifications.NotificationController -import io.github.sds100.keymapper.base.utils.ui.launchRepeatOnLifecycle -import io.github.sds100.keymapper.base.utils.ui.str -import io.github.sds100.keymapper.base.utils.ui.strArray -import io.github.sds100.keymapper.base.utils.ui.viewLifecycleScope -import io.github.sds100.keymapper.common.utils.firstBlocking -import io.github.sds100.keymapper.data.Keys -import io.github.sds100.keymapper.data.PreferenceDefaults -import io.github.sds100.keymapper.system.files.FileUtils -import io.github.sds100.keymapper.system.notifications.NotificationUtils -import io.github.sds100.keymapper.system.shizuku.ShizukuUtils -import kotlinx.coroutines.flow.collectLatest -import splitties.alertdialog.appcompat.alertDialog -import splitties.alertdialog.appcompat.messageResource -import splitties.alertdialog.appcompat.negativeButton -import splitties.alertdialog.appcompat.positiveButton - -class MainSettingsFragment : BaseSettingsFragment() { - - companion object { - private const val KEY_GRANT_WRITE_SECURE_SETTINGS = "pref_key_grant_write_secure_settings" - private const val CATEGORY_KEY_GRANT_WRITE_SECURE_SETTINGS = - "category_key_grant_write_secure_settings" - private const val KEY_GRANT_SHIZUKU = "pref_key_grant_shizuku" - private const val KEY_AUTOMATICALLY_CHANGE_IME_LINK = - "pref_automatically_change_ime_link" - } - - private val chooseAutomaticBackupLocationLauncher = - registerForActivityResult(CreateDocument(FileUtils.MIME_TYPE_ZIP)) { - it ?: return@registerForActivityResult - - viewModel.setAutomaticBackupLocation(it.toString()) - - val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - - requireContext().contentResolver.takePersistableUriPermission(it, takeFlags) - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceManager.preferenceDataStore = viewModel.sharedPrefsDataStoreWrapper - addPreferencesFromResource(R.xml.preferences_empty) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewLifecycleScope.launchWhenResumed { - if (preferenceScreen.isEmpty()) { - populatePreferenceScreen() - } - } - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.automaticBackupLocation.collectLatest { backupLocation -> - val preference = - findPreference(Keys.automaticBackupLocation.name) - ?: return@collectLatest - preference.summary = if (backupLocation.isBlank()) { - str(R.string.summary_pref_automatic_backup_location_disabled) - } else { - backupLocation - } - } - } - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.isWriteSecureSettingsPermissionGranted.collectLatest { isGranted -> - val writeSecureSettingsCategory = - findPreference(CATEGORY_KEY_GRANT_WRITE_SECURE_SETTINGS) - - findPreference(KEY_GRANT_WRITE_SECURE_SETTINGS)?.apply { - isEnabled = !isGranted - - if (isGranted) { - setTitle(R.string.title_pref_grant_write_secure_settings_granted) - setIcon(R.drawable.ic_outline_check_circle_outline_24) - } else { - setTitle(R.string.title_pref_grant_write_secure_settings_not_granted) - setIcon(R.drawable.ic_baseline_error_outline_24) - } - } - - writeSecureSettingsCategory - ?.findPreference(KEY_AUTOMATICALLY_CHANGE_IME_LINK)?.apply { - isEnabled = isGranted - } - } - } - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.isShizukuPermissionGranted.collectLatest { isGranted -> - findPreference(KEY_GRANT_SHIZUKU)?.apply { - if (isGranted) { - setTitle(R.string.title_pref_grant_shizuku_granted) - setIcon(R.drawable.ic_outline_check_circle_outline_24) - } else { - setTitle(R.string.title_pref_grant_shizuku_not_granted) - setIcon(R.drawable.ic_baseline_error_outline_24) - } - } - } - } - } - - private fun populatePreferenceScreen() = preferenceScreen.apply { - // dark theme - DropDownPreference(requireContext()).apply { - key = Keys.darkTheme.name - setDefaultValue(PreferenceDefaults.DARK_THEME) - isSingleLineTitle = false - - setTitle(R.string.title_pref_dark_theme) - setSummary(R.string.summary_pref_dark_theme) - entries = strArray(R.array.pref_dark_theme_entries) - entryValues = ThemeUtils.THEMES.map { it.toString() }.toTypedArray() - - addPreference(this) - } - - // automatic backup location - Preference(requireContext()).apply { - key = Keys.automaticBackupLocation.name - setDefaultValue("") - - setTitle(R.string.title_pref_automatic_backup_location) - isSingleLineTitle = false - - setOnPreferenceClickListener { - val backupLocation = viewModel.automaticBackupLocation.firstBlocking() - - if (backupLocation.isBlank()) { - try { - chooseAutomaticBackupLocationLauncher.launch(BackupUtils.DEFAULT_AUTOMATIC_BACKUP_NAME) - } catch (e: ActivityNotFoundException) { - viewModel.onCreateBackupFileActivityNotFound() - } - } else { - requireContext().alertDialog { - messageResource = R.string.dialog_message_change_location_or_disable - - positiveButton(R.string.pos_change_location) { - chooseAutomaticBackupLocationLauncher.launch(BackupUtils.DEFAULT_AUTOMATIC_BACKUP_NAME) - } - - negativeButton(R.string.neg_turn_off) { - viewModel.disableAutomaticBackup() - } - - show() - } - } - - true - } - addPreference(this) - } - - // hide home screen alerts - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.hideHomeScreenAlerts.name - setDefaultValue(false) - - setTitle(R.string.title_pref_hide_home_screen_alerts) - isSingleLineTitle = false - setSummary(R.string.summary_pref_hide_home_screen_alerts) - - addPreference(this) - } - - // force vibrate - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.forceVibrate.name - setDefaultValue(false) - - setTitle(R.string.title_pref_force_vibrate) - isSingleLineTitle = false - setSummary(R.string.summary_pref_force_vibrate) - - addPreference(this) - } - - // show device descriptors - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.showDeviceDescriptors.name - setDefaultValue(false) - isSingleLineTitle = false - - setTitle(R.string.title_pref_show_device_descriptors) - setSummary(R.string.summary_pref_show_device_descriptors) - - addPreference(this) - } - - // toggle key maps notification - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - // show a preference linking to the notification management screen - Preference(requireContext()).apply { - key = Keys.showToggleKeyMapsNotification.name - - setTitle(R.string.title_pref_show_toggle_keymaps_notification) - isSingleLineTitle = false - setSummary(R.string.summary_pref_show_toggle_keymaps_notification) - - setOnPreferenceClickListener { - onToggleKeyMapsNotificationClick() - - true - } - - addPreference(this) - } - } else { - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.showToggleKeyMapsNotification.name - setDefaultValue(true) - - setTitle(R.string.title_pref_show_toggle_keymaps_notification) - isSingleLineTitle = false - setSummary(R.string.summary_pref_show_toggle_keymaps_notification) - - addPreference(this) - } - } - - // default options - Preference(requireContext()).apply { - setTitle(R.string.title_pref_default_options) - setSummary(R.string.summary_pref_default_options) - isSingleLineTitle = false - - setOnPreferenceClickListener { - val direction = MainSettingsFragmentDirections.toDefaultOptionsSettingsFragment() - findNavController().navigate(direction) - - true - } - - addPreference(this) - } - - // apps can't show the keyboard picker when in the background from Android 8.1+ - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) { - Preference(requireContext()).apply { - setTitle(R.string.title_pref_category_ime_picker) - setSummary(R.string.summary_pref_category_ime_picker) - isSingleLineTitle = false - - setOnPreferenceClickListener { - val direction = MainSettingsFragmentDirections.toImePickerSettingsFragment() - findNavController().navigate(direction) - - true - } - - addPreference(this) - } - } - - // delete sound files - Preference(requireContext()).apply { - setTitle(R.string.title_pref_delete_sound_files) - setSummary(R.string.summary_pref_delete_sound_files) - isSingleLineTitle = false - - setOnPreferenceClickListener { - viewModel.onDeleteSoundFilesClick() - - true - } - - addPreference(this) - } - - // link to settings to automatically change the ime - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - addPreference(automaticallyChangeImeSettingsLink()) - } - - // android 11 device id reset work around - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { - Preference(requireContext()).apply { - setTitle(R.string.title_pref_reroute_keyevents_link) - setSummary(R.string.summary_pref_reroute_keyevents_link) - isSingleLineTitle = false - - setOnPreferenceClickListener { - val direction = - MainSettingsFragmentDirections.toAndroid11BugWorkaroundSettingsFragment() - findNavController().navigate(direction) - - true - } - - addPreference(this) - } - } - - // Shizuku - // shizuku is only supported on Marhsmallow+ - if (ShizukuUtils.isSupportedForSdkVersion()) { - Preference(requireContext()).apply { - setTitle(R.string.title_pref_category_shizuku) - setSummary(R.string.summary_pref_category_shizuku) - isSingleLineTitle = false - - setOnPreferenceClickListener { - val direction = - MainSettingsFragmentDirections.toShizukuSettingsFragment() - findNavController().navigate(direction) - - true - } - - addPreference(this) - } - } - - Preference(requireContext()).apply { - setTitle(R.string.title_pref_reset_settings) - setSummary(R.string.summary_pref_reset_settings) - - setOnPreferenceClickListener { - viewModel.onResetAllSettingsClick() - true - } - - addPreference(this) - } - - // write secure settings - PreferenceCategory(requireContext()).apply { - key = CATEGORY_KEY_GRANT_WRITE_SECURE_SETTINGS - setTitle(R.string.title_pref_category_write_secure_settings) - - preferenceScreen.addPreference(this) - - Preference(requireContext()).apply { - isSelectable = false - setSummary(R.string.summary_pref_category_write_secure_settings) - - addPreference(this) - } - - Preference(requireContext()).apply { - key = KEY_GRANT_WRITE_SECURE_SETTINGS - - if (viewModel.isWriteSecureSettingsPermissionGranted.firstBlocking()) { - setTitle(R.string.title_pref_grant_write_secure_settings_granted) - setIcon(R.drawable.ic_outline_check_circle_outline_24) - } else { - setTitle(R.string.title_pref_grant_write_secure_settings_not_granted) - setIcon(R.drawable.ic_baseline_error_outline_24) - } - - setOnPreferenceClickListener { - viewModel.requestWriteSecureSettingsPermission() - true - } - - addPreference(this) - } - - // accessibility services can change the ime on Android 11+ - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - addPreference(automaticallyChangeImeSettingsLink()) - } - } - - // root - createRootCategory() - - // log - createLogCategory() - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun onToggleKeyMapsNotificationClick() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - !viewModel.isNotificationPermissionGranted() - ) { - viewModel.requestNotificationsPermission() - return - } - - NotificationUtils.openChannelSettings( - requireContext(), - NotificationController.CHANNEL_TOGGLE_KEYMAPS, - ) - } - - private fun automaticallyChangeImeSettingsLink() = Preference(requireContext()).apply { - key = KEY_AUTOMATICALLY_CHANGE_IME_LINK - - setTitle(R.string.title_pref_automatically_change_ime) - setSummary(R.string.summary_pref_automatically_change_ime) - isSingleLineTitle = false - - setOnPreferenceClickListener { - val direction = MainSettingsFragmentDirections.toAutomaticallyChangeImeSettings() - findNavController().navigate(direction) - - true - } - } - - private fun createLogCategory() = PreferenceCategory(requireContext()).apply { - setTitle(R.string.title_pref_category_log) - preferenceScreen.addPreference(this) - - Preference(requireContext()).apply { - isSelectable = false - setSummary(R.string.summary_pref_category_log) - - addPreference(this) - } - - // enable logging - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.log.name - setDefaultValue(false) - - isSingleLineTitle = false - setTitle(R.string.title_pref_toggle_logging) - - addPreference(this) - } - - // open log fragment - Preference(requireContext()).apply { - isSingleLineTitle = false - setTitle(R.string.title_pref_view_and_share_log) - - setOnPreferenceClickListener { - findNavController().navigate(MainSettingsFragmentDirections.toLogFragment()) - - true - } - - addPreference(this) - } - - // report issue to developer -// Preference(requireContext()).apply { -// isSingleLineTitle = false -// setTitle(R.string.title_pref_report_issue) -// -// setOnPreferenceClickListener { -// -// true -// } -// -// addPreference(this) -// } - } - - @SuppressLint("NewApi") - private fun createRootCategory() = PreferenceCategory(requireContext()).apply { - setTitle(R.string.title_pref_category_root) - preferenceScreen.addPreference(this) - - Preference(requireContext()).apply { - isSelectable = false - setSummary(R.string.summary_pref_category_root) - - addPreference(this) - } - - // root permission switch - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.hasRootPermission.name - setDefaultValue(false) - - isSingleLineTitle = false - setTitle(R.string.title_pref_root_permission) - setSummary(R.string.summary_pref_root_permission) - - addPreference(this) - } - - // only show the options to show the keyboard picker when rooted in these versions - if (Build.VERSION.SDK_INT in Build.VERSION_CODES.O_MR1..Build.VERSION_CODES.P) { - // show a preference linking to the notification management screen - Preference(requireContext()).apply { - key = Keys.showImePickerNotification.name - - setTitle(R.string.title_pref_show_ime_picker_notification) - isSingleLineTitle = false - setSummary(R.string.summary_pref_show_ime_picker_notification) - - setOnPreferenceClickListener { - NotificationUtils.openChannelSettings( - requireContext(), - NotificationController.CHANNEL_IME_PICKER, - ) - - true - } - - addPreference(this) - } - - SwitchPreferenceCompat(requireContext()).apply { - key = Keys.showImePickerOnDeviceConnect.name - setDefaultValue(false) - - setTitle(R.string.title_pref_auto_show_ime_picker) - isSingleLineTitle = false - setSummary(R.string.summary_pref_auto_show_ime_picker) - - addPreference(this) - } - - addPreference( - SettingsUtils.createChooseDevicesPreference( - requireContext(), - viewModel, - Keys.devicesThatShowImePicker, - ), - ) - } - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt new file mode 100644 index 0000000000..e9283a37e2 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsScreen.kt @@ -0,0 +1,478 @@ +package io.github.sds100.keymapper.base.settings + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.CreateDocument +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +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.rememberScrollState +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.Android +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.FindInPage +import androidx.compose.material.icons.outlined.Gamepad +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.Construction +import androidx.compose.material.icons.rounded.Devices +import androidx.compose.material.icons.rounded.Keyboard +import androidx.compose.material.icons.rounded.PlayCircleOutline +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material.icons.rounded.Vibration +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.backup.BackupUtils +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow +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.RadioButtonText +import io.github.sds100.keymapper.base.utils.ui.compose.SwitchPreferenceCompose +import io.github.sds100.keymapper.base.utils.ui.compose.icons.FolderManaged +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.utils.ui.compose.icons.ProModeIcon +import io.github.sds100.keymapper.base.utils.ui.compose.icons.WandStars +import io.github.sds100.keymapper.common.utils.BuildUtils +import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.system.files.FileUtils +import kotlinx.coroutines.launch + +private val isProModeSupported = Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API +private val isAutoSwitchImeSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + +@Composable +fun SettingsScreen(modifier: Modifier = Modifier, viewModel: SettingsViewModel) { + val state by viewModel.mainScreenState.collectAsStateWithLifecycle() + val snackbarHostState = SnackbarHostState() + var showAutomaticBackupDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val automaticBackupLocationChooser = + rememberLauncherForActivityResult(CreateDocument(FileUtils.MIME_TYPE_ZIP)) { uri -> + uri ?: return@rememberLauncherForActivityResult + viewModel.setAutomaticBackupLocation(uri.toString()) + + val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + context.contentResolver.takePersistableUriPermission(uri, takeFlags) + } + + if (showAutomaticBackupDialog) { + val activityNotFoundText = + stringResource(R.string.dialog_message_no_app_found_to_create_file) + + AlertDialog( + onDismissRequest = { showAutomaticBackupDialog = false }, + title = { Text(stringResource(R.string.dialog_title_change_location_or_disable)) }, + text = { Text(stringResource(R.string.dialog_message_change_location_or_disable)) }, + confirmButton = { + TextButton( + onClick = { + showAutomaticBackupDialog = false + + try { + automaticBackupLocationChooser.launch( + BackupUtils.DEFAULT_AUTOMATIC_BACKUP_NAME, + ) + } catch (e: ActivityNotFoundException) { + scope.launch { + snackbarHostState.showSnackbar(activityNotFoundText) + } + } + }, + ) { + Text(stringResource(R.string.pos_change_location)) + } + }, + dismissButton = { + TextButton( + onClick = { + showAutomaticBackupDialog = false + viewModel.disableAutomaticBackup() + }, + ) { + Text(stringResource(R.string.neg_turn_off)) + } + }, + ) + } + + SettingsScreen( + modifier, + onBackClick = viewModel::onBackClick, + viewModel::onResetAllSettingsClick, + snackbarHostState = snackbarHostState, + ) { + Content( + state = state, + onThemeSelected = viewModel::onThemeSelected, + onPauseResumeNotificationClick = viewModel::onPauseResumeNotificationClick, + onDefaultOptionsClick = viewModel::onDefaultOptionsClick, + onProModeClick = { + if (isProModeSupported) { + viewModel.onProModeClick() + } else { + scope.launch { + snackbarHostState.showSnackbar( + context.getString( + R.string.error_sdk_version_too_low, + BuildUtils.getSdkVersionName(Constants.SYSTEM_BRIDGE_MIN_API), + ), + ) + } + } + }, + onAutomaticChangeImeClick = viewModel::onAutomaticChangeImeClick, + onForceVibrateToggled = viewModel::onForceVibrateToggled, + onLoggingToggled = viewModel::onLoggingToggled, + onViewLogClick = viewModel::onViewLogClick, + onHideHomeScreenAlertsToggled = viewModel::onHideHomeScreenAlertsToggled, + onShowDeviceDescriptorsToggled = viewModel::onShowDeviceDescriptorsToggled, + onAutomaticBackupClick = { + if (state.autoBackupLocation.isNullOrBlank()) { + automaticBackupLocationChooser.launch(BackupUtils.DEFAULT_AUTOMATIC_BACKUP_NAME) + } else { + showAutomaticBackupDialog = true + } + }, + onShareLogcatClick = viewModel::onShareLogcatClick, + onKeyEventActionMethodSelected = viewModel::onKeyEventActionMethodSelected, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SettingsScreen( + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + onResetClick: () -> Unit = {}, + snackbarHostState: SnackbarHostState = SnackbarHostState(), + content: @Composable () -> Unit, +) { + Scaffold( + modifier = modifier.displayCutoutPadding(), + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.action_settings)) }, + actions = { + OutlinedButton( + modifier = Modifier.padding(horizontal = 16.dp), + onClick = onResetClick, + ) { + Text(stringResource(R.string.settings_reset_app_bar_button)) + } + }, + ) + }, + bottomBar = { + BottomAppBar { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.action_go_back), + ) + } + } + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + content() + } + } +} + +@Composable +private fun Content( + modifier: Modifier = Modifier, + state: MainSettingsState, + onThemeSelected: (Theme) -> Unit = { }, + onPauseResumeNotificationClick: () -> Unit = { }, + onDefaultOptionsClick: () -> Unit = { }, + onAutomaticBackupClick: () -> Unit = { }, + onProModeClick: () -> Unit = { }, + onAutomaticChangeImeClick: () -> Unit = { }, + onForceVibrateToggled: (Boolean) -> Unit = { }, + onLoggingToggled: (Boolean) -> Unit = { }, + onViewLogClick: () -> Unit = { }, + onShareLogcatClick: () -> Unit = { }, + onHideHomeScreenAlertsToggled: (Boolean) -> Unit = { }, + onShowDeviceDescriptorsToggled: (Boolean) -> Unit = { }, + onKeyEventActionMethodSelected: (isProModeSelected: Boolean) -> Unit = {}, +) { + Column( + modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Spacer(modifier = Modifier.height(8.dp)) + + OptionsHeaderRow( + modifier = Modifier.fillMaxWidth(), + icon = KeyMapperIcons.WandStars, + text = stringResource(R.string.settings_section_customize_experience_title), + ) + + Text( + text = stringResource(R.string.title_pref_dark_theme), + style = MaterialTheme.typography.bodyLarge, + ) + + val buttonStates: List> = listOf( + Theme.AUTO to stringResource(R.string.theme_system), + Theme.LIGHT to stringResource(R.string.theme_light), + Theme.DARK to stringResource(R.string.theme_dark), + ) + + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + buttonStates, + state.theme, + onStateSelected = onThemeSelected, + ) + + OptionPageButton( + title = stringResource(R.string.title_pref_show_toggle_keymaps_notification), + text = stringResource(R.string.summary_pref_show_toggle_keymaps_notification), + icon = Icons.Rounded.PlayCircleOutline, + onClick = onPauseResumeNotificationClick, + ) + + SwitchPreferenceCompose( + title = stringResource(R.string.title_pref_hide_home_screen_alerts), + text = stringResource(R.string.summary_pref_hide_home_screen_alerts), + icon = Icons.Rounded.VisibilityOff, + isChecked = state.hideHomeScreenAlerts, + onCheckedChange = onHideHomeScreenAlertsToggled, + ) + + OptionsHeaderRow( + modifier = Modifier.fillMaxWidth(), + icon = Icons.Outlined.Gamepad, + text = stringResource(R.string.settings_section_key_maps_title), + ) + + OptionPageButton( + title = stringResource(R.string.title_pref_default_options), + text = stringResource(R.string.summary_pref_default_options), + icon = Icons.Rounded.Tune, + onClick = onDefaultOptionsClick, + ) + + SwitchPreferenceCompose( + title = stringResource(R.string.title_pref_force_vibrate), + text = stringResource(R.string.summary_pref_force_vibrate), + icon = Icons.Rounded.Vibration, + isChecked = state.forceVibrate, + onCheckedChange = onForceVibrateToggled, + ) + + SwitchPreferenceCompose( + title = stringResource(R.string.title_pref_show_device_descriptors), + text = stringResource(R.string.summary_pref_show_device_descriptors), + icon = Icons.Rounded.Devices, + isChecked = state.showDeviceDescriptors, + onCheckedChange = onShowDeviceDescriptorsToggled, + ) + + OptionsHeaderRow( + modifier = Modifier.fillMaxWidth(), + icon = KeyMapperIcons.FolderManaged, + text = stringResource(R.string.settings_section_data_management_title), + ) + + OptionPageButton( + title = if (state.autoBackupLocation == null) { + stringResource(R.string.title_pref_automatic_backup_location_disabled) + } else { + stringResource(R.string.title_pref_automatic_backup_location_enabled) + }, + text = state.autoBackupLocation + ?: stringResource(R.string.summary_pref_automatic_backup_location_disabled), + icon = Icons.Rounded.Tune, + onClick = onAutomaticBackupClick, + ) + + OptionsHeaderRow( + modifier = Modifier.fillMaxWidth(), + icon = Icons.Rounded.Construction, + text = stringResource(R.string.settings_section_power_user_title), + ) + + KeyEventActionMethodRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + isProModeSelected = state.keyEventActionsUseSystemBridege, + onSelected = onKeyEventActionMethodSelected, + ) + + OptionPageButton( + title = stringResource(R.string.title_pref_pro_mode), + text = if (isProModeSupported) { + stringResource(R.string.summary_pref_pro_mode) + } else { + stringResource( + R.string.error_sdk_version_too_low, + BuildUtils.getSdkVersionName(Constants.SYSTEM_BRIDGE_MIN_API), + ) + }, + icon = KeyMapperIcons.ProModeIcon, + onClick = onProModeClick, + enabled = isProModeSupported, + ) + + OptionPageButton( + title = stringResource(R.string.title_pref_automatically_change_ime), + text = if (isAutoSwitchImeSupported) { + stringResource(R.string.summary_pref_automatically_change_ime) + } else { + stringResource( + R.string.error_sdk_version_too_low, + BuildUtils.getSdkVersionName(Build.VERSION_CODES.R), + ) + }, + icon = Icons.Rounded.Keyboard, + onClick = onAutomaticChangeImeClick, + enabled = isAutoSwitchImeSupported, + ) + + OptionsHeaderRow( + modifier = Modifier.fillMaxWidth(), + icon = Icons.Rounded.Code, + text = stringResource(R.string.settings_section_debugging_title), + ) + + SwitchPreferenceCompose( + title = stringResource(R.string.title_pref_toggle_logging), + text = stringResource(R.string.summary_pref_toggle_logging), + icon = Icons.Outlined.BugReport, + isChecked = state.loggingEnabled, + onCheckedChange = onLoggingToggled, + ) + + OptionPageButton( + title = stringResource(R.string.title_pref_view_and_share_log), + text = stringResource(R.string.summary_pref_view_and_share_log), + icon = Icons.Outlined.FindInPage, + onClick = onViewLogClick, + ) + + OptionPageButton( + title = stringResource(R.string.title_pref_share_logcat), + text = stringResource(R.string.summary_pref_share_logcat), + icon = Icons.Outlined.Android, + onClick = onShareLogcatClick, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Composable +private fun KeyEventActionMethodRow( + modifier: Modifier = Modifier, + isProModeSelected: Boolean, + onSelected: (isProModeSelected: Boolean) -> Unit, +) { + Column(modifier) { + val buttonStates = listOf( + false to stringResource(R.string.fix_key_event_action_input_method_title), + true to stringResource(R.string.pro_mode_app_bar_title), + ) + + Text( + text = stringResource(R.string.title_pref_key_event_actions_use_system_bridge), + style = MaterialTheme.typography.bodyLarge, + ) + + Text( + text = stringResource(R.string.summary_pref_key_event_actions_use_system_bridge), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + for ((isProMode, text) in buttonStates) { + RadioButtonText( + text = text, + isSelected = isProMode == isProModeSelected, + onSelected = { onSelected(isProMode) }, + ) + } + } + } +} + +@Preview(heightDp = 1500) +@Composable +private fun Preview() { + KeyMapperTheme { + SettingsScreen(modifier = Modifier.fillMaxSize(), onBackClick = {}) { + Content( + state = MainSettingsState(), + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsUtils.kt deleted file mode 100644 index d1728bdb56..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/SettingsUtils.kt +++ /dev/null @@ -1,28 +0,0 @@ -package io.github.sds100.keymapper.base.settings - -import android.content.Context -import androidx.annotation.StringRes -import androidx.datastore.preferences.core.Preferences -import androidx.preference.Preference -import io.github.sds100.keymapper.base.R - -object SettingsUtils { - - fun createChooseDevicesPreference( - ctx: Context, - settingsViewModel: SettingsViewModel, - key: Preferences.Key>, - @StringRes title: Int = R.string.title_pref_choose_devices, - ): Preference = Preference(ctx).apply { - this.key = key.name - - setTitle(title) - isSingleLineTitle = false - - setOnPreferenceClickListener { - settingsViewModel.chooseDevicesForPreference(key) - - true - } - } -} 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 34d20d304a..201fe308be 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 @@ -1,11 +1,17 @@ package io.github.sds100.keymapper.base.settings +import android.os.Build import androidx.datastore.preferences.core.Preferences import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.logging.ShareLogcatUseCase +import io.github.sds100.keymapper.base.system.notifications.NotificationController import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.navigation.NavDestination +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.DialogResponse @@ -14,53 +20,29 @@ import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.onFailure -import io.github.sds100.keymapper.common.utils.onSuccess -import io.github.sds100.keymapper.common.utils.otherwise -import io.github.sds100.keymapper.data.utils.SharedPrefsDataStoreWrapper +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.PreferenceDefaults +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( private val useCase: ConfigSettingsUseCase, private val resourceProvider: ResourceProvider, + private val shareLogcatUseCase: ShareLogcatUseCase, dialogProvider: DialogProvider, - val sharedPrefsDataStoreWrapper: SharedPrefsDataStoreWrapper, + navigationProvider: NavigationProvider, ) : ViewModel(), DialogProvider by dialogProvider, - ResourceProvider by resourceProvider { - - val automaticBackupLocation = useCase.automaticBackupLocation - - val isWriteSecureSettingsPermissionGranted: StateFlow = - useCase.isWriteSecureSettingsGranted - .stateIn(viewModelScope, SharingStarted.Eagerly, true) - - val isShizukuInstalled: StateFlow = - useCase.isShizukuInstalled - .stateIn(viewModelScope, SharingStarted.Eagerly, true) - - val isShizukuStarted: StateFlow = - useCase.isShizukuStarted - .stateIn(viewModelScope, SharingStarted.Eagerly, true) - - val isShizukuPermissionGranted: StateFlow = - useCase.isShizukuPermissionGranted - .stateIn(viewModelScope, SharingStarted.Eagerly, true) - - val rerouteKeyEvents: StateFlow = useCase.rerouteKeyEvents - .stateIn(viewModelScope, SharingStarted.Lazily, false) - - val isCompatibleImeChosen: StateFlow = useCase.isCompatibleImeChosen - .stateIn(viewModelScope, SharingStarted.Lazily, false) - - val isCompatibleImeEnabled: StateFlow = useCase.isCompatibleImeEnabled - .stateIn(viewModelScope, SharingStarted.Lazily, false) + ResourceProvider by resourceProvider, + NavigationProvider by navigationProvider, + DefaultOptionsSettingsCallback { val defaultLongPressDelay: Flow = useCase.defaultLongPressDelay val defaultDoublePressDelay: Flow = useCase.defaultDoublePressDelay @@ -69,144 +51,321 @@ class SettingsViewModel @Inject constructor( val defaultVibrateDuration: Flow = useCase.defaultVibrateDuration val defaultRepeatRate: Flow = useCase.defaultRepeatRate + val mainScreenState: StateFlow = combine( + useCase.theme, + useCase.getPreference(Keys.log), + useCase.automaticBackupLocation, + useCase.getPreference(Keys.forceVibrate), + useCase.getPreference(Keys.hideHomeScreenAlerts), + useCase.getPreference(Keys.showDeviceDescriptors), + useCase.getPreference(Keys.keyEventActionsUseSystemBridge), + ) { values -> + MainSettingsState( + theme = values[0] as Theme, + loggingEnabled = values[1] as Boolean? ?: false, + autoBackupLocation = values[2] as String?, + forceVibrate = values[3] as Boolean? ?: false, + hideHomeScreenAlerts = values[4] as Boolean? ?: false, + showDeviceDescriptors = values[5] as Boolean? ?: false, + keyEventActionsUseSystemBridege = values[6] as Boolean? ?: false, + ) + }.stateIn(viewModelScope, SharingStarted.Lazily, MainSettingsState()) + + val defaultSettingsScreenState: StateFlow = combine( + useCase.getPreference(Keys.defaultLongPressDelay), + useCase.getPreference(Keys.defaultDoublePressDelay), + useCase.getPreference(Keys.defaultVibrateDuration), + useCase.getPreference(Keys.defaultRepeatDelay), + useCase.getPreference(Keys.defaultRepeatRate), + useCase.getPreference(Keys.defaultSequenceTriggerTimeout), + ) { values -> + DefaultSettingsState( + longPressDelay = values[0] ?: PreferenceDefaults.LONG_PRESS_DELAY, + defaultLongPressDelay = PreferenceDefaults.LONG_PRESS_DELAY, + doublePressDelay = values[1] ?: PreferenceDefaults.DOUBLE_PRESS_DELAY, + defaultDoublePressDelay = PreferenceDefaults.DOUBLE_PRESS_DELAY, + vibrateDuration = values[2] ?: PreferenceDefaults.VIBRATION_DURATION, + defaultVibrateDuration = PreferenceDefaults.VIBRATION_DURATION, + repeatDelay = values[3] ?: PreferenceDefaults.REPEAT_DELAY, + defaultRepeatDelay = PreferenceDefaults.REPEAT_DELAY, + repeatRate = values[4] ?: PreferenceDefaults.REPEAT_RATE, + defaultRepeatRate = PreferenceDefaults.REPEAT_RATE, + sequenceTriggerTimeout = values[5] ?: PreferenceDefaults.SEQUENCE_TRIGGER_TIMEOUT, + defaultSequenceTriggerTimeout = PreferenceDefaults.SEQUENCE_TRIGGER_TIMEOUT, + ) + }.stateIn(viewModelScope, SharingStarted.Lazily, DefaultSettingsState()) + + val automaticChangeImeSettingsState: StateFlow = combine( + useCase.getPreference(Keys.showToastWhenAutoChangingIme), + useCase.getPreference(Keys.changeImeOnInputFocus), + useCase.getPreference(Keys.changeImeOnDeviceConnect), + useCase.getPreference(Keys.toggleKeyboardOnToggleKeymaps), + ) { values -> + AutomaticChangeImeSettingsState( + showToastWhenAutoChangingIme = values[0] + ?: PreferenceDefaults.SHOW_TOAST_WHEN_AUTO_CHANGE_IME, + changeImeOnInputFocus = values[1] ?: PreferenceDefaults.CHANGE_IME_ON_INPUT_FOCUS, + changeImeOnDeviceConnect = values[2] ?: false, + toggleKeyboardOnToggleKeymaps = values[3] ?: false, + ) + }.stateIn(viewModelScope, SharingStarted.Lazily, AutomaticChangeImeSettingsState()) + fun setAutomaticBackupLocation(uri: String) = useCase.setAutomaticBackupLocation(uri) + fun disableAutomaticBackup() = useCase.disableAutomaticBackup() - fun onChooseCompatibleImeClick() { - viewModelScope.launch { - useCase - .chooseCompatibleIme() - .onSuccess { ime -> - val snackBar = - DialogModel.SnackBar( - message = getString( - R.string.toast_chose_keyboard, - ime.label, - ), + fun chooseDevicesForPreference(prefKey: Preferences.Key>) { + viewModelScope.launch { + val externalDevices = useCase.connectedInputDevices + .first { it is State.Data<*> } + .let { (it as State.Data).data } + .filter { it.isExternal } + + if (externalDevices.isEmpty()) { + val dialog = DialogModel.Alert( + message = getString( + R.string.dialog_message_settings_no_external_devices_connected, + ), + positiveButtonText = getString(R.string.pos_ok), + ) + + showDialog("no_external_devices", dialog) + } else { + val checkedDevices = useCase.getPreference(prefKey).first() ?: emptySet() + + val dialog = DialogModel.MultiChoice( + items = externalDevices.map { device -> + MultiChoiceItem( + id = device.descriptor, + label = device.name, + isChecked = checkedDevices.contains(device.descriptor), ) - showDialog("chose_ime_success", snackBar) - } - .otherwise { - useCase.showImePicker() - } - .onFailure { error -> - val snackBar = - DialogModel.SnackBar(message = error.getFullMessage(this@SettingsViewModel)) - showDialog("chose_ime_error", snackBar) - } + }, + ) + + val newCheckedDevices = showDialog("choose_device", dialog) ?: return@launch + + useCase.setPreference(prefKey, newCheckedDevices.toSet()) + } } } - fun onDeleteSoundFilesClick() { + fun onResetAllSettingsClick() { + val dialog = DialogModel.Alert( + title = getString(R.string.dialog_title_reset_settings), + message = getString(R.string.dialog_message_reset_settings), + positiveButtonText = getString(R.string.pos_button_reset_settings), + negativeButtonText = getString(R.string.neg_cancel), + ) + viewModelScope.launch { - val soundFiles = useCase.getSoundFiles() + val response = showDialog("reset_settings_dialog", dialog) - if (soundFiles.isEmpty()) { - showDialog("no sound files", DialogModel.Toast(getString(R.string.toast_no_sound_files))) - return@launch + if (response == DialogResponse.POSITIVE) { + useCase.resetAllSettings() } + } + } - val dialog = DialogModel.MultiChoice( - items = soundFiles.map { MultiChoiceItem(it.uid, it.name) }, - ) + fun onRequestRootClick() { + useCase.requestRootPermission() + } - val selectedFiles = showDialog("select_sound_files_to_delete", dialog) ?: return@launch + fun onProModeClick() { + viewModelScope.launch { + navigate("pro_mode_settings", NavDestination.ProMode) + } + } - useCase.deleteSoundFiles(selectedFiles) + fun onAutomaticChangeImeClick() { + viewModelScope.launch { + navigate("automatic_change_ime", NavDestination.AutomaticChangeImeSettings) } } - fun requestWriteSecureSettingsPermission() { - useCase.requestWriteSecureSettingsPermission() + fun onBackClick() { + viewModelScope.launch { + popBackStack() + } } - fun requestShizukuPermission() { - useCase.requestShizukuPermission() + fun onThemeSelected(theme: Theme) { + viewModelScope.launch { + useCase.setPreference(Keys.darkTheme, theme.value.toString()) + } } - fun downloadShizuku() { - useCase.downloadShizuku() + fun onPauseResumeNotificationClick() { + onNotificationSettingsClick(NotificationController.CHANNEL_TOGGLE_KEY_MAPS) } - fun openShizukuApp() { - useCase.openShizukuApp() + fun onDefaultOptionsClick() { + viewModelScope.launch { + navigate("default_options", NavDestination.DefaultOptionsSettings) + } } - fun isNotificationPermissionGranted(): Boolean = useCase.isNotificationsPermissionGranted() + override fun onLongPressDelayChanged(delay: Int) { + viewModelScope.launch { + useCase.setPreference(Keys.defaultLongPressDelay, delay) + } + } - fun requestNotificationsPermission() { - useCase.requestNotificationsPermission() + override fun onDoublePressDelayChanged(delay: Int) { + viewModelScope.launch { + useCase.setPreference(Keys.defaultDoublePressDelay, delay) + } } - fun onEnableCompatibleImeClick() { + override fun onVibrateDurationChanged(duration: Int) { viewModelScope.launch { - useCase.enableCompatibleIme() + useCase.setPreference(Keys.defaultVibrateDuration, duration) } } - fun resetDefaultMappingOptions() { - useCase.resetDefaultMappingOptions() + override fun onRepeatDelayChanged(delay: Int) { + viewModelScope.launch { + useCase.setPreference(Keys.defaultRepeatDelay, delay) + } } - fun chooseDevicesForPreference(prefKey: Preferences.Key>) { + override fun onRepeatRateChanged(rate: Int) { viewModelScope.launch { - val externalDevices = useCase.connectedInputDevices - .first { it is State.Data<*> } - .let { (it as State.Data).data } - .filter { it.isExternal } + useCase.setPreference(Keys.defaultRepeatRate, rate) + } + } - if (externalDevices.isEmpty()) { - val dialog = DialogModel.Alert( - message = getString(R.string.dialog_message_settings_no_external_devices_connected), - positiveButtonText = getString(R.string.pos_ok), - ) + override fun onSequenceTriggerTimeoutChanged(timeout: Int) { + viewModelScope.launch { + useCase.setPreference(Keys.defaultSequenceTriggerTimeout, timeout) + } + } - showDialog("no_external_devices", dialog) - } else { - val checkedDevices = useCase.getPreference(prefKey).first() ?: emptySet() + fun onShowToastWhenAutoChangingImeToggled(enabled: Boolean) { + viewModelScope.launch { + useCase.setPreference(Keys.showToastWhenAutoChangingIme, enabled) + } + } - val dialog = DialogModel.MultiChoice( - items = externalDevices.map { device -> - MultiChoiceItem( - id = device.descriptor, - label = device.name, - isChecked = checkedDevices.contains(device.descriptor), - ) - }, - ) + fun onChangeImeOnInputFocusToggled(enabled: Boolean) { + viewModelScope.launch { + useCase.setPreference(Keys.changeImeOnInputFocus, enabled) + } + } - val newCheckedDevices = showDialog("choose_device", dialog) ?: return@launch + fun onChangeImeOnDeviceConnectToggled(enabled: Boolean) { + viewModelScope.launch { + useCase.setPreference(Keys.changeImeOnDeviceConnect, enabled) + } + } - useCase.setPreference(prefKey, newCheckedDevices.toSet()) - } + fun onDevicesThatChangeImeClick() { + chooseDevicesForPreference(Keys.devicesThatChangeIme) + } + + fun onToggleKeyboardOnToggleKeymapsToggled(enabled: Boolean) { + viewModelScope.launch { + useCase.setPreference(Keys.toggleKeyboardOnToggleKeymaps, enabled) } } - fun onCreateBackupFileActivityNotFound() { - val dialog = DialogModel.Alert( - message = getString(R.string.dialog_message_no_app_found_to_create_file), - positiveButtonText = getString(R.string.pos_ok), - ) + fun onShowToggleKeyboardNotificationClick() { + onNotificationSettingsClick(NotificationController.CHANNEL_TOGGLE_KEYBOARD) + } + fun onForceVibrateToggled(enabled: Boolean) { viewModelScope.launch { - showDialog("create_document_activity_not_found", dialog) + useCase.setPreference(Keys.forceVibrate, enabled) } } - fun onResetAllSettingsClick() { - val dialog = DialogModel.Alert( - title = getString(R.string.dialog_title_reset_settings), - message = getString(R.string.dialog_message_reset_settings), - positiveButtonText = getString(R.string.pos_button_reset_settings), - negativeButtonText = getString(R.string.neg_cancel), - ) + fun onLoggingToggled(enabled: Boolean) { + viewModelScope.launch { + useCase.setPreference(Keys.log, enabled) + } + } + fun onViewLogClick() { viewModelScope.launch { - val response = showDialog("reset_settings_dialog", dialog) + navigate("log", NavDestination.Log) + } + } - if (response == DialogResponse.POSITIVE) { - useCase.resetAllSettings() + fun onShareLogcatClick() { + viewModelScope.launch { + shareLogcatUseCase.share().onFailure { error -> + val dialog = DialogModel.Ok( + title = getString(R.string.dialog_title_share_logcat_error), + message = error.getFullMessage(this@SettingsViewModel), + ) + showDialog("logcat_error", dialog) } } } + + fun onKeyEventActionMethodSelected(isProModeSelected: Boolean) { + viewModelScope.launch { + useCase.setPreference(Keys.keyEventActionsUseSystemBridge, isProModeSelected) + } + } + + fun onHideHomeScreenAlertsToggled(enabled: Boolean) { + viewModelScope.launch { + useCase.setPreference(Keys.hideHomeScreenAlerts, enabled) + } + } + + fun onShowDeviceDescriptorsToggled(enabled: Boolean) { + viewModelScope.launch { + useCase.setPreference(Keys.showDeviceDescriptors, enabled) + } + } + + private fun onNotificationSettingsClick(channel: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + !useCase.isNotificationsPermissionGranted() + ) { + useCase.requestNotificationsPermission() + return + } + + useCase.openNotificationChannelSettings(channel) + } } + +data class MainSettingsState( + val theme: Theme = Theme.AUTO, + val autoBackupLocation: String? = null, + val forceVibrate: Boolean = false, + val loggingEnabled: Boolean = false, + val hideHomeScreenAlerts: Boolean = false, + val showDeviceDescriptors: Boolean = false, + val keyEventActionsUseSystemBridege: Boolean = false, +) + +data class DefaultSettingsState( + val longPressDelay: Int = PreferenceDefaults.LONG_PRESS_DELAY, + val defaultLongPressDelay: Int = PreferenceDefaults.LONG_PRESS_DELAY, + + val doublePressDelay: Int = PreferenceDefaults.DOUBLE_PRESS_DELAY, + val defaultDoublePressDelay: Int = PreferenceDefaults.DOUBLE_PRESS_DELAY, + + val vibrateDuration: Int = PreferenceDefaults.VIBRATION_DURATION, + val defaultVibrateDuration: Int = PreferenceDefaults.VIBRATION_DURATION, + + val repeatDelay: Int = PreferenceDefaults.REPEAT_DELAY, + val defaultRepeatDelay: Int = PreferenceDefaults.REPEAT_DELAY, + + val repeatRate: Int = PreferenceDefaults.REPEAT_RATE, + val defaultRepeatRate: Int = PreferenceDefaults.REPEAT_RATE, + + val sequenceTriggerTimeout: Int = PreferenceDefaults.SEQUENCE_TRIGGER_TIMEOUT, + val defaultSequenceTriggerTimeout: Int = PreferenceDefaults.SEQUENCE_TRIGGER_TIMEOUT, +) + +data class AutomaticChangeImeSettingsState( + val showToastWhenAutoChangingIme: Boolean = PreferenceDefaults.SHOW_TOAST_WHEN_AUTO_CHANGE_IME, + val changeImeOnInputFocus: Boolean = PreferenceDefaults.CHANGE_IME_ON_INPUT_FOCUS, + val changeImeOnDeviceConnect: Boolean = false, + val toggleKeyboardOnToggleKeymaps: Boolean = false, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/ShizukuSettingsFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/ShizukuSettingsFragment.kt deleted file mode 100644 index d55063e4f9..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/ShizukuSettingsFragment.kt +++ /dev/null @@ -1,137 +0,0 @@ -package io.github.sds100.keymapper.base.settings - -import android.os.Bundle -import android.view.View -import androidx.lifecycle.Lifecycle -import androidx.preference.Preference -import androidx.preference.isEmpty -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.drawable -import io.github.sds100.keymapper.base.utils.ui.launchRepeatOnLifecycle -import io.github.sds100.keymapper.base.utils.ui.str -import io.github.sds100.keymapper.base.utils.ui.viewLifecycleScope -import io.github.sds100.keymapper.system.url.UrlUtils -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.launchIn - -class ShizukuSettingsFragment : BaseSettingsFragment() { - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - preferenceManager.preferenceDataStore = viewModel.sharedPrefsDataStoreWrapper - setPreferencesFromResource(R.xml.preferences_empty, rootKey) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - viewLifecycleScope.launchWhenResumed { - if (preferenceScreen.isEmpty()) { - populatePreferenceScreen() - } - } - } - - private fun populatePreferenceScreen() = preferenceScreen.apply { - // summary - Preference(requireContext()).apply { - setSummary(R.string.summary_pref_category_shizuku_follow_steps) - addPreference(this) - } - - // install shizuku - Preference(requireContext()).apply { - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.isShizukuInstalled.collectLatest { isInstalled -> - if (isInstalled) { - icon = drawable(R.drawable.ic_outline_check_circle_outline_24) - setTitle(R.string.title_pref_grant_shizuku_install_app_installed) - isEnabled = false - } else { - icon = drawable(R.drawable.ic_baseline_error_outline_24) - setTitle(R.string.title_pref_grant_shizuku_install_app_not_installed) - isEnabled = true - } - } - } - - isSingleLineTitle = false - - setOnPreferenceClickListener { - if (!viewModel.isShizukuInstalled.value) { - viewModel.downloadShizuku() - } - - true - } - - addPreference(this) - } - - // start shizuku - Preference(requireContext()).apply { - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - combine( - viewModel.isShizukuInstalled, - viewModel.isShizukuStarted, - ) { isInstalled, isStarted -> - isEnabled = isInstalled - - if (isStarted) { - icon = drawable(R.drawable.ic_outline_check_circle_outline_24) - setTitle(R.string.title_pref_grant_shizuku_started) - } else { - icon = drawable(R.drawable.ic_baseline_error_outline_24) - setTitle(R.string.title_pref_grant_shizuku_not_started) - } - }.launchIn(this) - } - - isSingleLineTitle = false - - setOnPreferenceClickListener { - if (!viewModel.isShizukuStarted.value) { - viewModel.openShizukuApp() - } - - true - } - - addPreference(this) - } - - // grant shizuku permission - Preference(requireContext()).apply { - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - combine( - viewModel.isShizukuStarted, - viewModel.isShizukuPermissionGranted, - ) { isStarted, isGranted -> - isEnabled = isStarted - - if (isGranted) { - icon = drawable(R.drawable.ic_outline_check_circle_outline_24) - setTitle(R.string.title_pref_grant_shizuku_granted) - } else { - icon = drawable(R.drawable.ic_baseline_error_outline_24) - setTitle(R.string.title_pref_grant_shizuku_not_granted) - } - }.launchIn(this) - } - - isSingleLineTitle = false - - setOnPreferenceClickListener { - if (viewModel.isShizukuPermissionGranted.value) { - UrlUtils.openUrl(requireContext(), str(R.string.url_shizuku_setting_benefits)) - } else { - viewModel.requestShizukuPermission() - } - - true - } - - addPreference(this) - } - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/Theme.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/Theme.kt new file mode 100644 index 0000000000..0ef9163046 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/settings/Theme.kt @@ -0,0 +1,7 @@ +package io.github.sds100.keymapper.base.settings + +enum class Theme(val value: Int) { + DARK(0), + LIGHT(1), + AUTO(2), +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/settings/ThemeUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/settings/ThemeUtils.kt deleted file mode 100644 index 63d6110cf6..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/settings/ThemeUtils.kt +++ /dev/null @@ -1,9 +0,0 @@ -package io.github.sds100.keymapper.base.settings - -object ThemeUtils { - const val DARK = 0 - const val LIGHT = 1 - const val AUTO = 2 - - val THEMES = arrayOf(LIGHT, DARK, AUTO) -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/CreateKeyMapShortcutActivity.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt similarity index 98% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/CreateKeyMapShortcutActivity.kt rename to base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt index 66b9d37eed..741ed4f789 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/CreateKeyMapShortcutActivity.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutActivity.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.base.keymaps +package io.github.sds100.keymapper.base.shortcuts import android.os.Bundle import androidx.activity.SystemBarStyle @@ -16,15 +16,15 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase import io.github.sds100.keymapper.base.system.accessibility.AccessibilityServiceAdapterImpl import io.github.sds100.keymapper.base.system.permissions.RequestPermissionDelegate -import io.github.sds100.keymapper.base.trigger.RecordTriggerController +import io.github.sds100.keymapper.base.trigger.RecordTriggerControllerImpl import io.github.sds100.keymapper.base.utils.ui.ResourceProviderImpl import io.github.sds100.keymapper.base.utils.ui.launchRepeatOnLifecycle import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapterImpl import io.github.sds100.keymapper.system.permissions.AndroidPermissionAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter -import kotlinx.coroutines.flow.collectLatest import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest @AndroidEntryPoint class CreateKeyMapShortcutActivity : AppCompatActivity() { @@ -42,7 +42,7 @@ class CreateKeyMapShortcutActivity : AppCompatActivity() { lateinit var onboardingUseCase: OnboardingUseCase @Inject - lateinit var recordTriggerController: RecordTriggerController + lateinit var recordTriggerController: RecordTriggerControllerImpl @Inject lateinit var notificationReceiverAdapter: NotificationReceiverAdapterImpl diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/CreateKeyMapShortcutScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt similarity index 92% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/CreateKeyMapShortcutScreen.kt rename to base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt index 366566c45a..57dce3fb48 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/CreateKeyMapShortcutScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.base.keymaps +package io.github.sds100.keymapper.base.shortcuts import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent @@ -42,6 +42,9 @@ import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.groups.GroupBreadcrumbRow import io.github.sds100.keymapper.base.groups.GroupListItemModel import io.github.sds100.keymapper.base.groups.GroupRow +import io.github.sds100.keymapper.base.home.KeyMapAppBarState +import io.github.sds100.keymapper.base.home.KeyMapList +import io.github.sds100.keymapper.base.home.KeyMapListState import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel import io.github.sds100.keymapper.base.trigger.TriggerError import io.github.sds100.keymapper.base.utils.ui.UnsavedChangesDialog @@ -123,7 +126,9 @@ private fun CreateKeyMapShortcutScreen( IconButton(onClick = { showBackDialog = true }) { Icon( Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.bottom_app_bar_back_content_description), + contentDescription = stringResource( + R.string.bottom_app_bar_back_content_description, + ), ) } }, @@ -154,14 +159,18 @@ private fun CreateKeyMapShortcutScreen( IconButton(onClick = onPopGroupClick) { Icon( Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.home_app_bar_pop_group), + contentDescription = stringResource( + R.string.home_app_bar_pop_group, + ), ) } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + titleContentColor = + MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = + MaterialTheme.colorScheme.onPrimaryContainer, ), ) @@ -262,7 +271,9 @@ private fun keyMapSampleList(): List { actions = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Open Key Mapper", ), ComposeChipModel.Error( @@ -285,7 +296,9 @@ private fun keyMapSampleList(): List { constraints = listOf( ComposeChipModel.Normal( id = "0", - ComposeIconInfo.Drawable(drawable = context.drawable(R.drawable.ic_launcher_web)), + ComposeIconInfo.Drawable( + drawable = context.drawable(R.drawable.ic_launcher_web), + ), "Key Mapper is not open", ), ComposeChipModel.Error( @@ -374,6 +387,7 @@ private fun PreviewRootGroup() { isPaused = true, ), listItems = State.Data(keyMapSampleList()), + showCreateKeyMapTapTarget = false, ), showShortcutNameDialog = null, ) @@ -395,8 +409,10 @@ private fun PreviewChildGroup() { breadcrumbs = groupSampleList(), isEditingGroupName = false, isNewGroup = false, + keyMapsEnabled = null, ), listItems = State.Data(keyMapSampleList()), + showCreateKeyMapTapTarget = false, ), showShortcutNameDialog = null, ) @@ -415,6 +431,7 @@ private fun PreviewEmpty() { isPaused = true, ), listItems = State.Data(emptyList()), + showCreateKeyMapTapTarget = false, ), showShortcutNameDialog = null, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/CreateKeyMapShortcutUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutUseCase.kt similarity index 81% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/CreateKeyMapShortcutUseCase.kt rename to base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutUseCase.kt index 563b7bff63..53a8734296 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/CreateKeyMapShortcutUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutUseCase.kt @@ -1,17 +1,17 @@ -package io.github.sds100.keymapper.base.keymaps +package io.github.sds100.keymapper.base.shortcuts import android.content.Intent import android.graphics.drawable.Drawable import androidx.core.os.bundleOf +import dagger.hilt.android.scopes.ViewModelScoped import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.system.apps.AppShortcutAdapter import javax.inject.Inject +@ViewModelScoped class CreateKeyMapShortcutUseCaseImpl @Inject constructor( private val appShortcutAdapter: AppShortcutAdapter, - private val resourceProvider: ResourceProvider, ) : CreateKeyMapShortcutUseCase { companion object { @@ -46,11 +46,7 @@ class CreateKeyMapShortcutUseCaseImpl @Inject constructor( return appShortcutAdapter.pinShortcut(shortcut) } - override fun createIntent( - keyMapUid: String, - shortcutLabel: String, - icon: Drawable?, - ): Intent { + override fun createIntent(keyMapUid: String, shortcutLabel: String, icon: Drawable?): Intent { val shortcut = if (icon == null) { appShortcutAdapter.createLauncherShortcut( iconResId = R.mipmap.ic_launcher_round, @@ -73,15 +69,7 @@ class CreateKeyMapShortcutUseCaseImpl @Inject constructor( interface CreateKeyMapShortcutUseCase { val isSupported: Boolean - fun pinShortcut( - keyMapUid: String, - shortcutLabel: String, - icon: Drawable?, - ): KMResult<*> + fun pinShortcut(keyMapUid: String, shortcutLabel: String, icon: Drawable?): KMResult<*> - fun createIntent( - keyMapUid: String, - shortcutLabel: String, - icon: Drawable?, - ): Intent + fun createIntent(keyMapUid: String, shortcutLabel: String, icon: Drawable?): Intent } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/CreateKeyMapShortcutViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutViewModel.kt similarity index 85% rename from base/src/main/java/io/github/sds100/keymapper/base/keymaps/CreateKeyMapShortcutViewModel.kt rename to base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutViewModel.kt index 086e330619..0c692779c2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/CreateKeyMapShortcutViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutViewModel.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.base.keymaps +package io.github.sds100.keymapper.base.shortcuts import android.content.Intent import android.graphics.Color @@ -15,6 +15,13 @@ import io.github.sds100.keymapper.base.constraints.ConstraintErrorSnapshot import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.constraints.ConstraintUiHelper import io.github.sds100.keymapper.base.groups.GroupListItemModel +import io.github.sds100.keymapper.base.home.KeyMapAppBarState +import io.github.sds100.keymapper.base.home.KeyMapGroup +import io.github.sds100.keymapper.base.home.KeyMapListItemCreator +import io.github.sds100.keymapper.base.home.KeyMapListState +import io.github.sds100.keymapper.base.home.ListKeyMapsUseCase +import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState +import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCase import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel import io.github.sds100.keymapper.base.trigger.TriggerErrorSnapshot import io.github.sds100.keymapper.base.utils.ui.ResourceProvider @@ -22,6 +29,7 @@ import io.github.sds100.keymapper.base.utils.ui.TintType import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.mapData +import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -31,11 +39,11 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class CreateKeyMapShortcutViewModel @Inject constructor( - private val config: ConfigKeyMapUseCase, + private val configKeyMapState: ConfigKeyMapState, + private val configTrigger: ConfigTriggerUseCase, private val listKeyMaps: ListKeyMapsUseCase, private val createKeyMapShortcut: CreateKeyMapShortcutUseCase, private val resourceProvider: ResourceProvider, @@ -54,6 +62,7 @@ class CreateKeyMapShortcutViewModel @Inject constructor( isPaused = false, ), listItems = State.Loading, + showCreateKeyMapTapTarget = false, ) private val _state: MutableStateFlow = MutableStateFlow(initialState) val state = _state.asStateFlow() @@ -72,7 +81,13 @@ class CreateKeyMapShortcutViewModel @Inject constructor( listKeyMaps.triggerErrorSnapshot, listKeyMaps.actionErrorSnapshot, listKeyMaps.constraintErrorSnapshot, - ) { keyMapGroup, showDeviceDescriptors, triggerErrorSnapshot, actionErrorSnapshot, constraintErrorSnapshot -> + ) { + keyMapGroup, + showDeviceDescriptors, + triggerErrorSnapshot, + actionErrorSnapshot, + constraintErrorSnapshot, + -> _state.value = buildState( keyMapGroup, showDeviceDescriptors, @@ -143,11 +158,14 @@ class CreateKeyMapShortcutViewModel @Inject constructor( breadcrumbs = breadcrumbs, isEditingGroupName = false, isNewGroup = false, - parentConstraintCount = keyMapGroup.parents.sumOf { it.constraintState.constraints.size }, + parentConstraintCount = keyMapGroup.parents.sumOf { + it.constraintState.constraints.size + }, + keyMapsEnabled = null, ) } - return KeyMapListState(appBarState, listItemsState) + return KeyMapListState(appBarState, listItemsState, showCreateKeyMapTapTarget = false) } fun onKeyMapCardClick(uid: String) { @@ -156,10 +174,10 @@ class CreateKeyMapShortcutViewModel @Inject constructor( if (state.keyMaps !is State.Data) return@launch - config.loadKeyMap(uid) - config.setTriggerFromOtherAppsEnabled(true) + configKeyMapState.loadKeyMap(uid) + configTrigger.setTriggerFromOtherAppsEnabled(true) - val keyMapState = config.keyMap.first() + val keyMapState = configKeyMapState.keyMap.first() if (keyMapState !is State.Data) return@launch @@ -212,7 +230,7 @@ class CreateKeyMapShortcutViewModel @Inject constructor( icon = icon, ) - config.save() + configKeyMapState.save() _returnIntentResult.emit(intent) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortFieldOrder.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortFieldOrder.kt index 503f8136e8..cd8c37e51f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortFieldOrder.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortFieldOrder.kt @@ -3,7 +3,4 @@ package io.github.sds100.keymapper.base.sorting import kotlinx.serialization.Serializable @Serializable -data class SortFieldOrder( - val field: SortField, - val order: SortOrder = SortOrder.NONE, -) +data class SortFieldOrder(val field: SortField, val order: SortOrder = SortOrder.NONE) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortKeyMapsUseCase.kt index 3f8bf662b7..c23c197d36 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortKeyMapsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortKeyMapsUseCase.kt @@ -8,10 +8,10 @@ import io.github.sds100.keymapper.base.sorting.comparators.KeyMapOptionsComparat import io.github.sds100.keymapper.base.sorting.comparators.KeyMapTriggerComparator import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.serialization.json.Json -import javax.inject.Inject class SortKeyMapsUseCaseImpl @Inject constructor( private val preferenceRepository: PreferenceRepository, @@ -102,13 +102,8 @@ interface SortKeyMapsUseCase { fun observeKeyMapsSorter(): Flow> } -private class Sorter( - private val comparatorsOrder: List>, -) : Comparator { - override fun compare( - keyMap: KeyMap?, - otherKeyMap: KeyMap?, - ): Int { +private class Sorter(private val comparatorsOrder: List>) : Comparator { + override fun compare(keyMap: KeyMap?, otherKeyMap: KeyMap?): Int { if (keyMap == null || otherKeyMap == null) { return 0 } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapActionsComparator.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapActionsComparator.kt index 53ffdbb46f..f4969e98b2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapActionsComparator.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapActionsComparator.kt @@ -15,10 +15,7 @@ class KeyMapActionsComparator( */ private val reverse: Boolean = false, ) : Comparator { - override fun compare( - keyMap: KeyMap?, - otherKeyMap: KeyMap?, - ): Int { + override fun compare(keyMap: KeyMap?, otherKeyMap: KeyMap?): Int { if (keyMap == null || otherKeyMap == null) { return 0 } @@ -70,7 +67,8 @@ class KeyMapActionsComparator( is ActionData.InputKeyEvent -> Success(action.keyCode.toString()) is ActionData.Sound.SoundFile -> Success(action.soundDescription) is ActionData.Sound.Ringtone -> Success(action.uri) - is ActionData.Volume.Stream -> Success(action.volumeStream.toString()) + is ActionData.Volume.Up -> Success(action.volumeStream?.toString() ?: "") + is ActionData.Volume.Down -> Success(action.volumeStream?.toString() ?: "") is ActionData.Volume.SetRingerMode -> Success(action.ringerMode.toString()) is ActionData.Flashlight -> Success(action.lens.toString()) is ActionData.SwitchKeyboard -> Success(action.savedImeName) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt index 0f9f9a0987..65df1611cc 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.sorting.comparators import io.github.sds100.keymapper.base.constraints.Constraint +import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.constraints.DisplayConstraintUseCase import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.common.utils.KMResult @@ -18,10 +19,7 @@ class KeyMapConstraintsComparator( */ private val reverse: Boolean = false, ) : Comparator { - override fun compare( - keyMap: KeyMap?, - otherKeyMap: KeyMap?, - ): Int { + override fun compare(keyMap: KeyMap?, otherKeyMap: KeyMap?): Int { if (keyMap == null || otherKeyMap == null) { return 0 } @@ -54,10 +52,7 @@ class KeyMapConstraintsComparator( result } - private fun compareConstraints( - constraint: Constraint, - otherConstraint: Constraint, - ): Int { + private fun compareConstraints(constraint: Constraint, otherConstraint: Constraint): Int { // If constraints are different, compare their types so they are ordered // by their type. // @@ -86,52 +81,63 @@ class KeyMapConstraintsComparator( } private fun getSecondarySortField(constraint: Constraint): KMResult { - return when (constraint) { - is Constraint.AppInForeground -> displayConstraints.getAppName(constraint.packageName) - is Constraint.AppNotInForeground -> displayConstraints.getAppName(constraint.packageName) - is Constraint.AppNotPlayingMedia -> displayConstraints.getAppName(constraint.packageName) - is Constraint.AppPlayingMedia -> displayConstraints.getAppName(constraint.packageName) - is Constraint.BtDeviceConnected -> Success(constraint.deviceName) - is Constraint.BtDeviceDisconnected -> Success(constraint.deviceName) - is Constraint.Charging -> Success("") - is Constraint.DeviceIsLocked -> Success("") - is Constraint.DeviceIsUnlocked -> Success("") - is Constraint.Discharging -> Success("") - is Constraint.FlashlightOff -> Success(constraint.lens.toString()) - is Constraint.FlashlightOn -> Success(constraint.lens.toString()) - is Constraint.ImeChosen -> Success(constraint.imeLabel) - is Constraint.ImeNotChosen -> Success(constraint.imeLabel) - is Constraint.InPhoneCall -> Success("") - is Constraint.MediaPlaying -> Success("") - is Constraint.NoMediaPlaying -> Success("") - is Constraint.NotInPhoneCall -> Success("") - is Constraint.OrientationCustom -> Success(constraint.orientation.toString()) - is Constraint.OrientationLandscape -> Success("") - is Constraint.OrientationPortrait -> Success("") - is Constraint.PhoneRinging -> Success("") - is Constraint.ScreenOff -> Success("") - is Constraint.ScreenOn -> Success("") - is Constraint.WifiConnected -> if (constraint.ssid == null) { + return when (constraint.data) { + is ConstraintData.AppInForeground -> displayConstraints.getAppName( + constraint.data.packageName, + ) + is ConstraintData.AppNotInForeground -> displayConstraints.getAppName( + constraint.data.packageName, + ) + is ConstraintData.AppNotPlayingMedia -> displayConstraints.getAppName( + constraint.data.packageName, + ) + is ConstraintData.AppPlayingMedia -> displayConstraints.getAppName( + constraint.data.packageName, + ) + is ConstraintData.BtDeviceConnected -> Success(constraint.data.deviceName) + is ConstraintData.BtDeviceDisconnected -> Success(constraint.data.deviceName) + is ConstraintData.Charging -> Success("") + is ConstraintData.DeviceIsLocked -> Success("") + is ConstraintData.DeviceIsUnlocked -> Success("") + is ConstraintData.Discharging -> Success("") + is ConstraintData.FlashlightOff -> Success(constraint.data.lens.toString()) + is ConstraintData.FlashlightOn -> Success(constraint.data.lens.toString()) + is ConstraintData.ImeChosen -> Success(constraint.data.imeLabel) + is ConstraintData.ImeNotChosen -> Success(constraint.data.imeLabel) + is ConstraintData.InPhoneCall -> Success("") + is ConstraintData.MediaPlaying -> Success("") + is ConstraintData.NoMediaPlaying -> Success("") + is ConstraintData.NotInPhoneCall -> Success("") + is ConstraintData.OrientationCustom -> Success(constraint.data.orientation.toString()) + is ConstraintData.OrientationLandscape -> Success("") + is ConstraintData.OrientationPortrait -> Success("") + is ConstraintData.PhoneRinging -> Success("") + is ConstraintData.ScreenOff -> Success("") + is ConstraintData.ScreenOn -> Success("") + is ConstraintData.WifiConnected -> if (constraint.data.ssid == null) { Success("") } else { - Success(constraint.ssid) + Success(constraint.data.ssid) } - is Constraint.WifiDisconnected -> if (constraint.ssid == null) { + is ConstraintData.WifiDisconnected -> if (constraint.data.ssid == null) { Success("") } else { - Success(constraint.ssid) + Success(constraint.data.ssid) } - is Constraint.WifiOff -> Success("") - is Constraint.WifiOn -> Success("") - is Constraint.LockScreenNotShowing -> Success("") - is Constraint.LockScreenShowing -> Success("") - is Constraint.Time -> Success( - constraint.startTime + is ConstraintData.WifiOff -> Success("") + is ConstraintData.WifiOn -> Success("") + is ConstraintData.LockScreenNotShowing -> Success("") + is ConstraintData.LockScreenShowing -> Success("") + is ConstraintData.Time -> Success( + constraint.data.startTime .toEpochSecond(LocalDate.now(), ZoneOffset.UTC) .toString(), ) + + ConstraintData.HingeClosed -> Success("") + ConstraintData.HingeOpen -> Success("") } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapOptionsComparator.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapOptionsComparator.kt index 448e860b59..130f74f0a1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapOptionsComparator.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapOptionsComparator.kt @@ -9,10 +9,7 @@ class KeyMapOptionsComparator( */ private val reverse: Boolean = false, ) : Comparator { - override fun compare( - keyMap: KeyMap?, - otherKeyMap: KeyMap?, - ): Int { + override fun compare(keyMap: KeyMap?, otherKeyMap: KeyMap?): Int { if (keyMap == null || otherKeyMap == null) { return 0 } @@ -21,7 +18,6 @@ class KeyMapOptionsComparator( keyMap, otherKeyMap, { it.vibrate }, - { it.trigger.screenOffTrigger }, { it.trigger.triggerFromOtherApps }, { it.showToast }, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapTriggerComparator.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapTriggerComparator.kt index 70768d8b16..a72d977ed3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapTriggerComparator.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapTriggerComparator.kt @@ -12,10 +12,7 @@ class KeyMapTriggerComparator( /** * Compare trigger keys -> keys length -> trigger mode */ - override fun compare( - keyMap: KeyMap?, - otherKeyMap: KeyMap?, - ): Int { + override fun compare(keyMap: KeyMap?, otherKeyMap: KeyMap?): Int { if (keyMap == null || otherKeyMap == null) { return 0 } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityNodeRecorder.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityNodeRecorder.kt index 769d70a7da..2c132559c0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityNodeRecorder.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityNodeRecorder.kt @@ -88,9 +88,7 @@ class AccessibilityNodeRecorder @AssistedInject constructor( } } - private fun getNodesRecursively( - node: AccessibilityNodeInfo, - ): Set { + private fun getNodesRecursively(node: AccessibilityNodeInfo): Set { val set = mutableSetOf() val entity = buildNodeEntity(node, interacted = false) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityServiceAdapterImpl.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityServiceAdapterImpl.kt index bb08d91614..b05d2e35d7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityServiceAdapterImpl.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityServiceAdapterImpl.kt @@ -2,41 +2,33 @@ package io.github.sds100.keymapper.base.system.accessibility import android.content.ActivityNotFoundException import android.content.Context -import android.content.Intent -import android.database.ContentObserver -import android.net.Uri -import android.os.Build -import android.os.Handler -import android.os.Looper import android.provider.Settings import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.common.KeyMapperClassProvider -import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.AccessibilityServiceError import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.SettingsUtils import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.onSuccess import io.github.sds100.keymapper.system.JobSchedulerHelper -import io.github.sds100.keymapper.system.SettingsUtils import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceEvent import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton @Singleton class AccessibilityServiceAdapterImpl @Inject constructor( @@ -48,7 +40,8 @@ class AccessibilityServiceAdapterImpl @Inject constructor( ) : AccessibilityServiceAdapter { private val ctx = context.applicationContext - override val eventReceiver = MutableSharedFlow() + override val eventReceiver = + MutableSharedFlow(extraBufferCapacity = 10) val eventsToService = MutableSharedFlow() @@ -56,35 +49,36 @@ class AccessibilityServiceAdapterImpl @Inject constructor( init { // use job scheduler because there is there is a much shorter delay when the app is in the background - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - JobSchedulerHelper.observeEnabledAccessibilityServices(ctx) - } else { - val uri = Settings.Secure.getUriFor(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) - val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { - override fun onChange(selfChange: Boolean, uri: Uri?) { - super.onChange(selfChange, uri) - - coroutineScope.launch { - state.value = getState() - } - } - } - - ctx.contentResolver.registerContentObserver(uri, false, observer) - } + JobSchedulerHelper.observeEnabledAccessibilityServices(ctx) coroutineScope.launch { state.value = getState() - } - eventReceiver.onEach { - Timber.d("Received event from service: $it") - }.launchIn(coroutineScope) + eventReceiver.collect { + Timber.d("Received event from service: $it") + } + } } - override fun sendAsync(event: AccessibilityServiceEvent) { - coroutineScope.launch { - eventsToService.emit(event) + override fun sendAsync(event: AccessibilityServiceEvent): KMResult { + val state = state.value + + when (state) { + AccessibilityServiceState.DISABLED -> { + return AccessibilityServiceError.Disabled + } + + AccessibilityServiceState.CRASHED -> { + return AccessibilityServiceError.Crashed + } + + AccessibilityServiceState.ENABLED -> { + coroutineScope.launch { + eventsToService.emit(event) + } + + return Success(Unit) + } } } @@ -93,12 +87,12 @@ class AccessibilityServiceAdapterImpl @Inject constructor( if (state.value == AccessibilityServiceState.DISABLED) { Timber.e("Failed to send event to accessibility service because disabled: $event") - return KMError.AccessibilityServiceDisabled + return AccessibilityServiceError.Disabled } if (state.value == AccessibilityServiceState.CRASHED) { Timber.e("Failed to send event to accessibility service because crashed: $event") - return KMError.AccessibilityServiceCrashed + return AccessibilityServiceError.Crashed } coroutineScope.launch { @@ -131,7 +125,9 @@ class AccessibilityServiceAdapterImpl @Inject constructor( } val pong: AccessibilityServiceEvent.Pong? = withTimeoutOrNull(2000L) { - eventReceiver.first { it == AccessibilityServiceEvent.Pong(key) } as AccessibilityServiceEvent.Pong? + eventReceiver.first { + it == AccessibilityServiceEvent.Pong(key) + } as AccessibilityServiceEvent.Pong? } if (pong == null) { @@ -178,17 +174,7 @@ class AccessibilityServiceAdapterImpl @Inject constructor( private fun launchAccessibilitySettings(): Boolean { try { - val settingsIntent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) - - settingsIntent.addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK - or Intent.FLAG_ACTIVITY_CLEAR_TASK - or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS - // Add this flag so user only has to press back once. - or Intent.FLAG_ACTIVITY_NO_HISTORY, - ) - - ctx.startActivity(settingsIntent) + SettingsUtils.launchSettingsScreen(ctx, Settings.ACTION_ACCESSIBILITY_SETTINGS) return true } catch (e: ActivityNotFoundException) { @@ -197,15 +183,12 @@ class AccessibilityServiceAdapterImpl @Inject constructor( } private suspend fun disableServiceSuspend() { - // disableSelf method only exists in 7.0.0+ - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - send(AccessibilityServiceEvent.DisableService).onSuccess { - Timber.i("Disabling service by calling disableSelf()") - - return - }.onFailure { - Timber.i("Failed to disable service by calling disableSelf()") - } + send(AccessibilityServiceEvent.DisableService).onSuccess { + Timber.i("Disabling service by calling disableSelf()") + + return + }.onFailure { + Timber.i("Failed to disable service by calling disableSelf()") } if (permissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS)) { @@ -254,7 +237,9 @@ class AccessibilityServiceAdapterImpl @Inject constructor( } val pong: AccessibilityServiceEvent.Pong? = withTimeoutOrNull(2000L) { - eventReceiver.first { it == AccessibilityServiceEvent.Pong(key) } as AccessibilityServiceEvent.Pong? + eventReceiver.first { + it == AccessibilityServiceEvent.Pong(key) + } as AccessibilityServiceEvent.Pong? } pingJob.cancel() 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 a39025a574..1408163049 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 @@ -4,6 +4,7 @@ import android.accessibilityservice.AccessibilityService import android.accessibilityservice.FingerprintGestureController import android.accessibilityservice.GestureDescription import android.accessibilityservice.GestureDescription.StrokeDescription +import android.accessibilityservice.InputMethod import android.app.ActivityManager import android.content.Intent import android.content.res.Configuration @@ -11,8 +12,9 @@ import android.graphics.Path import android.graphics.Point import android.os.Build import android.view.KeyEvent -import android.view.MotionEvent import android.view.accessibility.AccessibilityEvent +import android.view.inputmethod.EditorInfo +import androidx.annotation.RequiresApi import androidx.core.content.getSystemService import androidx.core.os.bundleOf import androidx.lifecycle.Lifecycle @@ -22,26 +24,21 @@ import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import dagger.hilt.android.AndroidEntryPoint -import io.github.sds100.keymapper.api.IKeyEventRelayServiceCallback import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.system.inputmethod.ImeInputEventInjectorImpl -import io.github.sds100.keymapper.base.trigger.KeyEventDetectionSource -import io.github.sds100.keymapper.common.utils.InputEventType +import io.github.sds100.keymapper.base.input.InputEventDetectionSource +import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.MathUtils import io.github.sds100.keymapper.common.utils.PinchScreenType import io.github.sds100.keymapper.common.utils.Success -import io.github.sds100.keymapper.system.devices.InputDeviceUtils -import io.github.sds100.keymapper.system.inputevents.MyKeyEvent -import io.github.sds100.keymapper.system.inputevents.MyMotionEvent +import io.github.sds100.keymapper.system.inputevents.KMKeyEvent import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter -import io.github.sds100.keymapper.system.inputmethod.KeyEventRelayServiceWrapperImpl +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import timber.log.Timber -import javax.inject.Inject @AndroidEntryPoint abstract class BaseAccessibilityService : @@ -50,11 +47,6 @@ abstract class BaseAccessibilityService : IAccessibilityService, SavedStateRegistryOwner { - companion object { - - private const val CALLBACK_ID_ACCESSIBILITY_SERVICE = "accessibility_service" - } - @Inject lateinit var accessibilityServiceAdapter: AccessibilityServiceAdapterImpl @@ -67,41 +59,53 @@ abstract class BaseAccessibilityService : override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController!!.savedStateRegistry - private var fingerprintGestureCallback: FingerprintGestureController.FingerprintGestureCallback? = - null + private var fingerprintGestureCallback: + FingerprintGestureController.FingerprintGestureCallback? = null override val rootNode: AccessibilityNodeModel? get() { return rootInActiveWindow?.toModel() } + override val activeWindowPackageNames: List + get() = windows + ?.filter { it.isActive } + ?.mapNotNull { it.root?.packageName?.toString() } + ?.toList() ?: emptyList() + private val _activeWindowPackage: MutableStateFlow = MutableStateFlow(null) override val activeWindowPackage: Flow = _activeWindowPackage override val isFingerprintGestureDetectionAvailable: Boolean - get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + get() = fingerprintGestureController.isGestureDetectionAvailable + + private val accessibilityInputMethod: InputMethod? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + object : InputMethod(this) { + override fun onStartInput(attribute: EditorInfo, restarting: Boolean) { + super.onStartInput(attribute, restarting) + + getController()?.onStartInput(attribute, restarting = restarting) + } + + override fun onFinishInput() { + super.onFinishInput() + + getController()?.onFinishInput() + } + } } else { - false + null } private val _isKeyboardHidden by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - MutableStateFlow(softKeyboardController.showMode == SHOW_MODE_HIDDEN) - } else { - MutableStateFlow(false) - } + MutableStateFlow(softKeyboardController.showMode == SHOW_MODE_HIDDEN) } override val isKeyboardHidden: Flow get() = _isKeyboardHidden - override fun switchIme(imeId: String) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - softKeyboardController.switchToInputMethod(imeId) - } - } - override var serviceFlags: Int? get() = serviceInfo?.flags set(value) { @@ -142,53 +146,6 @@ abstract class BaseAccessibilityService : } } - private val relayServiceCallback: IKeyEventRelayServiceCallback = - object : IKeyEventRelayServiceCallback.Stub() { - override fun onKeyEvent(event: KeyEvent?): Boolean { - event ?: return false - - val device = event.device?.let { InputDeviceUtils.createInputDeviceInfo(it) } - - return getController() - ?.onKeyEventFromIme( - MyKeyEvent( - keyCode = event.keyCode, - action = event.action, - metaState = event.metaState, - scanCode = event.scanCode, - device = device, - repeatCount = event.repeatCount, - source = event.source, - ), - ) ?: false - } - - override fun onMotionEvent(event: MotionEvent?): Boolean { - event ?: return false - - return getController() - ?.onMotionEventFromIme(MyMotionEvent.fromMotionEvent(event)) - ?: return false - } - } - - val keyEventRelayServiceWrapper: KeyEventRelayServiceWrapperImpl by lazy { - KeyEventRelayServiceWrapperImpl( - ctx = this, - id = CALLBACK_ID_ACCESSIBILITY_SERVICE, - servicePackageName = packageName, - callback = relayServiceCallback, - ) - } - - val imeInputEventInjector by lazy { - ImeInputEventInjectorImpl( - this, - keyEventRelayService = keyEventRelayServiceWrapper, - inputMethodAdapter = inputMethodAdapter, - ) - } - override val lifecycle: Lifecycle get() = lifecycleRegistry @@ -203,16 +160,12 @@ abstract class BaseAccessibilityService : lifecycleRegistry.currentState = Lifecycle.State.CREATED - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - softKeyboardController.addOnShowModeChangedListener { _, showMode -> - when (showMode) { - SHOW_MODE_AUTO -> _isKeyboardHidden.value = false - SHOW_MODE_HIDDEN -> _isKeyboardHidden.value = true - } + softKeyboardController.addOnShowModeChangedListener { _, showMode -> + when (showMode) { + SHOW_MODE_AUTO -> _isKeyboardHidden.value = false + SHOW_MODE_HIDDEN -> _isKeyboardHidden.value = true } } - - keyEventRelayServiceWrapper.onCreate() } override fun onServiceConnected() { @@ -225,34 +178,32 @@ abstract class BaseAccessibilityService : _activeWindowPackage.update { rootInActiveWindow?.packageName?.toString() } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - fingerprintGestureCallback = - object : FingerprintGestureController.FingerprintGestureCallback() { - override fun onGestureDetected(gesture: Int) { - super.onGestureDetected(gesture) + fingerprintGestureCallback = + object : FingerprintGestureController.FingerprintGestureCallback() { + override fun onGestureDetected(gesture: Int) { + super.onGestureDetected(gesture) - val id: FingerprintGestureType = when (gesture) { - FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_DOWN -> - FingerprintGestureType.SWIPE_DOWN + val id: FingerprintGestureType = when (gesture) { + FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_DOWN -> + FingerprintGestureType.SWIPE_DOWN - FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_UP -> - FingerprintGestureType.SWIPE_UP + FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_UP -> + FingerprintGestureType.SWIPE_UP - FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_LEFT -> - FingerprintGestureType.SWIPE_LEFT + FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_LEFT -> + FingerprintGestureType.SWIPE_LEFT - FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_RIGHT -> - FingerprintGestureType.SWIPE_RIGHT + FingerprintGestureController.FINGERPRINT_GESTURE_SWIPE_RIGHT -> + FingerprintGestureType.SWIPE_RIGHT - else -> return - } - getController()?.onFingerprintGesture(id) + else -> return } + getController()?.onFingerprintGesture(id) } - - fingerprintGestureCallback?.let { - fingerprintGestureController.registerFingerprintGestureCallback(it, null) } + + fingerprintGestureCallback?.let { + fingerprintGestureController.registerFingerprintGestureCallback(it, null) } } @@ -266,12 +217,8 @@ abstract class BaseAccessibilityService : override fun onDestroy() { lifecycleRegistry.currentState = Lifecycle.State.DESTROYED - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - fingerprintGestureController - .unregisterFingerprintGestureCallback(fingerprintGestureCallback) - } - - keyEventRelayServiceWrapper.onDestroy() + fingerprintGestureController + .unregisterFingerprintGestureCallback(fingerprintGestureCallback) Timber.i("Accessibility service: onDestroy") @@ -288,7 +235,9 @@ abstract class BaseAccessibilityService : val memoryInfo = ActivityManager.MemoryInfo() getSystemService()?.getMemoryInfo(memoryInfo) - Timber.i("Accessibility service: onLowMemory, total: ${memoryInfo.totalMem}, available: ${memoryInfo.availMem}, is low memory: ${memoryInfo.lowMemory}, threshold: ${memoryInfo.threshold}") + Timber.i( + "Accessibility service: onLowMemory, total: ${memoryInfo.totalMem}, available: ${memoryInfo.availMem}, is low memory: ${memoryInfo.lowMemory}, threshold: ${memoryInfo.threshold}", + ) super.onTrimMemory(level) } @@ -306,23 +255,11 @@ abstract class BaseAccessibilityService : override fun onKeyEvent(event: KeyEvent?): Boolean { event ?: return super.onKeyEvent(event) - val device = if (event.device == null) { - null - } else { - InputDeviceUtils.createInputDeviceInfo(event.device) - } + val kmKeyEvent = KMKeyEvent.fromAndroidKeyEvent(event) ?: return false return getController()?.onKeyEvent( - MyKeyEvent( - keyCode = event.keyCode, - action = event.action, - metaState = event.metaState, - scanCode = event.scanCode, - device = device, - repeatCount = event.repeatCount, - source = event.source, - ), - KeyEventDetectionSource.ACCESSIBILITY_SERVICE, + kmKeyEvent, + InputEventDetectionSource.ACCESSIBILITY_SERVICE, ) ?: false } @@ -330,25 +267,39 @@ abstract class BaseAccessibilityService : return findFocus(focus)?.toModel() } - override fun setInputMethodEnabled(imeId: String, enabled: Boolean) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - softKeyboardController.setInputMethodEnabled(imeId, enabled) - } + override fun onCreateInputMethod(): InputMethod { + return accessibilityInputMethod ?: super.onCreateInputMethod() } override fun hideKeyboard() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - softKeyboardController.showMode = SHOW_MODE_HIDDEN - } + softKeyboardController.showMode = SHOW_MODE_HIDDEN } override fun showKeyboard() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - softKeyboardController.showMode = SHOW_MODE_AUTO + softKeyboardController.showMode = SHOW_MODE_AUTO + } + + @RequiresApi(Build.VERSION_CODES.R) + override fun switchIme(imeId: String): KMResult { + if (softKeyboardController.switchToInputMethod(imeId)) { + return Success(Unit) + } else { + return KMError.SwitchImeFailed } } - override fun doGlobalAction(action: Int): KMResult<*> { + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun enableIme(imeId: String): KMResult { + val statusCode = softKeyboardController.setInputMethodEnabled(imeId, true) + + if (statusCode == SoftKeyboardController.ENABLE_IME_SUCCESS) { + return Success(Unit) + } else { + return KMError.EnableImeFailed + } + } + + override fun doGlobalAction(action: Int): KMResult { val success = performGlobalAction(action) if (success) { @@ -358,51 +309,44 @@ abstract class BaseAccessibilityService : } } - override fun tapScreen(x: Int, y: Int, inputEventType: InputEventType): KMResult<*> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - val duration = 1L // ms + override fun tapScreen(x: Int, y: Int, inputEventAction: InputEventAction): KMResult<*> { + val duration = 1L // ms - val path = Path().apply { - moveTo(x.toFloat(), y.toFloat()) - } + val path = Path().apply { + moveTo(x.toFloat(), y.toFloat()) + } - val strokeDescription = - when { - inputEventType == InputEventType.DOWN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> - StrokeDescription( - path, - 0, - duration, - true, - ) - - inputEventType == InputEventType.UP && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> - StrokeDescription( - path, - 59999, - duration, - false, - ) - - else -> StrokeDescription(path, 0, duration) - } + val strokeDescription = when (inputEventAction) { + InputEventAction.DOWN -> StrokeDescription( + path, + 0, + duration, + true, + ) + + InputEventAction.UP -> StrokeDescription( + path, + 59999, + duration, + false, + ) + + else -> StrokeDescription(path, 0, duration) + } - strokeDescription.let { - val gestureDescription = GestureDescription.Builder().apply { - addStroke(it) - }.build() + strokeDescription.let { + val gestureDescription = GestureDescription.Builder().apply { + addStroke(it) + }.build() - val success = dispatchGesture(gestureDescription, null, null) + val success = dispatchGesture(gestureDescription, null, null) - return if (success) { - Success(Unit) - } else { - KMError.FailedToDispatchGesture - } + return if (success) { + Success(Unit) + } else { + KMError.FailedToDispatchGesture } } - - return KMError.SdkVersionTooLow(Build.VERSION_CODES.N) } override fun swipeScreen( @@ -412,99 +356,95 @@ abstract class BaseAccessibilityService : yEnd: Int, fingerCount: Int, duration: Int, - inputEventType: InputEventType, + inputEventAction: InputEventAction, ): KMResult<*> { // virtual distance between fingers on multitouch gestures val fingerGestureDistance = 10L - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (fingerCount >= GestureDescription.getMaxStrokeCount()) { - return KMError.GestureStrokeCountTooHigh - } - if (duration >= GestureDescription.getMaxGestureDuration()) { - return KMError.GestureDurationTooHigh - } + if (fingerCount >= GestureDescription.getMaxStrokeCount()) { + return KMError.GestureStrokeCountTooHigh + } + if (duration >= GestureDescription.getMaxGestureDuration()) { + return KMError.GestureDurationTooHigh + } - val pStart = Point(xStart, yStart) - val pEnd = Point(xEnd, yEnd) + val pStart = Point(xStart, yStart) + val pEnd = Point(xEnd, yEnd) - val gestureBuilder = GestureDescription.Builder() + val gestureBuilder = GestureDescription.Builder() + + if (fingerCount == 1) { + val p = Path() + p.moveTo(pStart.x.toFloat(), pStart.y.toFloat()) + p.lineTo(pEnd.x.toFloat(), pEnd.y.toFloat()) + gestureBuilder.addStroke(StrokeDescription(p, 0, duration.toLong())) + } else { + // segments between fingers + val segmentCount = fingerCount - 1 + // the line of the perpendicular line which will be created to place the virtual fingers on it + val perpendicularLineLength = (fingerGestureDistance * fingerCount).toInt() + + // the length of each segment between fingers + val segmentLength = perpendicularLineLength / segmentCount + // perpendicular line of the start swipe point + val perpendicularLineStart = MathUtils.getPerpendicularOfLine( + pStart, + pEnd, + perpendicularLineLength, + ) + // perpendicular line of the end swipe point + val perpendicularLineEnd = MathUtils.getPerpendicularOfLine( + pEnd, + pStart, + perpendicularLineLength, + true, + ) + + // this is the angle between start and end point to rotate all virtual fingers on the perpendicular lines in the same direction + val angle = + MathUtils.angleBetweenPoints(Point(xStart, yStart), Point(xEnd, yEnd)) - 90 + + // create the virtual fingers + for (index in 0..segmentCount) { + // offset of each finger + val fingerOffsetLength = index * segmentLength * 2 + // move the coordinates of the current virtual finger on the perpendicular line for the start coordinates + val startFingerCoordinateWithOffset = + MathUtils.movePointByDistanceAndAngle( + perpendicularLineStart.start, + fingerOffsetLength, + angle, + ) + // move the coordinates of the current virtual finger on the perpendicular line for the end coordinates + val endFingerCoordinateWithOffset = + MathUtils.movePointByDistanceAndAngle( + perpendicularLineEnd.start, + fingerOffsetLength, + angle, + ) - if (fingerCount == 1) { + // create a path for each finger, move the the coordinates on the perpendicular line and draw it to the end coordinates of the perpendicular line of the end swipe point val p = Path() - p.moveTo(pStart.x.toFloat(), pStart.y.toFloat()) - p.lineTo(pEnd.x.toFloat(), pEnd.y.toFloat()) - gestureBuilder.addStroke(StrokeDescription(p, 0, duration.toLong())) - } else { - // segments between fingers - val segmentCount = fingerCount - 1 - // the line of the perpendicular line which will be created to place the virtual fingers on it - val perpendicularLineLength = (fingerGestureDistance * fingerCount).toInt() - - // the length of each segment between fingers - val segmentLength = perpendicularLineLength / segmentCount - // perpendicular line of the start swipe point - val perpendicularLineStart = MathUtils.getPerpendicularOfLine( - pStart, - pEnd, - perpendicularLineLength, + p.moveTo( + startFingerCoordinateWithOffset.x.toFloat(), + startFingerCoordinateWithOffset.y.toFloat(), ) - // perpendicular line of the end swipe point - val perpendicularLineEnd = MathUtils.getPerpendicularOfLine( - pEnd, - pStart, - perpendicularLineLength, - true, + p.lineTo( + endFingerCoordinateWithOffset.x.toFloat(), + endFingerCoordinateWithOffset.y.toFloat(), ) - // this is the angle between start and end point to rotate all virtual fingers on the perpendicular lines in the same direction - val angle = - MathUtils.angleBetweenPoints(Point(xStart, yStart), Point(xEnd, yEnd)) - 90 - - // create the virtual fingers - for (index in 0..segmentCount) { - // offset of each finger - val fingerOffsetLength = index * segmentLength * 2 - // move the coordinates of the current virtual finger on the perpendicular line for the start coordinates - val startFingerCoordinateWithOffset = - MathUtils.movePointByDistanceAndAngle( - perpendicularLineStart.start, - fingerOffsetLength, - angle, - ) - // move the coordinates of the current virtual finger on the perpendicular line for the end coordinates - val endFingerCoordinateWithOffset = - MathUtils.movePointByDistanceAndAngle( - perpendicularLineEnd.start, - fingerOffsetLength, - angle, - ) - - // create a path for each finger, move the the coordinates on the perpendicular line and draw it to the end coordinates of the perpendicular line of the end swipe point - val p = Path() - p.moveTo( - startFingerCoordinateWithOffset.x.toFloat(), - startFingerCoordinateWithOffset.y.toFloat(), - ) - p.lineTo( - endFingerCoordinateWithOffset.x.toFloat(), - endFingerCoordinateWithOffset.y.toFloat(), - ) - - gestureBuilder.addStroke(StrokeDescription(p, 0, duration.toLong())) - } + gestureBuilder.addStroke(StrokeDescription(p, 0, duration.toLong())) } + } - val success = dispatchGesture(gestureBuilder.build(), null, null) + val success = dispatchGesture(gestureBuilder.build(), null, null) - return if (success) { - Success(Unit) - } else { - KMError.FailedToDispatchGesture - } + return if (success) { + Success(Unit) + } else { + KMError.FailedToDispatchGesture } - - return KMError.SdkVersionTooLow(Build.VERSION_CODES.N) } override fun pinchScreen( @@ -514,7 +454,7 @@ abstract class BaseAccessibilityService : pinchType: PinchScreenType, fingerCount: Int, duration: Int, - inputEventType: InputEventType, + inputEventAction: InputEventAction, ): KMResult<*> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (fingerCount >= GestureDescription.getMaxStrokeCount()) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityServiceController.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityServiceController.kt index 121afbadaa..065224ad53 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityServiceController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityServiceController.kt @@ -4,24 +4,28 @@ import android.accessibilityservice.AccessibilityServiceInfo import android.content.res.Configuration import android.os.Build import android.view.KeyEvent +import android.view.MotionEvent import android.view.accessibility.AccessibilityEvent -import android.view.accessibility.AccessibilityNodeInfo +import android.view.inputmethod.EditorInfo +import androidx.annotation.RequiresApi import androidx.lifecycle.lifecycleScope +import io.github.sds100.keymapper.api.IKeyEventRelayServiceCallback import io.github.sds100.keymapper.base.actions.ActionData import io.github.sds100.keymapper.base.actions.PerformActionsUseCaseImpl import io.github.sds100.keymapper.base.actions.TestActionEvent import io.github.sds100.keymapper.base.constraints.DetectConstraintsUseCaseImpl +import io.github.sds100.keymapper.base.detection.DetectKeyMapsUseCaseImpl +import io.github.sds100.keymapper.base.detection.KeyMapDetectionController +import io.github.sds100.keymapper.base.detection.TriggerKeyMapFromOtherAppsController +import io.github.sds100.keymapper.base.input.InputEventDetectionSource +import io.github.sds100.keymapper.base.input.InputEventHub import io.github.sds100.keymapper.base.keymaps.FingerprintGesturesSupportedUseCase import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase import io.github.sds100.keymapper.base.keymaps.TriggerKeyMapEvent -import io.github.sds100.keymapper.base.keymaps.detection.DetectKeyMapsUseCaseImpl -import io.github.sds100.keymapper.base.keymaps.detection.DetectScreenOffKeyEventsController -import io.github.sds100.keymapper.base.keymaps.detection.DpadMotionEventTracker -import io.github.sds100.keymapper.base.keymaps.detection.KeyMapController -import io.github.sds100.keymapper.base.keymaps.detection.TriggerKeyMapFromOtherAppsController -import io.github.sds100.keymapper.base.reroutekeyevents.RerouteKeyEventsController -import io.github.sds100.keymapper.base.trigger.KeyEventDetectionSource -import io.github.sds100.keymapper.base.trigger.RecordTriggerEvent +import io.github.sds100.keymapper.base.promode.SystemBridgeSetupAssistantController +import io.github.sds100.keymapper.base.system.inputmethod.AutoSwitchImeController +import io.github.sds100.keymapper.base.trigger.RecordTriggerController +import io.github.sds100.keymapper.common.utils.Constants import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.common.utils.hasFlag import io.github.sds100.keymapper.common.utils.minusFlag @@ -30,14 +34,9 @@ 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.accessibility.AccessibilityServiceEvent -import io.github.sds100.keymapper.system.devices.DevicesAdapter -import io.github.sds100.keymapper.system.inputevents.InputEventUtils -import io.github.sds100.keymapper.system.inputevents.MyKeyEvent -import io.github.sds100.keymapper.system.inputevents.MyMotionEvent -import io.github.sds100.keymapper.system.root.SuAdapter -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import io.github.sds100.keymapper.system.inputevents.KMGamePadEvent +import io.github.sds100.keymapper.system.inputevents.KMKeyEvent +import io.github.sds100.keymapper.system.inputmethod.KeyEventRelayServiceWrapper import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -53,51 +52,49 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import timber.log.Timber abstract class BaseAccessibilityServiceController( private val service: BaseAccessibilityService, - private val rerouteKeyEventsControllerFactory: RerouteKeyEventsController.Factory, private val accessibilityNodeRecorderFactory: AccessibilityNodeRecorder.Factory, private val performActionsUseCaseFactory: PerformActionsUseCaseImpl.Factory, private val detectKeyMapsUseCaseFactory: DetectKeyMapsUseCaseImpl.Factory, private val detectConstraintsUseCaseFactory: DetectConstraintsUseCaseImpl.Factory, private val fingerprintGesturesSupported: FingerprintGesturesSupportedUseCase, private val pauseKeyMapsUseCase: PauseKeyMapsUseCase, - private val devicesAdapter: DevicesAdapter, - private val suAdapter: SuAdapter, private val settingsRepository: PreferenceRepository, + private val keyEventRelayServiceWrapper: KeyEventRelayServiceWrapper, + private val inputEventHub: InputEventHub, + private val recordTriggerController: RecordTriggerController, + private val setupAssistantControllerFactory: SystemBridgeSetupAssistantController.Factory, + private val autoSwitchImeControllerFactory: AutoSwitchImeController.Factory, ) { companion object { - - /** - * How long should the accessibility service record a trigger in seconds. - */ - private const val RECORD_TRIGGER_TIMER_LENGTH = 5 private const val DEFAULT_NOTIFICATION_TIMEOUT = 200L + private const val CALLBACK_ID_ACCESSIBILITY_SERVICE = "accessibility_service" } private val performActionsUseCase = performActionsUseCaseFactory.create( accessibilityService = service, - imeInputEventInjector = service.imeInputEventInjector, + coroutineScope = service.lifecycleScope, ) private val detectKeyMapsUseCase = detectKeyMapsUseCaseFactory.create( accessibilityService = service, coroutineScope = service.lifecycleScope, - imeInputEventInjector = service.imeInputEventInjector, ) val detectConstraintsUseCase = detectConstraintsUseCaseFactory.create(service) - val keyMapController = KeyMapController( + val keyMapDetectionController = KeyMapDetectionController( service.lifecycleScope, detectKeyMapsUseCase, performActionsUseCase, detectConstraintsUseCase, + inputEventHub, + pauseKeyMapsUseCase, + recordTriggerController, ) val triggerKeyMapFromOtherAppsController = TriggerKeyMapFromOtherAppsController( @@ -107,40 +104,27 @@ abstract class BaseAccessibilityServiceController( detectConstraintsUseCase, ) - val rerouteKeyEventsController = rerouteKeyEventsControllerFactory.create( - service.lifecycleScope, - service.imeInputEventInjector, - ) - val accessibilityNodeRecorder = accessibilityNodeRecorderFactory.create(service) - private val detectScreenOffKeyEventsController = DetectScreenOffKeyEventsController( - suAdapter, - devicesAdapter, - ) { event -> - if (!isPaused.value) { - withContext(Dispatchers.Main.immediate) { - keyMapController.onKeyEvent(event) - } + private val setupAssistantController: SystemBridgeSetupAssistantController? = + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + setupAssistantControllerFactory.create(service.lifecycleScope, service) + } else { + null } - } - private var recordingTriggerJob: Job? = null - - private val recordingTrigger: Boolean - get() = recordingTriggerJob != null && recordingTriggerJob?.isActive == true - - private val recordDpadMotionEventTracker: DpadMotionEventTracker = - DpadMotionEventTracker() + private val autoSwitchImeController: AutoSwitchImeController? by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + autoSwitchImeControllerFactory.create(service, service.lifecycleScope) + } else { + null + } + } val isPaused: StateFlow = pauseKeyMapsUseCase.isPaused .stateIn(service.lifecycleScope, SharingStarted.Eagerly, false) - private val screenOffTriggersEnabled: StateFlow = - detectKeyMapsUseCase.detectScreenOffTriggers - .stateIn(service.lifecycleScope, SharingStarted.Eagerly, false) - private val changeImeOnInputFocusFlow: StateFlow = settingsRepository .get(Keys.changeImeOnInputFocus) @@ -160,10 +144,7 @@ abstract class BaseAccessibilityServiceController( // detect when to show/hide overlays. .withFlag(AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS) .withFlag(AccessibilityServiceInfo.FLAG_INPUT_METHOD_EDITOR) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - flags = flags.withFlag(AccessibilityServiceInfo.FLAG_ENABLE_ACCESSIBILITY_VOLUME) - } + .withFlag(AccessibilityServiceInfo.FLAG_ENABLE_ACCESSIBILITY_VOLUME) return@lazy flags } @@ -190,9 +171,28 @@ abstract class BaseAccessibilityServiceController( private val inputEvents: SharedFlow = service.accessibilityServiceAdapter.eventsToService + private val outputEvents: MutableSharedFlow = service.accessibilityServiceAdapter.eventReceiver + private val relayServiceCallback: IKeyEventRelayServiceCallback = + object : IKeyEventRelayServiceCallback.Stub() { + override fun onKeyEvent(event: KeyEvent?): Boolean { + event ?: return false + + val kmKeyEvent = KMKeyEvent.fromAndroidKeyEvent(event) ?: return false + return onKeyEventFromIme(kmKeyEvent) + } + + override fun onMotionEvent(event: MotionEvent?): Boolean { + event ?: return false + + val gamePadEvent = KMGamePadEvent.fromMotionEvent(event) + ?: return false + return onMotionEventFromIme(gamePadEvent) + } + } + init { serviceFlags.onEach { flags -> // check that it isn't null because this can only be called once the service is bound @@ -222,34 +222,21 @@ abstract class BaseAccessibilityServiceController( } }.launchIn(service.lifecycleScope) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - combine( - detectKeyMapsUseCase.requestFingerprintGestureDetection, - isPaused, - ) { request, isPaused -> - if (request && !isPaused) { - requestFingerprintGestureDetection() - } else { - denyFingerprintGestureDetection() - } - }.launchIn(service.lifecycleScope) - } + combine( + detectKeyMapsUseCase.requestFingerprintGestureDetection, + isPaused, + ) { request, isPaused -> + if (request && !isPaused) { + requestFingerprintGestureDetection() + } else { + denyFingerprintGestureDetection() + } + }.launchIn(service.lifecycleScope) pauseKeyMapsUseCase.isPaused.distinctUntilChanged().onEach { - keyMapController.reset() triggerKeyMapFromOtherAppsController.reset() }.launchIn(service.lifecycleScope) - detectKeyMapsUseCase.isScreenOn.onEach { isScreenOn -> - if (!isScreenOn) { - if (screenOffTriggersEnabled.value) { - detectScreenOffKeyEventsController.startListening(service.lifecycleScope) - } - } else { - detectScreenOffKeyEventsController.stopListening() - } - }.launchIn(service.lifecycleScope) - inputEvents.onEach { onEventFromUi(it) }.launchIn(service.lifecycleScope) @@ -274,7 +261,8 @@ abstract class BaseAccessibilityServiceController( enableAccessibilityVolumeStream = false } else { enableAccessibilityVolumeStream = keyMaps.any { model -> - model.keyMap.isEnabled && model.keyMap.actionList.any { it.data is ActionData.Sound } + model.keyMap.isEnabled && + model.keyMap.actionList.any { it.data is ActionData.Sound } } } @@ -291,8 +279,13 @@ abstract class BaseAccessibilityServiceController( } } - val imeInputFocusEvents = - AccessibilityEvent.TYPE_VIEW_FOCUSED or AccessibilityEvent.TYPE_VIEW_CLICKED + // The accessibility event is only used on older than SDK 33. On newer versions the + // accessibility input method API is used. + val imeInputStartedEvents = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + AccessibilityEvent.TYPE_WINDOWS_CHANGED + } else { + 0 + } val recordNodeEvents = AccessibilityEvent.TYPE_VIEW_FOCUSED or AccessibilityEvent.TYPE_VIEW_CLICKED @@ -306,12 +299,14 @@ abstract class BaseAccessibilityServiceController( serviceEventTypes.update { eventTypes -> var newEventTypes = eventTypes - if (!changeImeOnInputFocus && recordState == RecordAccessibilityNodeState.Idle) { + if (!changeImeOnInputFocus && + recordState == RecordAccessibilityNodeState.Idle + ) { newEventTypes = - newEventTypes and (imeInputFocusEvents or recordNodeEvents).inv() + newEventTypes and (imeInputStartedEvents or recordNodeEvents).inv() } else { if (changeImeOnInputFocus) { - newEventTypes = newEventTypes or imeInputFocusEvents + newEventTypes = newEventTypes or imeInputStartedEvents } if (recordState is RecordAccessibilityNodeState.CountingDown) { @@ -331,6 +326,10 @@ abstract class BaseAccessibilityServiceController( } }.collect() } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + autoSwitchImeController?.init() + } } open fun onServiceConnected() { @@ -340,113 +339,55 @@ abstract class BaseAccessibilityServiceController( service.notificationTimeout = serviceNotificationTimeout.value // check if fingerprint gestures are supported - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val isFingerprintGestureRequested = - serviceFlags.value.hasFlag(AccessibilityServiceInfo.FLAG_REQUEST_FINGERPRINT_GESTURES) - requestFingerprintGestureDetection() - - /* Don't update whether fingerprint gesture detection is supported if it has - * been supported at some point. Just in case the fingerprint reader is being - * used while this is called. */ - if (fingerprintGesturesSupported.isSupported.firstBlocking() != true) { - fingerprintGesturesSupported.setSupported( - service.isFingerprintGestureDetectionAvailable, - ) - } + val isFingerprintGestureRequested = + serviceFlags.value.hasFlag(AccessibilityServiceInfo.FLAG_REQUEST_FINGERPRINT_GESTURES) + requestFingerprintGestureDetection() + + /* Don't update whether fingerprint gesture detection is supported if it has + * been supported at some point. Just in case the fingerprint reader is being + * used while this is called. */ + if (fingerprintGesturesSupported.isSupported.firstBlocking() != true) { + fingerprintGesturesSupported.setSupported( + service.isFingerprintGestureDetectionAvailable, + ) + } - if (!isFingerprintGestureRequested) { - denyFingerprintGestureDetection() - } + if (!isFingerprintGestureRequested) { + denyFingerprintGestureDetection() } - } - open fun onDestroy() { - accessibilityNodeRecorder.teardown() - } + keyEventRelayServiceWrapper.registerClient( + CALLBACK_ID_ACCESSIBILITY_SERVICE, + relayServiceCallback, + ) - open fun onConfigurationChanged(newConfig: Configuration) { + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + setupAssistantController?.onServiceConnected() + } } - /** - * Returns an MyKeyEvent which is either the same or more unique - */ - private fun getUniqueEvent(event: MyKeyEvent): MyKeyEvent { - // Guard to ignore processing when not applicable - if (event.keyCode != KeyEvent.KEYCODE_UNKNOWN) return event + open fun onDestroy() { + keyMapDetectionController.teardown() + keyEventRelayServiceWrapper.unregisterClient(CALLBACK_ID_ACCESSIBILITY_SERVICE) + accessibilityNodeRecorder.teardown() - // Don't offset negative values - val scanCodeOffset: Int = if (event.scanCode >= 0) { - InputEventUtils.KEYCODE_TO_SCANCODE_OFFSET - } else { - 0 + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + setupAssistantController?.teardown() } + } - val eventProxy = event.copy( - // Fallback to scanCode when keyCode is unknown as it's typically more unique - // Add offset to go past possible keyCode values - keyCode = event.scanCode + scanCodeOffset, - ) - - return eventProxy + open fun onConfigurationChanged(newConfig: Configuration) { } fun onKeyEvent( - event: MyKeyEvent, - detectionSource: KeyEventDetectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, + event: KMKeyEvent, + detectionSource: InputEventDetectionSource = + InputEventDetectionSource.ACCESSIBILITY_SERVICE, ): Boolean { - val detailedLogInfo = event.toString() - - if (recordingTrigger) { - if (event.action == KeyEvent.ACTION_DOWN) { - Timber.d("Recorded key ${KeyEvent.keyCodeToString(event.keyCode)}, $detailedLogInfo") - - val uniqueEvent: MyKeyEvent = getUniqueEvent(event) - - service.lifecycleScope.launch { - outputEvents.emit( - RecordTriggerEvent.RecordedTriggerKey( - uniqueEvent.keyCode, - uniqueEvent.device, - detectionSource, - ), - ) - } - } - - return true - } - - if (isPaused.value) { - when (event.action) { - KeyEvent.ACTION_DOWN -> Timber.d("Down ${KeyEvent.keyCodeToString(event.keyCode)} - not filtering because paused, $detailedLogInfo") - KeyEvent.ACTION_UP -> Timber.d("Up ${KeyEvent.keyCodeToString(event.keyCode)} - not filtering because paused, $detailedLogInfo") - } - } else { - try { - var consume: Boolean - val uniqueEvent: MyKeyEvent = getUniqueEvent(event) - - consume = keyMapController.onKeyEvent(uniqueEvent) - - if (!consume) { - consume = rerouteKeyEventsController.onKeyEvent(uniqueEvent) - } - - when (uniqueEvent.action) { - KeyEvent.ACTION_DOWN -> Timber.d("Down ${KeyEvent.keyCodeToString(uniqueEvent.keyCode)} - consumed: $consume, $detailedLogInfo") - KeyEvent.ACTION_UP -> Timber.d("Up ${KeyEvent.keyCodeToString(uniqueEvent.keyCode)} - consumed: $consume, $detailedLogInfo") - } - - return consume - } catch (e: Exception) { - Timber.e(e) - } - } - - return false + return inputEventHub.onInputEvent(event, detectionSource) } - fun onKeyEventFromIme(event: MyKeyEvent): Boolean { + fun onKeyEventFromIme(event: KMKeyEvent): Boolean { /* Issue #850 If a volume key is sent while the phone is ringing or in a call @@ -454,86 +395,52 @@ abstract class BaseAccessibilityServiceController( is sent. This is a restriction in Android. So send a fake DOWN key event as well before returning the UP key event. */ - if (event.action == KeyEvent.ACTION_UP && (event.keyCode == KeyEvent.KEYCODE_VOLUME_UP || event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)) { - onKeyEvent( + if (event.action == KeyEvent.ACTION_UP && + ( + event.keyCode == KeyEvent.KEYCODE_VOLUME_UP || + event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN + ) + ) { + inputEventHub.onInputEvent( event.copy(action = KeyEvent.ACTION_DOWN), - detectionSource = KeyEventDetectionSource.INPUT_METHOD, + detectionSource = InputEventDetectionSource.INPUT_METHOD, ) } - return onKeyEvent( + return inputEventHub.onInputEvent(event, InputEventDetectionSource.INPUT_METHOD) + } + + fun onMotionEventFromIme(event: KMGamePadEvent): Boolean { + return inputEventHub.onInputEvent( event, - detectionSource = KeyEventDetectionSource.INPUT_METHOD, + detectionSource = InputEventDetectionSource.INPUT_METHOD, ) } - fun onMotionEventFromIme(event: MyMotionEvent): Boolean { - if (isPaused.value) { - return false - } - - if (recordingTrigger) { - val dpadKeyEvents = recordDpadMotionEventTracker.convertMotionEvent(event) - - var consume = false - - for (keyEvent in dpadKeyEvents) { - if (keyEvent.action == KeyEvent.ACTION_DOWN) { - Timber.d("Recorded motion event ${KeyEvent.keyCodeToString(keyEvent.keyCode)}") - - service.lifecycleScope.launch { - outputEvents.emit( - RecordTriggerEvent.RecordedTriggerKey( - keyEvent.keyCode, - keyEvent.device, - KeyEventDetectionSource.INPUT_METHOD, - ), - ) - } - } - - // Consume the key event if it is an DOWN or UP. - consume = true - } + open fun onAccessibilityEvent(event: AccessibilityEvent) { + accessibilityNodeRecorder.onAccessibilityEvent(event) - if (consume) { - return true - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + autoSwitchImeController?.onAccessibilityEvent(event) } - try { - val consume = keyMapController.onMotionEvent(event) - - return consume - } catch (e: Exception) { - Timber.e(e) - return false + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + setupAssistantController?.onAccessibilityEvent(event) } } - open fun onAccessibilityEvent(event: AccessibilityEvent) { - accessibilityNodeRecorder.onAccessibilityEvent(event) - - if (changeImeOnInputFocusFlow.value) { - val focussedNode = - service.findFocussedNode(AccessibilityNodeInfo.FOCUS_INPUT) + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + fun onStartInput(attribute: EditorInfo, restarting: Boolean) { + autoSwitchImeController?.onStartInput(attribute, restarting) + } - if (focussedNode?.isEditable == true && focussedNode.isFocused) { - Timber.d("Got input focus") - service.lifecycleScope.launch { - outputEvents.emit(AccessibilityServiceEvent.OnInputFocusChange(isFocussed = true)) - } - } else { - Timber.d("Lost input focus") - service.lifecycleScope.launch { - outputEvents.emit(AccessibilityServiceEvent.OnInputFocusChange(isFocussed = false)) - } - } - } + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + fun onFinishInput() { + autoSwitchImeController?.onFinishInput() } fun onFingerprintGesture(type: FingerprintGestureType) { - keyMapController.onFingerprintGesture(type) + keyMapDetectionController.onFingerprintGesture(type) } private fun triggerKeyMapFromIntent(uid: String) { @@ -542,27 +449,8 @@ abstract class BaseAccessibilityServiceController( open fun onEventFromUi(event: AccessibilityServiceEvent) { Timber.d("Service received event from UI: $event") - when (event) { - is RecordTriggerEvent.StartRecordingTrigger -> - if (!recordingTrigger) { - recordDpadMotionEventTracker.reset() - recordingTriggerJob = recordTriggerJob() - } - - is RecordTriggerEvent.StopRecordingTrigger -> { - val wasRecordingTrigger = recordingTrigger - - recordingTriggerJob?.cancel() - recordingTriggerJob = null - recordDpadMotionEventTracker.reset() - - if (wasRecordingTrigger) { - service.lifecycleScope.launch { - outputEvents.emit(RecordTriggerEvent.OnStoppedRecordingTrigger) - } - } - } + when (event) { is TestActionEvent -> service.lifecycleScope.launch { performActionsUseCase.perform( event.action, @@ -575,15 +463,20 @@ abstract class BaseAccessibilityServiceController( is AccessibilityServiceEvent.HideKeyboard -> service.hideKeyboard() is AccessibilityServiceEvent.ShowKeyboard -> service.showKeyboard() - is AccessibilityServiceEvent.ChangeIme -> service.switchIme(event.imeId) - is AccessibilityServiceEvent.DisableService -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + is AccessibilityServiceEvent.ChangeIme -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + service.switchIme(event.imeId) + } + + is AccessibilityServiceEvent.DisableService -> service.disableSelf() - } is TriggerKeyMapEvent -> triggerKeyMapFromIntent(event.uid) - is AccessibilityServiceEvent.EnableInputMethod -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - service.setInputMethodEnabled(event.imeId, true) + is AccessibilityServiceEvent.EnableInputMethod -> if (Build.VERSION.SDK_INT >= + Build.VERSION_CODES.TIRAMISU + ) { + service.enableIme(event.imeId) } is RecordAccessibilityNodeEvent.StartRecordingNodes -> { @@ -594,54 +487,43 @@ abstract class BaseAccessibilityServiceController( accessibilityNodeRecorder.stopRecording() } - else -> Unit - } - } - - private fun recordTriggerJob() = service.lifecycleScope.launch { - repeat(RECORD_TRIGGER_TIMER_LENGTH) { iteration -> - if (isActive) { - val timeLeft = RECORD_TRIGGER_TIMER_LENGTH - iteration - outputEvents.emit(RecordTriggerEvent.OnIncrementRecordTriggerTimer(timeLeft)) + is AccessibilityServiceEvent.GlobalAction -> { + service.doGlobalAction(event.action) + } - delay(1000) + is AccessibilityServiceEvent.OnKeyMapperImeStartInput -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + autoSwitchImeController?.onStartInput(event.attribute, event.restarting) + } } - } - outputEvents.emit(RecordTriggerEvent.OnStoppedRecordingTrigger) + else -> Unit + } } private fun requestFingerprintGestureDetection() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Timber.d("Accessibility service: request fingerprint gesture detection") - serviceFlags.value = - serviceFlags.value.withFlag(AccessibilityServiceInfo.FLAG_REQUEST_FINGERPRINT_GESTURES) - } + Timber.d("Accessibility service: request fingerprint gesture detection") + serviceFlags.value = + serviceFlags.value.withFlag(AccessibilityServiceInfo.FLAG_REQUEST_FINGERPRINT_GESTURES) } private fun denyFingerprintGestureDetection() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Timber.d("Accessibility service: deny fingerprint gesture detection") - serviceFlags.value = - serviceFlags.value.minusFlag(AccessibilityServiceInfo.FLAG_REQUEST_FINGERPRINT_GESTURES) - } + Timber.d("Accessibility service: deny fingerprint gesture detection") + serviceFlags.value = + serviceFlags.value.minusFlag(AccessibilityServiceInfo.FLAG_REQUEST_FINGERPRINT_GESTURES) } private fun enableAccessibilityVolumeStream() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - serviceFeedbackType.value = - serviceFeedbackType.value.withFlag(AccessibilityServiceInfo.FEEDBACK_AUDIBLE) - serviceFlags.value = - serviceFlags.value.withFlag(AccessibilityServiceInfo.FLAG_ENABLE_ACCESSIBILITY_VOLUME) - } + serviceFeedbackType.value = + serviceFeedbackType.value.withFlag(AccessibilityServiceInfo.FEEDBACK_AUDIBLE) + serviceFlags.value = + serviceFlags.value.withFlag(AccessibilityServiceInfo.FLAG_ENABLE_ACCESSIBILITY_VOLUME) } private fun disableAccessibilityVolumeStream() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - serviceFeedbackType.value = - serviceFeedbackType.value.minusFlag(AccessibilityServiceInfo.FEEDBACK_AUDIBLE) - serviceFlags.value = - serviceFlags.value.minusFlag(AccessibilityServiceInfo.FLAG_ENABLE_ACCESSIBILITY_VOLUME) - } + serviceFeedbackType.value = + serviceFeedbackType.value.minusFlag(AccessibilityServiceInfo.FEEDBACK_AUDIBLE) + serviceFlags.value = + serviceFlags.value.minusFlag(AccessibilityServiceInfo.FLAG_ENABLE_ACCESSIBILITY_VOLUME) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/ControlAccessibilityServiceUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/ControlAccessibilityServiceUseCase.kt index ae32bebf39..b9e43fc1e2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/ControlAccessibilityServiceUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/ControlAccessibilityServiceUseCase.kt @@ -1,17 +1,20 @@ package io.github.sds100.keymapper.base.system.accessibility +import android.os.Build import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState +import io.github.sds100.keymapper.system.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter -import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow @Singleton class ControlAccessibilityServiceUseCaseImpl @Inject constructor( private val adapter: AccessibilityServiceAdapter, private val permissionAdapter: PermissionAdapter, + private val packageManagerAdapter: PackageManagerAdapter, ) : ControlAccessibilityServiceUseCase { override val serviceState: Flow = adapter.state @@ -31,12 +34,26 @@ class ControlAccessibilityServiceUseCaseImpl @Inject constructor( adapter.stop() } + override fun acknowledgeCrashed() { + adapter.acknowledgeCrashed() + } + /** * @return whether the user must manually start/stop the service. */ override fun isUserInteractionRequired(): Boolean { return !permissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS) } + + override fun isRestrictedSetting(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // If the app is installed outside of Google Play then Android disables + // the accessibility service until restricted settings are enabled. + packageManagerAdapter.getInstallSourcePackageName() != "com.android.vending" + } else { + false + } + } } interface ControlAccessibilityServiceUseCase { @@ -44,5 +61,8 @@ interface ControlAccessibilityServiceUseCase { fun startService(): Boolean fun restartService(): Boolean fun stopService() + fun acknowledgeCrashed() + fun isUserInteractionRequired(): Boolean + fun isRestrictedSetting(): Boolean } 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 c62a1c8b05..470900c561 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 @@ -1,16 +1,15 @@ package io.github.sds100.keymapper.base.system.accessibility -import android.os.Build -import androidx.annotation.RequiresApi -import io.github.sds100.keymapper.common.utils.InputEventType +import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface +import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.PinchScreenType import kotlinx.coroutines.flow.Flow -interface IAccessibilityService { +interface IAccessibilityService : SwitchImeInterface { fun doGlobalAction(action: Int): KMResult<*> - fun tapScreen(x: Int, y: Int, inputEventType: InputEventType): KMResult<*> + fun tapScreen(x: Int, y: Int, inputEventAction: InputEventAction): KMResult<*> fun swipeScreen( xStart: Int, @@ -19,7 +18,7 @@ interface IAccessibilityService { yEnd: Int, fingerCount: Int, duration: Int, - inputEventType: InputEventType, + inputEventAction: InputEventAction, ): KMResult<*> fun pinchScreen( @@ -29,7 +28,7 @@ interface IAccessibilityService { pinchType: PinchScreenType, fingerCount: Int, duration: Int, - inputEventType: InputEventType, + inputEventAction: InputEventAction, ): KMResult<*> val isFingerprintGestureDetectionAvailable: Boolean @@ -46,16 +45,12 @@ interface IAccessibilityService { val rootNode: AccessibilityNodeModel? val activeWindowPackage: Flow + val activeWindowPackageNames: List - @RequiresApi(Build.VERSION_CODES.TIRAMISU) - fun setInputMethodEnabled(imeId: String, enabled: Boolean) fun hideKeyboard() fun showKeyboard() val isKeyboardHidden: Flow - fun switchIme(imeId: String) - - @RequiresApi(Build.VERSION_CODES.N) fun disableSelf() fun findFocussedNode(focus: Int): AccessibilityNodeModel? diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/RecordAccessibilityNodeEvent.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/RecordAccessibilityNodeEvent.kt index 2665f76624..1e12f948d1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/RecordAccessibilityNodeEvent.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/RecordAccessibilityNodeEvent.kt @@ -11,5 +11,6 @@ sealed class RecordAccessibilityNodeEvent : AccessibilityServiceEvent() { data object StopRecordingNodes : RecordAccessibilityNodeEvent() @Serializable - data class OnRecordNodeStateChanged(val state: RecordAccessibilityNodeState) : RecordAccessibilityNodeEvent() + data class OnRecordNodeStateChanged(val state: RecordAccessibilityNodeState) : + RecordAccessibilityNodeEvent() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseActivityViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseActivityViewModel.kt index 579bb48d4b..eb2d5096e1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseActivityViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseActivityViewModel.kt @@ -8,6 +8,7 @@ import io.github.sds100.keymapper.base.utils.ui.IconInfo import io.github.sds100.keymapper.base.utils.ui.TintType import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.valueOrNull +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -17,12 +18,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import javax.inject.Inject @HiltViewModel -class ChooseActivityViewModel @Inject constructor( - private val useCase: DisplayAppsUseCase, -) : ViewModel() { +class ChooseActivityViewModel @Inject constructor(private val useCase: DisplayAppsUseCase) : + ViewModel() { val searchQuery = MutableStateFlow(null) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppShortcutViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppShortcutViewModel.kt index c018268c2c..c8a81b428f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppShortcutViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppShortcutViewModel.kt @@ -14,6 +14,8 @@ import io.github.sds100.keymapper.base.utils.ui.showDialog import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.mapData import io.github.sds100.keymapper.common.utils.valueOrNull +import java.util.Locale +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -24,8 +26,6 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import java.util.Locale -import javax.inject.Inject @HiltViewModel class ChooseAppShortcutViewModel @Inject constructor( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppViewModel.kt index 4339359584..e38ad421d1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppViewModel.kt @@ -11,6 +11,8 @@ import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.mapData import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.system.apps.PackageInfo +import java.util.Locale +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -24,13 +26,10 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch -import java.util.Locale -import javax.inject.Inject @HiltViewModel -class ChooseAppViewModel @Inject constructor( - private val useCase: DisplayAppsUseCase, -) : ViewModel() { +class ChooseAppViewModel @Inject constructor(private val useCase: DisplayAppsUseCase) : + ViewModel() { val searchQuery = MutableStateFlow(null) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/DisplayAppShortcutsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/DisplayAppShortcutsUseCase.kt index ed74512319..ff8e608343 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/DisplayAppShortcutsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/DisplayAppShortcutsUseCase.kt @@ -5,8 +5,8 @@ import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.apps.AppShortcutAdapter import io.github.sds100.keymapper.system.apps.AppShortcutInfo -import kotlinx.coroutines.flow.Flow import javax.inject.Inject +import kotlinx.coroutines.flow.Flow class DisplayAppShortcutsUseCaseImpl @Inject constructor( private val appShortcutAdapter: AppShortcutAdapter, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/DisplayAppsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/DisplayAppsUseCase.kt index a0d651f115..5a5278015e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/DisplayAppsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/DisplayAppsUseCase.kt @@ -5,21 +5,23 @@ import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.apps.PackageInfo import io.github.sds100.keymapper.system.apps.PackageManagerAdapter -import kotlinx.coroutines.flow.Flow import javax.inject.Inject +import kotlinx.coroutines.flow.Flow -class DisplayAppsUseCaseImpl @Inject constructor( - private val adapter: PackageManagerAdapter, -) : DisplayAppsUseCase { +class DisplayAppsUseCaseImpl @Inject constructor(private val adapter: PackageManagerAdapter) : + DisplayAppsUseCase { override val installedPackages: Flow>> = adapter.installedPackages override fun getAppName(packageName: String): KMResult = adapter.getAppName(packageName) - override fun getAppIcon(packageName: String): KMResult = adapter.getAppIcon(packageName) + override fun getAppIcon(packageName: String): KMResult = + adapter.getAppIcon(packageName) - override fun getActivityLabel(packageName: String, activityClass: String): KMResult = adapter.getActivityLabel(packageName, activityClass) + override fun getActivityLabel(packageName: String, activityClass: String): KMResult = + adapter.getActivityLabel(packageName, activityClass) - override fun getActivityIcon(packageName: String, activityClass: String): KMResult = adapter.getActivityIcon(packageName, activityClass) + override fun getActivityIcon(packageName: String, activityClass: String): KMResult = + adapter.getActivityIcon(packageName, activityClass) } interface DisplayAppsUseCase { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/bluetooth/ChooseBluetoothDeviceFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/bluetooth/ChooseBluetoothDeviceFragment.kt index 6cf0b9b199..f02ed5cb3c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/bluetooth/ChooseBluetoothDeviceFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/bluetooth/ChooseBluetoothDeviceFragment.kt @@ -51,10 +51,7 @@ class ChooseBluetoothDeviceFragment : SimpleRecyclerViewFragment() { } } - override fun populateList( - recyclerView: EpoxyRecyclerView, - listItems: List, - ) { + override fun populateList(recyclerView: EpoxyRecyclerView, listItems: List) { recyclerView.withModels { listItems.forEach { listItem -> if (listItem is SimpleListItemOld) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/bluetooth/ChooseBluetoothDeviceUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/bluetooth/ChooseBluetoothDeviceUseCase.kt index ada7e5d7cc..402da3f219 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/bluetooth/ChooseBluetoothDeviceUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/bluetooth/ChooseBluetoothDeviceUseCase.kt @@ -4,9 +4,9 @@ import io.github.sds100.keymapper.system.bluetooth.BluetoothDeviceInfo import io.github.sds100.keymapper.system.devices.DevicesAdapter import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import javax.inject.Inject class ChooseBluetoothDeviceUseCaseImpl @Inject constructor( private val devicesAdapter: DevicesAdapter, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/bluetooth/ChooseBluetoothDeviceViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/bluetooth/ChooseBluetoothDeviceViewModel.kt index 9b3e10f725..f358135445 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/bluetooth/ChooseBluetoothDeviceViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/bluetooth/ChooseBluetoothDeviceViewModel.kt @@ -11,6 +11,7 @@ import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.TextListItem import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.bluetooth.BluetoothDeviceInfo +import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -19,7 +20,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class ChooseBluetoothDeviceViewModel @Inject constructor( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/AutoSwitchImeController.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/AutoSwitchImeController.kt index 3cedb56284..bce7414cbd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/AutoSwitchImeController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/AutoSwitchImeController.kt @@ -1,30 +1,55 @@ package io.github.sds100.keymapper.base.system.inputmethod +import android.os.Build +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityWindowInfo +import android.view.inputmethod.EditorInfo +import androidx.annotation.RequiresApi +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase +import io.github.sds100.keymapper.base.system.accessibility.BaseAccessibilityService 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.utils.Success +import io.github.sds100.keymapper.common.utils.KMError +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.otherwise +import io.github.sds100.keymapper.common.utils.valueOrNull 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.data.utils.PrefDelegate -import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter -import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceEvent import io.github.sds100.keymapper.system.devices.DevicesAdapter import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter +import io.github.sds100.keymapper.system.lock.LockScreenAdapter import io.github.sds100.keymapper.system.popup.ToastAdapter import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import timber.log.Timber -import javax.inject.Inject -class AutoSwitchImeController @Inject constructor( +/** + * This requires Android 11+ because this is when the accessibility service API for switching + * input methods was introduced. On older versions one would have to use WRITE_SECURE_SETTINGS + * permission, and it is not worth the effort to build a UI to explain this in the app. + */ +@RequiresApi(Build.VERSION_CODES.R) +class AutoSwitchImeController @AssistedInject constructor( + // Use the accessibility service so the calls are synchronous. This will reduce race conditions + // checking which input method is chosen when they start/finish. + @Assisted + private val service: BaseAccessibilityService, + @Assisted private val coroutineScope: CoroutineScope, private val preferenceRepository: PreferenceRepository, private val inputMethodAdapter: InputMethodAdapter, @@ -32,139 +57,242 @@ class AutoSwitchImeController @Inject constructor( private val devicesAdapter: DevicesAdapter, private val toastAdapter: ToastAdapter, private val resourceProvider: ResourceProvider, - private val accessibilityServiceAdapter: AccessibilityServiceAdapter, private val buildConfigProvider: BuildConfigProvider, + private val lockScreenAdapter: LockScreenAdapter, ) : PreferenceRepository by preferenceRepository { - private val imeHelper = KeyMapperImeHelper(inputMethodAdapter, buildConfigProvider.packageName) - private val devicesThatToggleKeyboard - by PrefDelegate(Keys.devicesThatChangeIme, emptySet()) + @AssistedFactory + interface Factory { + fun create( + accessibilityService: BaseAccessibilityService, + coroutineScope: CoroutineScope, + ): AutoSwitchImeController + } + + private val imeHelper: KeyMapperImeHelper = KeyMapperImeHelper( + service, + inputMethodAdapter, + buildConfigProvider.packageName, + ) - private val devicesThatShowImePicker - by PrefDelegate(Keys.devicesThatShowImePicker, emptySet()) + private val devicesThatToggleKeyboard: Set by PrefDelegate( + Keys.devicesThatChangeIme, + emptySet(), + ) - private val changeImeOnDeviceConnect by PrefDelegate(Keys.changeImeOnDeviceConnect, false) - private val showImePickerOnBtConnect by PrefDelegate(Keys.showImePickerOnDeviceConnect, false) + private val changeImeOnDeviceConnect: Boolean by PrefDelegate( + Keys.changeImeOnDeviceConnect, + false, + ) private val toggleKeyboardOnToggleKeymaps by PrefDelegate( Keys.toggleKeyboardOnToggleKeymaps, false, ) - private var changeImeOnInputFocus: Boolean = false + private var showToast: StateFlow = + preferenceRepository.get(Keys.showToastWhenAutoChangingIme) + .map { it ?: PreferenceDefaults.SHOW_TOAST_WHEN_AUTO_CHANGE_IME } + .stateIn( + coroutineScope, + SharingStarted.Eagerly, + PreferenceDefaults.SHOW_TOAST_WHEN_AUTO_CHANGE_IME, + ) - private var showToast: Boolean = PreferenceDefaults.SHOW_TOAST_WHEN_AUTO_CHANGE_IME + private val changeImeOnInputFocusPreference: Flow = + preferenceRepository + .get(Keys.changeImeOnInputFocus) + .map { it ?: PreferenceDefaults.CHANGE_IME_ON_INPUT_FOCUS } + + private val changeImeOnToggleKeyMaps: Flow = + preferenceRepository + .get(Keys.toggleKeyboardOnToggleKeymaps) + .map { it ?: false } + + /** + * Only change the input method when input is started/finished if the user has enabled + * the setting, and key maps are resumed if the option to switch ime on toggle key maps + * is also enabled. This prevents the IME immediately changing again when + * the user pauses their key maps. + */ + private val changeImeOnStartInput: StateFlow = combine( + changeImeOnInputFocusPreference, + changeImeOnToggleKeyMaps, + pauseKeyMapsUseCase.isPaused, + ) { changeOnFocus, toggleOnKeyMaps, isPaused -> + changeOnFocus && (!toggleOnKeyMaps || !isPaused) + }.stateIn( + coroutineScope, + SharingStarted.Eagerly, + false, + ) + + private var isImeBeingSwitched = false fun init() { pauseKeyMapsUseCase.isPaused.onEach { isPaused -> - if (!toggleKeyboardOnToggleKeymaps) return@onEach if (isPaused) { - chooseIncompatibleIme(imePickerAllowed = true) + chooseIncompatibleIme() } else { - chooseCompatibleIme(imePickerAllowed = true) + chooseCompatibleIme() } }.launchIn(coroutineScope) devicesAdapter.onInputDeviceConnect.onEach { device -> - if (showImePickerOnBtConnect && devicesThatShowImePicker.contains(device.descriptor)) { - inputMethodAdapter.showImePicker(fromForeground = false) - } - if (changeImeOnDeviceConnect && devicesThatToggleKeyboard.contains(device.descriptor)) { - chooseCompatibleIme(imePickerAllowed = true) + chooseCompatibleIme() } }.launchIn(coroutineScope) devicesAdapter.onInputDeviceDisconnect.onEach { device -> - if (showImePickerOnBtConnect && devicesThatShowImePicker.contains(device.descriptor)) { - inputMethodAdapter.showImePicker(fromForeground = false) - } - if (changeImeOnDeviceConnect && devicesThatToggleKeyboard.contains(device.descriptor)) { - chooseIncompatibleIme(imePickerAllowed = true) + chooseIncompatibleIme() } }.launchIn(coroutineScope) + } - preferenceRepository.get(Keys.changeImeOnInputFocus).onEach { - changeImeOnInputFocus = it ?: PreferenceDefaults.CHANGE_IME_ON_INPUT_FOCUS - }.launchIn(coroutineScope) + fun onAccessibilityEvent(event: AccessibilityEvent) { + // On SDK 33 and newer, the more reliable accessibility input method API is used. + // See onStartInput and onFinishInput. On OxygenOS 11 it can not detect the Key Mapper + // Basic input method window so onStartInput is also called from the KeyMapperImeService as + // a fallback. - preferenceRepository.get(Keys.showToastWhenAutoChangingIme).onEach { - showToast = it ?: false - }.launchIn(coroutineScope) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && + changeImeOnStartInput.value && + event.eventType == AccessibilityEvent.TYPE_WINDOWS_CHANGED + ) { + if (isImeBeingSwitched) { + isImeBeingSwitched = false + return + } - accessibilityServiceAdapter.eventReceiver.onEach { event -> - when (event) { - is AccessibilityServiceEvent.OnInputFocusChange -> { - if (!changeImeOnInputFocus) { - return@onEach - } - - if (event.isFocussed) { - Timber.d("Choose normal keyboard because got input focus") - chooseIncompatibleIme(imePickerAllowed = false) - } else { - Timber.d("Choose key mapper keyboard because lost input focus") - chooseCompatibleIme(imePickerAllowed = false) - } - } + val isInputStarted = isImeWindowVisible() - else -> Unit + if (isInputStarted) { + if (chooseIncompatibleIme()) { + isImeBeingSwitched = true + } + } else { + if (chooseCompatibleIme()) { + isImeBeingSwitched = true + } } - }.launchIn(coroutineScope) + } + } + + private fun isImeWindowVisible(): Boolean { + val imeWindow: AccessibilityWindowInfo? = + service.windows.find { it.type == AccessibilityWindowInfo.TYPE_INPUT_METHOD } + + return imeWindow != null && imeWindow.root?.isVisibleToUser == true + } + + fun onStartInput(attribute: EditorInfo, restarting: Boolean) { + if (!changeImeOnStartInput.value) { + return + } + + // Make sure the input type actually accepts text because sometimes the input method + // can be started even when the user isn't typing, such as in Minecraft. + // One must use the mask because other bits are used for flags. + // There are cases where the ime is showing but the app reports no TYPE_CLASS for some reason + // such as in the Reddit search bar so as a fallback check for a label or hint. + val isValidInputStarted = + (attribute.inputType and EditorInfo.TYPE_MASK_CLASS) != EditorInfo.TYPE_NULL || + attribute.label != null || + attribute.hintText != null + + val result = if (isValidInputStarted) { + chooseIncompatibleIme() + } else if (!lockScreenAdapter.isLocked()) { + // Do not choose the key mapper ime if the lock screen is showing + // in case the user needs the keyboard to unlock. This would also + // clash and cause infinite loops with the safety feature to + // auto-switch inside the KeyMapperImeService + // that switches regardless of whether any auto-switching features are enabled. + chooseCompatibleIme() + } else { + false + } + + // Drop the next event if the IME was just changed to prevent an infinite loop. + if (result) { + isImeBeingSwitched = true + } + } + + fun onFinishInput() { + if (!changeImeOnStartInput.value) { + return + } + + if (isImeBeingSwitched) { + isImeBeingSwitched = false + return + } + + if (!lockScreenAdapter.isLocked()) { + // Do not choose the key mapper ime if the lock screen is showing + // in case the user needs the keyboard to unlock. This would also + // clash and cause infinite loops with the safety feature to + // auto-switch inside the KeyMapperImeService + // that switches regardless of whether any auto-switching features are enabled. + chooseCompatibleIme() + } } - private suspend fun chooseIncompatibleIme(imePickerAllowed: Boolean) { + private fun chooseIncompatibleIme(): Boolean { // only choose the keyboard if the correct one isn't already chosen if (!imeHelper.isCompatibleImeChosen()) { - return + return false } - imeHelper.chooseLastUsedIncompatibleInputMethod() - .onSuccess { ime -> - if (showToast) { - val message = - resourceProvider.getString(R.string.toast_chose_keyboard, ime.label) - toastAdapter.show(message) - } - } - .otherwise { - if (imePickerAllowed) { - inputMethodAdapter.showImePicker(fromForeground = false) - } else { - Success(Unit) + Timber.d("AutoSwitchImeController: Choosing incompatible IME") + + return imeHelper.chooseLastUsedIncompatibleInputMethod() + .onSuccess { imeId -> + if (showToast.value) { + showToast(imeId) } } .onFailure { error -> toastAdapter.show(error.getFullMessage(resourceProvider)) } + .isSuccess } - private suspend fun chooseCompatibleIme(imePickerAllowed: Boolean) { + private fun chooseCompatibleIme(): Boolean { // only choose the keyboard if the correct one isn't already chosen if (imeHelper.isCompatibleImeChosen()) { - return + return false } - imeHelper.chooseCompatibleInputMethod() - .onSuccess { ime -> - if (showToast) { - val message = - resourceProvider.getString(R.string.toast_chose_keyboard, ime.label) - toastAdapter.show(message) - } - } - .otherwise { - if (imePickerAllowed) { - inputMethodAdapter.showImePicker(fromForeground = false) - } else { - Success(Unit) + Timber.d("AutoSwitchImeController: Choosing compatible IME") + + return imeHelper.chooseCompatibleInputMethod() + .onSuccess { imeId -> + if (showToast.value) { + showToast(imeId) } } .onFailure { error -> - toastAdapter.show(error.getFullMessage(resourceProvider)) + // Do not show an error if no IME is enabled, just let this auto switching + // feature not work silently. If the user hasn't enabled an IME then they probably + // aren't using any feature that requires the IME. + if (error != KMError.NoCompatibleImeEnabled) { + toastAdapter.show(error.getFullMessage(resourceProvider)) + } } + .isSuccess + } + + private fun showToast(imeId: String) { + val imeLabel = inputMethodAdapter.getInfoById(imeId).valueOrNull()?.label ?: return + + val message = + resourceProvider.getString(R.string.toast_chose_keyboard, imeLabel) + toastAdapter.show(message) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ImeInputEventInjector.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ImeInputEventInjector.kt index db7abdfcde..1f099f6756 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ImeInputEventInjector.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ImeInputEventInjector.kt @@ -1,49 +1,30 @@ package io.github.sds100.keymapper.base.system.inputmethod -import android.content.Context -import android.content.Intent -import android.os.Build import android.os.SystemClock import android.view.KeyCharacterMap import android.view.KeyEvent -import io.github.sds100.keymapper.common.utils.InputEventType -import io.github.sds100.keymapper.system.inputevents.InputEventInjector -import io.github.sds100.keymapper.system.inputmethod.InputKeyModel +import io.github.sds100.keymapper.base.input.InjectKeyEventModel import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.inputmethod.KeyEventRelayServiceWrapper +import javax.inject.Inject +import javax.inject.Singleton import timber.log.Timber /** * This class handles communicating with the Key Mapper input method services * so key events and text can be inputted. */ -class ImeInputEventInjectorImpl( - private val ctx: Context, +@Singleton +class ImeInputEventInjectorImpl @Inject constructor( private val keyEventRelayService: KeyEventRelayServiceWrapper, private val inputMethodAdapter: InputMethodAdapter, ) : ImeInputEventInjector { - companion object { - // DON'T CHANGE THESE!!! - private const val KEY_MAPPER_INPUT_METHOD_ACTION_INPUT_DOWN_UP = - "io.github.sds100.keymapper.inputmethod.ACTION_INPUT_DOWN_UP" - private const val KEY_MAPPER_INPUT_METHOD_ACTION_INPUT_DOWN = - "io.github.sds100.keymapper.inputmethod.ACTION_INPUT_DOWN" - private const val KEY_MAPPER_INPUT_METHOD_ACTION_INPUT_UP = - "io.github.sds100.keymapper.inputmethod.ACTION_INPUT_UP" - private const val KEY_MAPPER_INPUT_METHOD_ACTION_TEXT = - "io.github.sds100.keymapper.inputmethod.ACTION_INPUT_TEXT" - - private const val KEY_MAPPER_INPUT_METHOD_EXTRA_KEY_EVENT = - "io.github.sds100.keymapper.inputmethod.EXTRA_KEY_EVENT" - private const val KEY_MAPPER_INPUT_METHOD_EXTRA_TEXT = - "io.github.sds100.keymapper.inputmethod.EXTRA_TEXT" - private const val CALLBACK_ID_INPUT_METHOD = "input_method" } - override suspend fun inputKeyEvent(model: InputKeyModel) { - Timber.d("Inject key event with input method ${KeyEvent.keyCodeToString(model.keyCode)}, $model") + override fun inputKeyEvent(event: InjectKeyEventModel) { + Timber.d("Inject key event with input method $event") val imePackageName = inputMethodAdapter.chosenIme.value?.packageName @@ -52,79 +33,11 @@ class ImeInputEventInjectorImpl( return } - // Only use the new key event relay service on Android 14+ because - // it introduced a 1 second delay for broadcasts to context-registered - // receivers. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - inputKeyEventRelayService(model, imePackageName) - } else { - inputKeyEventBroadcast(model, imePackageName) - } - } - - private fun inputKeyEventBroadcast(model: InputKeyModel, imePackageName: String) { - val intentAction = when (model.inputType) { - InputEventType.DOWN -> KEY_MAPPER_INPUT_METHOD_ACTION_INPUT_DOWN - InputEventType.DOWN_UP -> KEY_MAPPER_INPUT_METHOD_ACTION_INPUT_DOWN_UP - InputEventType.UP -> KEY_MAPPER_INPUT_METHOD_ACTION_INPUT_UP - } - - Intent(intentAction).apply { - setPackage(imePackageName) - - val action = when (model.inputType) { - InputEventType.DOWN, InputEventType.DOWN_UP -> KeyEvent.ACTION_DOWN - InputEventType.UP -> KeyEvent.ACTION_UP - } - - val eventTime = SystemClock.uptimeMillis() - - val keyEvent = createInjectedKeyEvent(eventTime, action, model) - - putExtra(KEY_MAPPER_INPUT_METHOD_EXTRA_KEY_EVENT, keyEvent) - - ctx.sendBroadcast(this) - } - } - - private fun inputKeyEventRelayService(model: InputKeyModel, imePackageName: String) { - val eventTime = SystemClock.uptimeMillis() - - when (model.inputType) { - InputEventType.DOWN_UP -> { - val downKeyEvent = createInjectedKeyEvent(eventTime, KeyEvent.ACTION_DOWN, model) - keyEventRelayService.sendKeyEvent( - downKeyEvent, - imePackageName, - CALLBACK_ID_INPUT_METHOD, - ) - - val upKeyEvent = createInjectedKeyEvent(eventTime, KeyEvent.ACTION_UP, model) - keyEventRelayService.sendKeyEvent( - upKeyEvent, - imePackageName, - CALLBACK_ID_INPUT_METHOD, - ) - } - - InputEventType.DOWN -> { - val downKeyEvent = createInjectedKeyEvent(eventTime, KeyEvent.ACTION_DOWN, model) - keyEventRelayService.sendKeyEvent( - downKeyEvent, - imePackageName, - CALLBACK_ID_INPUT_METHOD, - ) - } - - InputEventType.UP -> { - val upKeyEvent = createInjectedKeyEvent(eventTime, KeyEvent.ACTION_UP, model) - keyEventRelayService.sendKeyEvent( - upKeyEvent, - imePackageName, - CALLBACK_ID_INPUT_METHOD, - ) - } - } + keyEventRelayService.sendKeyEvent( + event.toAndroidKeyEvent(), + imePackageName, + CALLBACK_ID_INPUT_METHOD, + ) } override fun inputText(text: String) { @@ -137,25 +50,11 @@ class ImeInputEventInjectorImpl( return } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - inputTextRelayService(text, imePackageName) - } else { - inputTextBroadcast(text, imePackageName) - } - } - - private fun inputTextBroadcast(text: String, imePackageName: String) { - Intent(KEY_MAPPER_INPUT_METHOD_ACTION_TEXT).apply { - setPackage(imePackageName) - - putExtra(KEY_MAPPER_INPUT_METHOD_EXTRA_TEXT, text) - ctx.sendBroadcast(this) - } + inputTextRelayService(text, imePackageName) } private fun inputTextRelayService(text: String, imePackageName: String) { // taken from android.view.inputmethod.BaseInputConnection.sendCurrentText() - if (text.isEmpty()) { return } @@ -201,6 +100,7 @@ class ImeInputEventInjectorImpl( } } -interface ImeInputEventInjector : InputEventInjector { +interface ImeInputEventInjector { fun inputText(text: String) + fun inputKeyEvent(event: InjectKeyEventModel) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/KeyMapperImeHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/KeyMapperImeHelper.kt index d1e0f388ae..290ab90d19 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/KeyMapperImeHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/KeyMapperImeHelper.kt @@ -5,14 +5,15 @@ import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.common.utils.onSuccess -import io.github.sds100.keymapper.common.utils.suspendThen import io.github.sds100.keymapper.common.utils.then +import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map class KeyMapperImeHelper( + private val switchImeInterface: SwitchImeInterface, private val imeAdapter: InputMethodAdapter, private val packageName: String, ) { @@ -47,32 +48,49 @@ class KeyMapperImeHelper( imeAdapter.inputMethods .map { containsCompatibleIme(it) } - suspend fun enableCompatibleInputMethods() { - keyMapperImePackageList.forEach { packageName -> - imeAdapter.getInfoByPackageName(packageName).onSuccess { - imeAdapter.enableIme(it.id) + val isCompatibleImeChosenFlow: Flow = + imeAdapter.chosenIme + .map { chosenIme -> + if (chosenIme == null) { + false + } else { + isKeyMapperInputMethod(chosenIme.packageName, packageName) + } } + + fun enableCompatibleInputMethods(): KMResult { + var result: KMResult? = null + + for (imePackageName in keyMapperImePackageList) { + val imeId = + imeAdapter.getInfoByPackageName(imePackageName).valueOrNull()?.id ?: continue + + result = switchImeInterface.enableIme(imeId) } + + return result ?: KMError.InputMethodNotFound(packageName) } - suspend fun chooseCompatibleInputMethod(): KMResult = - getLastUsedCompatibleImeId().suspendThen { - imeAdapter.chooseImeWithoutUserInput(it) + fun chooseCompatibleInputMethod(): KMResult = + getLastUsedCompatibleImeId().then { imeId -> + switchImeInterface.switchIme(imeId).then { Success(imeId) } } - suspend fun chooseLastUsedIncompatibleInputMethod(): KMResult = - getLastUsedIncompatibleImeId().then { - imeAdapter.chooseImeWithoutUserInput(it) + fun chooseLastUsedIncompatibleInputMethod(): KMResult = + getLastUsedIncompatibleImeId().then { imeId -> + switchImeInterface.switchIme(imeId).then { Success(imeId) } } - suspend fun toggleCompatibleInputMethod(): KMResult = if (isCompatibleImeChosen()) { - chooseLastUsedIncompatibleInputMethod() - } else { - chooseCompatibleInputMethod() + fun toggleCompatibleInputMethod(): KMResult { + return if (isCompatibleImeChosen()) { + chooseLastUsedIncompatibleInputMethod() + } else { + chooseCompatibleInputMethod() + } } fun isCompatibleImeChosen(): Boolean { - val chosenIme = imeAdapter.chosenIme.value ?: return false + val chosenIme = imeAdapter.getChosenIme() ?: return false return isKeyMapperInputMethod(chosenIme.packageName, packageName) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ShowHideInputMethodUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ShowHideInputMethodUseCase.kt index 3fe64c9677..fa55568944 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ShowHideInputMethodUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ShowHideInputMethodUseCase.kt @@ -2,10 +2,10 @@ package io.github.sds100.keymapper.base.system.inputmethod import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceEvent +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.runBlocking -import javax.inject.Inject class ShowHideInputMethodUseCaseImpl @Inject constructor( private val serviceAdapter: AccessibilityServiceAdapter, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/SwitchImeAsyncImpl.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/SwitchImeAsyncImpl.kt new file mode 100644 index 0000000000..2f41dbab76 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/SwitchImeAsyncImpl.kt @@ -0,0 +1,114 @@ +package io.github.sds100.keymapper.base.system.inputmethod + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.provider.Settings +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.SettingsUtils +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.onFailure +import io.github.sds100.keymapper.common.utils.onSuccess +import io.github.sds100.keymapper.common.utils.otherwise +import io.github.sds100.keymapper.common.utils.then +import io.github.sds100.keymapper.system.SystemError +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceEvent +import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter +import io.github.sds100.keymapper.system.permissions.Permission +import io.github.sds100.keymapper.system.permissions.PermissionAdapter +import io.github.sds100.keymapper.system.root.SuAdapter +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.runBlocking + +/** + * This implementation of SwitchImeInterface communicates asynchronously with the accessibility + * service. This is needed in cases where the accessibility service instance is not accessible + * to the caller. A synchronous version is implemented in BaseAccessibilityService. + */ +@Singleton +class SwitchImeAsyncImpl @Inject constructor( + @ApplicationContext private val ctx: Context, + private val serviceAdapter: AccessibilityServiceAdapter, + private val inputMethodAdapter: InputMethodAdapter, + private val buildConfigProvider: BuildConfigProvider, + private val permissionAdapter: PermissionAdapter, + private val suAdapter: SuAdapter, +) : SwitchImeInterface { + + override fun enableIme(imeId: String): KMResult { + return enableImeWithoutUserInput(imeId).otherwise { + try { + val intent = Intent(Settings.ACTION_INPUT_METHOD_SETTINGS) + intent.flags = Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_NEW_TASK + + ctx.startActivity(intent) + Success(Unit) + } catch (_: Exception) { + KMError.CantFindImeSettings + } + } + } + + private fun enableImeWithoutUserInput(imeId: String): KMResult { + return inputMethodAdapter.getInfoByPackageName(buildConfigProvider.packageName) + .then { keyMapperImeInfo -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + imeId == keyMapperImeInfo.id + ) { + serviceAdapter.sendAsync( + AccessibilityServiceEvent.EnableInputMethod( + keyMapperImeInfo.id, + ), + ) + } else { + runBlocking { suAdapter.execute("ime enable $imeId").then { Success(Unit) } } + } + } + } + + override fun switchIme(imeId: String): KMResult { + inputMethodAdapter.getInfoById(imeId).onSuccess { + if (!it.isEnabled) { + return SystemError.ImeDisabled(it) + } + }.onFailure { + return it + } + + // First try using the accessibility service, and if that fails then + // try WRITE_SECURE_SETTINGS if possible. Otherwise return the accessibility service + // error. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + return serviceAdapter.sendAsync(AccessibilityServiceEvent.ChangeIme(imeId)) + .otherwise { error -> + if (permissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS)) { + SettingsUtils.putSecureSetting( + ctx, + Settings.Secure.DEFAULT_INPUT_METHOD, + imeId, + ) + Success(Unit) + } else { + error + } + } + } + + if (permissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS)) { + SettingsUtils.putSecureSetting( + ctx, + Settings.Secure.DEFAULT_INPUT_METHOD, + imeId, + ) + + return Success(Unit) + } + + return KMError.SwitchImeFailed + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/SwitchImeInterface.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/SwitchImeInterface.kt new file mode 100644 index 0000000000..d87e848673 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/SwitchImeInterface.kt @@ -0,0 +1,15 @@ +package io.github.sds100.keymapper.base.system.inputmethod + +import io.github.sds100.keymapper.common.utils.KMResult + +interface SwitchImeInterface { + /** + * Enable the input method in the settings. + */ + fun enableIme(imeId: String): KMResult + + /** + * Switch the active input method to the given input method id. + */ + fun switchIme(imeId: String): KMResult +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ToggleCompatibleImeUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ToggleCompatibleImeUseCase.kt index f73ed91346..eda4166e59 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ToggleCompatibleImeUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/inputmethod/ToggleCompatibleImeUseCase.kt @@ -1,25 +1,63 @@ package io.github.sds100.keymapper.base.system.inputmethod +import android.os.Build import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.then +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter -import kotlinx.coroutines.flow.Flow +import io.github.sds100.keymapper.system.permissions.Permission +import io.github.sds100.keymapper.system.permissions.PermissionAdapter import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch @Singleton class ToggleCompatibleImeUseCaseImpl @Inject constructor( private val inputMethodAdapter: InputMethodAdapter, private val buildConfigProvider: BuildConfigProvider, + private val switchImeInterface: SwitchImeInterface, + private val serviceAdapter: AccessibilityServiceAdapter, + private val permissionAdapter: PermissionAdapter, ) : ToggleCompatibleImeUseCase { private val keyMapperImeHelper = - KeyMapperImeHelper(inputMethodAdapter, buildConfigProvider.packageName) + KeyMapperImeHelper(switchImeInterface, inputMethodAdapter, buildConfigProvider.packageName) + + override val sufficientPermissions: Flow = channelFlow { + suspend fun invalidate() { + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + serviceAdapter.state.first() == AccessibilityServiceState.ENABLED -> send(true) + + permissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS) -> send(true) + + else -> send(false) + } + } + + invalidate() + + launch { + permissionAdapter.onPermissionsUpdate.collectLatest { + invalidate() + } + } - override val sufficientPermissions: Flow = - inputMethodAdapter.isUserInputRequiredToChangeIme + launch { + serviceAdapter.state.collectLatest { + invalidate() + } + } + } - override suspend fun toggle(): KMResult = keyMapperImeHelper.toggleCompatibleInputMethod() + override suspend fun toggle(): KMResult = + keyMapperImeHelper.toggleCompatibleInputMethod().then { inputMethodAdapter.getInfoById(it) } } interface ToggleCompatibleImeUseCase { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentFragment.kt index d55d39aa69..b5704a9f04 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentFragment.kt @@ -77,7 +77,11 @@ class ConfigIntentFragment : Fragment() { ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> val insets = - insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime()) + insets.getInsets( + WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() or + WindowInsetsCompat.Type.ime(), + ) v.updatePadding(insets.left, insets.top, insets.right, insets.bottom) WindowInsetsCompat.CONSUMED } @@ -121,20 +125,10 @@ class ConfigIntentFragment : Fragment() { private fun EpoxyController.bindExtra(model: IntentExtraListItem) { val intentNameTextWatcher = object : TextWatcher { - override fun beforeTextChanged( - s: CharSequence?, - start: Int, - count: Int, - after: Int, - ) { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { } - override fun onTextChanged( - s: CharSequence?, - start: Int, - before: Int, - count: Int, - ) { + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { } override fun afterTextChanged(s: Editable?) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentViewModel.kt index a6bca470c0..1473281ba6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentViewModel.kt @@ -47,6 +47,7 @@ import io.github.sds100.keymapper.system.intents.ShortArrayExtraType import io.github.sds100.keymapper.system.intents.ShortExtraType import io.github.sds100.keymapper.system.intents.StringArrayExtraType import io.github.sds100.keymapper.system.intents.StringExtraType +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -58,7 +59,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class ConfigIntentViewModel @Inject constructor( @@ -97,11 +97,20 @@ class ConfigIntentViewModel @Inject constructor( yield(Intent.FLAG_ACTIVITY_CLEAR_TASK to "FLAG_ACTIVITY_CLEAR_TASK") yield(Intent.FLAG_ACTIVITY_CLEAR_TOP to "FLAG_ACTIVITY_CLEAR_TOP") - yield(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET to "FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET") + yield( + Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET to + "FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET", + ) - yield(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS to "FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS") + yield( + Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS to + "FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS", + ) yield(Intent.FLAG_ACTIVITY_FORWARD_RESULT to "FLAG_ACTIVITY_FORWARD_RESULT") - yield(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY to "FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY") + yield( + Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY to + "FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY", + ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { yield(Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT to "FLAG_ACTIVITY_LAUNCH_ADJACENT") @@ -124,10 +133,16 @@ class ConfigIntentViewModel @Inject constructor( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { yield(Intent.FLAG_ACTIVITY_REQUIRE_DEFAULT to "FLAG_ACTIVITY_REQUIRE_DEFAULT") - yield(Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER to "FLAG_ACTIVITY_REQUIRE_NON_BROWSER") + yield( + Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER to + "FLAG_ACTIVITY_REQUIRE_NON_BROWSER", + ) } - yield(Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED to "FLAG_ACTIVITY_RESET_TASK_IF_NEEDED") + yield( + Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED to + "FLAG_ACTIVITY_RESET_TASK_IF_NEEDED", + ) yield(Intent.FLAG_ACTIVITY_RETAIN_IN_RECENTS to "FLAG_ACTIVITY_RETAIN_IN_RECENTS") @@ -142,7 +157,10 @@ class ConfigIntentViewModel @Inject constructor( yield(Intent.FLAG_EXCLUDE_STOPPED_PACKAGES to "FLAG_EXCLUDE_STOPPED_PACKAGES") yield(Intent.FLAG_FROM_BACKGROUND to "FLAG_FROM_BACKGROUND") - yield(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION to "FLAG_GRANT_PERSISTABLE_URI_PERMISSION") + yield( + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION to + "FLAG_GRANT_PERSISTABLE_URI_PERMISSION", + ) yield(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION to "FLAG_GRANT_PREFIX_URI_PERMISSION") @@ -157,7 +175,10 @@ class ConfigIntentViewModel @Inject constructor( yield(Intent.FLAG_RECEIVER_REPLACE_PENDING to "FLAG_RECEIVER_REPLACE_PENDING") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - yield(Intent.FLAG_RECEIVER_VISIBLE_TO_INSTANT_APPS to "FLAG_RECEIVER_VISIBLE_TO_INSTANT_APPS") + yield( + Intent.FLAG_RECEIVER_VISIBLE_TO_INSTANT_APPS to + "FLAG_RECEIVER_VISIBLE_TO_INSTANT_APPS", + ) } }.toList() } @@ -433,7 +454,9 @@ class ConfigIntentViewModel @Inject constructor( is ShortArray -> ShortArrayExtraType is String -> StringExtraType is Array<*> -> StringArrayExtraType - else -> throw IllegalArgumentException("Don't know how to convert this extra (${value.javaClass.name}) to an IntentExtraType") + else -> throw IllegalArgumentException( + "Don't know how to convert this extra (${value.javaClass.name}) to an IntentExtraType", + ) } val extra = IntentExtraModel( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/navigation/OpenMenuHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/navigation/OpenMenuHelper.kt index 78120f505d..57a3dde490 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/navigation/OpenMenuHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/navigation/OpenMenuHelper.kt @@ -1,27 +1,19 @@ package io.github.sds100.keymapper.base.system.navigation +import android.view.InputDevice import android.view.KeyEvent import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import io.github.sds100.keymapper.base.input.InjectKeyEventModel +import io.github.sds100.keymapper.base.input.InputEventHub import io.github.sds100.keymapper.base.system.accessibility.AccessibilityNodeAction import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityService -import io.github.sds100.keymapper.common.utils.InputEventType import io.github.sds100.keymapper.common.utils.KMResult -import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.common.utils.success -import io.github.sds100.keymapper.system.inputevents.InputEventInjector -import io.github.sds100.keymapper.system.inputmethod.InputKeyModel -import io.github.sds100.keymapper.system.permissions.Permission -import io.github.sds100.keymapper.system.permissions.PermissionAdapter -import io.github.sds100.keymapper.system.root.SuAdapter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch +import io.github.sds100.keymapper.common.utils.then class OpenMenuHelper( - private val suAdapter: SuAdapter, private val accessibilityService: IAccessibilityService, - private val shizukuInputEventInjector: InputEventInjector, - private val permissionAdapter: PermissionAdapter, - private val coroutineScope: CoroutineScope, + private val inputEventHub: InputEventHub, ) { companion object { @@ -30,24 +22,29 @@ class OpenMenuHelper( fun openMenu(): KMResult<*> { when { - permissionAdapter.isGranted(Permission.SHIZUKU) -> { - val inputKeyModel = InputKeyModel( + inputEventHub.isSystemBridgeConnected() -> { + val downEvent = InjectKeyEventModel( keyCode = KeyEvent.KEYCODE_MENU, - inputType = InputEventType.DOWN_UP, + action = KeyEvent.ACTION_DOWN, + metaState = 0, + scanCode = 0, + deviceId = -1, + repeatCount = 0, + source = InputDevice.SOURCE_UNKNOWN, ) - coroutineScope.launch { - shizukuInputEventInjector.inputKeyEvent(inputKeyModel) - } + val upEvent = downEvent.copy(action = KeyEvent.ACTION_UP) - return success() + return inputEventHub.injectKeyEventAsync(downEvent).then { + inputEventHub.injectKeyEventAsync(upEvent) + } } - suAdapter.isGranted.firstBlocking() -> - return suAdapter.execute("input keyevent ${KeyEvent.KEYCODE_MENU}\n") - else -> { - accessibilityService.performActionOnNode({ it.contentDescription == OVERFLOW_MENU_CONTENT_DESCRIPTION }) { + accessibilityService.performActionOnNode({ + it.contentDescription == + OVERFLOW_MENU_CONTENT_DESCRIPTION + }) { AccessibilityNodeAction( AccessibilityNodeInfoCompat.ACTION_CLICK, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/AndroidNotificationAdapter.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/AndroidNotificationAdapter.kt index dbae0c9ee3..4c72b8efe2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/AndroidNotificationAdapter.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/AndroidNotificationAdapter.kt @@ -1,40 +1,86 @@ package io.github.sds100.keymapper.base.system.notifications +import android.Manifest import android.app.NotificationChannel import android.app.PendingIntent +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager import android.os.Build +import android.provider.Settings +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput +import androidx.core.content.ContextCompat import com.google.android.material.color.DynamicColors import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.utils.ui.color import io.github.sds100.keymapper.common.KeyMapperClassProvider +import io.github.sds100.keymapper.common.notifications.KMNotificationAction import io.github.sds100.keymapper.system.notifications.NotificationAdapter import io.github.sds100.keymapper.system.notifications.NotificationChannelModel -import io.github.sds100.keymapper.system.notifications.NotificationIntentType import io.github.sds100.keymapper.system.notifications.NotificationModel +import io.github.sds100.keymapper.system.notifications.NotificationRemoteInput +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton +import timber.log.Timber @Singleton class AndroidNotificationAdapter @Inject constructor( - @ApplicationContext private val context: Context, + @ApplicationContext private val ctx: Context, private val coroutineScope: CoroutineScope, private val classProvider: KeyMapperClassProvider, ) : NotificationAdapter { - private val ctx = context.applicationContext private val manager: NotificationManagerCompat = NotificationManagerCompat.from(ctx) - override val onNotificationActionClick = MutableSharedFlow() + override val onNotificationActionClick = MutableSharedFlow() + override val onNotificationRemoteInput = MutableSharedFlow() + + private val broadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + context ?: return + intent ?: return + + onReceiveNotificationActionIntent(intent) + + // dismiss the notification drawer after tapping on the notification. This is deprecated on S+ + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS).apply { + context.sendBroadcast(this) + } + } + } + } + + init { + val intentFilter = IntentFilter().apply { + for (entry in KMNotificationAction.IntentAction.entries) { + addAction(entry.name) + } + } + ContextCompat.registerReceiver( + ctx, + broadcastReceiver, + intentFilter, + ContextCompat.RECEIVER_EXPORTED, + ) + } override fun showNotification(notification: NotificationModel) { + if (ActivityCompat.checkSelfPermission(ctx, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + return + } + val builder = NotificationCompat.Builder(ctx, notification.channel).apply { if (!DynamicColors.isDynamicColorAvailable()) { color = ctx.color(R.color.md_theme_secondary) @@ -59,21 +105,41 @@ class AndroidNotificationAdapter @Inject constructor( setStyle(NotificationCompat.BigTextStyle()) } + if (notification.timeout != null) { + this.setTimeoutAfter(notification.timeout!!) + } + + if (notification.showIndeterminateProgress) { + setProgress(1, 1, true) + } + setSmallIcon(notification.icon) if (!notification.showOnLockscreen) { setVisibility(NotificationCompat.VISIBILITY_SECRET) } - for (action in notification.actions) { - addAction( - NotificationCompat.Action( - 0, - action.text, - createActionIntent(action.intentType), - ), + for ((action, label) in notification.actions) { + val pendingIntent = createActionIntent(action) + + val notificationAction = NotificationCompat.Action.Builder( + 0, + label, + pendingIntent, ) + + if (action is KMNotificationAction.RemoteInput) { + val remoteInput = RemoteInput.Builder(action.key) + .setLabel(label) + .build() + + notificationAction.addRemoteInput(remoteInput) + } + + addAction(notificationAction.build()) } + + setSilent(notification.silent) } manager.notify(notification.id, builder.build()) @@ -84,52 +150,125 @@ class AndroidNotificationAdapter @Inject constructor( } override fun createChannel(channel: NotificationChannelModel) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - manager.createNotificationChannel( - NotificationChannel( - channel.id, - channel.name, - channel.importance, - ), - ) - } + val androidChannel = NotificationChannel( + channel.id, + channel.name, + channel.importance, + ) + + manager.createNotificationChannel(androidChannel) } override fun deleteChannel(channelId: String) { manager.deleteNotificationChannel(channelId) } + override fun openChannelSettings(channelId: String) { + Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, channelId) + + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + ctx.startActivity(this) + } + } + fun onReceiveNotificationActionIntent(intent: Intent) { - val actionId = intent.action ?: return + val intentAction = + KMNotificationAction.IntentAction.entries.single { it.name == intent.action } + + Timber.d("Received notification click: actionId=$intentAction") + + // Check if there's RemoteInput data + val remoteInputBundle = RemoteInput.getResultsFromIntent(intent) + + if (remoteInputBundle != null) { + for (key in remoteInputBundle.keySet()) { + val text = remoteInputBundle.getCharSequence(key)?.toString() + + if (!text.isNullOrEmpty()) { + coroutineScope.launch { + onNotificationRemoteInput.emit(NotificationRemoteInput(intentAction, text)) + } + + return + } + } + } + // No text input, treat as regular action click coroutineScope.launch { - onNotificationActionClick.emit(actionId) + onNotificationActionClick.emit(intentAction) } } - private fun createActionIntent(intentType: NotificationIntentType): PendingIntent { - when (intentType) { - is NotificationIntentType.Broadcast -> { - val intent = Intent(ctx, NotificationClickReceiver::class.java).apply { - action = intentType.action - } + private fun createActionIntent(notificationAction: KMNotificationAction): PendingIntent { + return when (notificationAction) { + KMNotificationAction.Activity.AccessibilitySettings -> createActivityPendingIntent( + Settings.ACTION_ACCESSIBILITY_SETTINGS, + ) - return PendingIntent.getBroadcast(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) - } + is KMNotificationAction.Activity.MainActivity -> createMainActivityPendingIntent( + notificationAction.action, + ) - is NotificationIntentType.MainActivity -> { - val intent = Intent(ctx, classProvider.getMainActivity()).apply { - action = intentType.customIntentAction ?: Intent.ACTION_MAIN - } + is KMNotificationAction.Broadcast -> createBroadcastPendingIntent( + notificationAction.intentAction.name, + ) + is KMNotificationAction.RemoteInput -> createRemoteInputPendingIntent( + notificationAction.intentAction.name, + ) + } + } - return PendingIntent.getActivity(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) - } + private fun createRemoteInputPendingIntent(action: String): PendingIntent { + val intent = Intent(action).apply { + setPackage(ctx.packageName) + } - is NotificationIntentType.Activity -> { - val intent = Intent(intentType.action) + return PendingIntent.getBroadcast( + ctx, + 0, + intent, + PendingIntent.FLAG_MUTABLE, + ) + } - return PendingIntent.getActivity(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) - } + private fun createBroadcastPendingIntent(action: String): PendingIntent { + val intent = Intent(action).apply { + setPackage(ctx.packageName) } + + return PendingIntent.getBroadcast( + ctx, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun createActivityPendingIntent(action: String): PendingIntent { + val intent = Intent(action) + + return PendingIntent.getActivity( + ctx, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun createMainActivityPendingIntent(action: String?): PendingIntent { + val intent = Intent(ctx, classProvider.getMainActivity()).apply { + this.action = action ?: Intent.ACTION_MAIN + } + + return PendingIntent.getActivity( + ctx, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE, + ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/ManageNotificationsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/ManageNotificationsUseCase.kt index 3d699686ef..24b09c1682 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/ManageNotificationsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/ManageNotificationsUseCase.kt @@ -1,74 +1,24 @@ package io.github.sds100.keymapper.base.system.notifications -import android.os.Build -import io.github.sds100.keymapper.data.Keys -import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.common.notifications.KMNotificationAction import io.github.sds100.keymapper.system.notifications.NotificationAdapter import io.github.sds100.keymapper.system.notifications.NotificationChannelModel import io.github.sds100.keymapper.system.notifications.NotificationModel +import io.github.sds100.keymapper.system.notifications.NotificationRemoteInput import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter -import io.github.sds100.keymapper.system.root.SuAdapter -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map import javax.inject.Inject +import kotlinx.coroutines.flow.Flow class ManageNotificationsUseCaseImpl @Inject constructor( - private val preferences: PreferenceRepository, private val notificationAdapter: NotificationAdapter, - private val suAdapter: SuAdapter, private val permissionAdapter: PermissionAdapter, ) : ManageNotificationsUseCase { - override val showImePickerNotification: Flow = - combine( - suAdapter.isGranted, - preferences.get(Keys.showImePickerNotification), - ) { hasRootPermission, show -> - when { - Build.VERSION.SDK_INT < Build.VERSION_CODES.O -> show ?: false - - /* - always show the notification on Oreo+ because the system/user controls - whether notifications are shown. - */ - Build.VERSION.SDK_INT == Build.VERSION_CODES.O -> true - - /* - This needs root permission on API 27 and 28 - */ - ( - Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1 || - Build.VERSION.SDK_INT == Build.VERSION_CODES.P - ) && - hasRootPermission -> true - - else -> false - } - } - - override val showToggleKeyboardNotification: Flow = - preferences.get(Keys.showToggleKeyboardNotification).map { - // always show the notification on Oreo+ because the system/user controls whether notifications are shown - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - true - } else { - it ?: true - } - } - - override val showToggleMappingsNotification: Flow = - preferences.get(Keys.showToggleKeyMapsNotification).map { - // always show the notification on Oreo+ because the system/user controls whether notifications are shown - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - true - } else { - it ?: true - } - } - - override val onActionClick: Flow = notificationAdapter.onNotificationActionClick + override val onNotificationTextInput: Flow = + notificationAdapter.onNotificationRemoteInput + override val onActionClick: Flow = + notificationAdapter.onNotificationActionClick override fun show(notification: NotificationModel) { notificationAdapter.showNotification(notification) @@ -92,14 +42,13 @@ class ManageNotificationsUseCaseImpl @Inject constructor( } interface ManageNotificationsUseCase { - val showImePickerNotification: Flow - val showToggleKeyboardNotification: Flow - val showToggleMappingsNotification: Flow /** - * The string is the ID of the action. + * Emits text input from notification actions that support RemoteInput. */ - val onActionClick: Flow + val onNotificationTextInput: Flow + + val onActionClick: Flow fun isPermissionGranted(): Boolean fun show(notification: NotificationModel) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationClickReceiver.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationClickReceiver.kt deleted file mode 100644 index 0558a8ad5f..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationClickReceiver.kt +++ /dev/null @@ -1,28 +0,0 @@ -package io.github.sds100.keymapper.base.system.notifications - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.os.Build -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject - -@AndroidEntryPoint -class NotificationClickReceiver : BroadcastReceiver() { - - @Inject - lateinit var notificationAdapter: AndroidNotificationAdapter - - override fun onReceive(context: Context, intent: Intent?) { - intent ?: return - - notificationAdapter.onReceiveNotificationActionIntent(intent) - - // dismiss the notification drawer after tapping on the notification. This is deprecated on S+ - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS).apply { - context.sendBroadcast(this) - } - } - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt index 4a25132dae..2c9fcc417b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/NotificationController.kt @@ -1,6 +1,6 @@ package io.github.sds100.keymapper.base.system.notifications -import android.provider.Settings +import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import io.github.sds100.keymapper.base.BaseMainActivity @@ -9,19 +9,22 @@ import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase import io.github.sds100.keymapper.base.system.accessibility.ControlAccessibilityServiceUseCase import io.github.sds100.keymapper.base.system.inputmethod.ShowHideInputMethodUseCase -import io.github.sds100.keymapper.base.system.inputmethod.ShowInputMethodPickerUseCase import io.github.sds100.keymapper.base.system.inputmethod.ToggleCompatibleImeUseCase 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.notifications.KMNotificationAction +import io.github.sds100.keymapper.common.utils.Constants import io.github.sds100.keymapper.common.utils.DefaultDispatcherProvider import io.github.sds100.keymapper.common.utils.DispatcherProvider import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.onSuccess +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState import io.github.sds100.keymapper.system.notifications.NotificationChannelModel -import io.github.sds100.keymapper.system.notifications.NotificationIntentType import io.github.sds100.keymapper.system.notifications.NotificationModel +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -32,38 +35,41 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton @Singleton class NotificationController @Inject constructor( private val coroutineScope: CoroutineScope, private val manageNotifications: ManageNotificationsUseCase, private val pauseMappings: PauseKeyMapsUseCase, - private val showImePicker: ShowInputMethodPickerUseCase, private val controlAccessibilityService: ControlAccessibilityServiceUseCase, private val toggleCompatibleIme: ToggleCompatibleImeUseCase, private val hideInputMethod: ShowHideInputMethodUseCase, private val onboardingUseCase: OnboardingUseCase, private val resourceProvider: ResourceProvider, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), - private val buildConfigProvider: BuildConfigProvider, ) : ResourceProvider by resourceProvider { companion object { - private const val ID_IME_PICKER = 123 + // private const val ID_IME_PICKER = 123 private const val ID_KEYBOARD_HIDDEN = 747 private const val ID_TOGGLE_MAPPINGS = 231 private const val ID_TOGGLE_KEYBOARD = 143 + const val ID_SETUP_ASSISTANT = 144 + const val ID_SYSTEM_BRIDGE_STATUS = 145 // private const val ID_FEATURE_ASSISTANT_TRIGGER = 900 private const val ID_FEATURE_FLOATING_BUTTONS = 901 - const val CHANNEL_TOGGLE_KEYMAPS = "channel_toggle_remaps" + const val CHANNEL_TOGGLE_KEY_MAPS = "channel_toggle_remaps" + + @Deprecated("Removed in 4.0.0") const val CHANNEL_IME_PICKER = "channel_ime_picker" + const val CHANNEL_KEYBOARD_HIDDEN = "channel_warning_keyboard_hidden" const val CHANNEL_TOGGLE_KEYBOARD = "channel_toggle_keymapper_keyboard" const val CHANNEL_NEW_FEATURES = "channel_new_features" + const val CHANNEL_SETUP_ASSISTANT = "channel_setup_assistant" @Deprecated("Removed in 2.0. This channel shouldn't exist") private const val CHANNEL_ID_WARNINGS = "channel_warnings" @@ -72,31 +78,6 @@ class NotificationController @Inject constructor( private const val CHANNEL_ID_PERSISTENT = "channel_persistent" } - private val actionResumeMappings = - "${buildConfigProvider.packageName}.ACTION_RESUME_MAPPINGS" - - private val actionPauseMappings = "${buildConfigProvider.packageName}.ACTION_PAUSE_MAPPINGS" - - private val actionStartService = - "${buildConfigProvider.packageName}.ACTION_START_ACCESSIBILITY_SERVICE" - - private val actionRestartService = - "${buildConfigProvider.packageName}.ACTION_RESTART_ACCESSIBILITY_SERVICE" - - private val actionStopService = - "${buildConfigProvider.packageName}.ACTION_STOP_ACCESSIBILITY_SERVICE" - - private val actionDismissToggleMappings = - "${buildConfigProvider.packageName}.ACTION_DISMISS_TOGGLE_MAPPINGS" - - private val actionShowImePicker = - "${buildConfigProvider.packageName}.ACTION_SHOW_IME_PICKER" - - private val actionShowKeyboard = "${buildConfigProvider.packageName}.ACTION_SHOW_KEYBOARD" - - private val actionToggleKeyboard = - "${buildConfigProvider.packageName}.ACTION_TOGGLE_KEYBOARD" - /** * Open the app and use the String as the Intent action. */ @@ -109,6 +90,7 @@ class NotificationController @Inject constructor( fun init() { manageNotifications.deleteChannel(CHANNEL_ID_WARNINGS) manageNotifications.deleteChannel(CHANNEL_ID_PERSISTENT) + manageNotifications.deleteChannel(CHANNEL_IME_PICKER) manageNotifications.createChannel( NotificationChannelModel( @@ -119,28 +101,10 @@ class NotificationController @Inject constructor( ) combine( - manageNotifications.showToggleMappingsNotification, controlAccessibilityService.serviceState, pauseMappings.isPaused, - ) { show, serviceState, areMappingsPaused -> - invalidateToggleMappingsNotification(show, serviceState, areMappingsPaused) - }.flowOn(dispatchers.default()).launchIn(coroutineScope) - - manageNotifications.showImePickerNotification.onEach { show -> - if (show) { - manageNotifications.createChannel( - NotificationChannelModel( - id = CHANNEL_IME_PICKER, - name = getString(R.string.notification_channel_ime_picker), - NotificationManagerCompat.IMPORTANCE_MIN, - ), - ) - - manageNotifications.show(imePickerNotification()) - } else { - // don't delete the channel because then the user's notification config is lost - manageNotifications.dismiss(ID_IME_PICKER) - } + ) { serviceState, areMappingsPaused -> + invalidateToggleMappingsNotification(serviceState, areMappingsPaused) }.flowOn(dispatchers.default()).launchIn(coroutineScope) toggleCompatibleIme.sufficientPermissions.onEach { canToggleIme -> @@ -188,26 +152,44 @@ class NotificationController @Inject constructor( } else { manageNotifications.dismiss(ID_KEYBOARD_HIDDEN) } - }.flowOn(dispatchers.default()).launchIn(coroutineScope) + }.launchIn(coroutineScope) manageNotifications.onActionClick.onEach { actionId -> when (actionId) { - actionResumeMappings -> pauseMappings.resume() - actionPauseMappings -> pauseMappings.pause() - actionStartService -> attemptStartAccessibilityService() - actionRestartService -> attemptRestartAccessibilityService() - actionStopService -> controlAccessibilityService.stopService() - - actionDismissToggleMappings -> manageNotifications.dismiss(ID_TOGGLE_MAPPINGS) - actionShowImePicker -> showImePicker.show(fromForeground = false) - actionShowKeyboard -> hideInputMethod.show() - actionToggleKeyboard -> toggleCompatibleIme.toggle().onSuccess { - _showToast.emit(getString(R.string.toast_chose_keyboard, it.label)) - }.onFailure { - _showToast.emit(it.getFullMessage(this)) - } + KMNotificationAction.IntentAction.RESUME_KEY_MAPS -> pauseMappings.resume() + KMNotificationAction.IntentAction.PAUSE_KEY_MAPS -> pauseMappings.pause() + KMNotificationAction.IntentAction.DISMISS_TOGGLE_KEY_MAPS_NOTIFICATION -> + manageNotifications.dismiss(ID_TOGGLE_MAPPINGS) + + KMNotificationAction.IntentAction.STOP_ACCESSIBILITY_SERVICE -> + controlAccessibilityService.stopService() + KMNotificationAction.IntentAction.START_ACCESSIBILITY_SERVICE -> + attemptStartAccessibilityService() + KMNotificationAction.IntentAction.RESTART_ACCESSIBILITY_SERVICE -> + attemptRestartAccessibilityService() + KMNotificationAction.IntentAction.TOGGLE_KEY_MAPPER_IME -> + toggleCompatibleIme.toggle() + .onSuccess { + _showToast.emit(getString(R.string.toast_chose_keyboard, it.label)) + }.onFailure { + _showToast.emit(it.getFullMessage(this)) + } + + KMNotificationAction.IntentAction.SHOW_KEYBOARD -> hideInputMethod.show() + else -> Unit // Ignore other notification actions } - }.flowOn(dispatchers.default()).launchIn(coroutineScope) + }.launchIn(coroutineScope) + + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + coroutineScope.launch { + systemBridgeConnectionManager.connectionState + .collect { connectionState -> + if (connectionState is SystemBridgeConnectionState.Connected) { + showSystemBridgeStartedNotification() + } + } + } + } } fun onOpenApp() { @@ -215,7 +197,6 @@ class NotificationController @Inject constructor( coroutineScope.launch { invalidateToggleMappingsNotification( - show = manageNotifications.showToggleMappingsNotification.first(), serviceState = controlAccessibilityService.serviceState.first(), areMappingsPaused = pauseMappings.isPaused.first(), ) @@ -239,23 +220,17 @@ class NotificationController @Inject constructor( } private fun invalidateToggleMappingsNotification( - show: Boolean, serviceState: AccessibilityServiceState, areMappingsPaused: Boolean, ) { manageNotifications.createChannel( NotificationChannelModel( - id = CHANNEL_TOGGLE_KEYMAPS, + id = CHANNEL_TOGGLE_KEY_MAPS, name = getString(R.string.notification_channel_toggle_mappings), NotificationManagerCompat.IMPORTANCE_MIN, ), ) - if (!show) { - manageNotifications.dismiss(ID_TOGGLE_MAPPINGS) - return - } - when (serviceState) { AccessibilityServiceState.ENABLED -> { if (areMappingsPaused) { @@ -274,35 +249,31 @@ class NotificationController @Inject constructor( } private fun mappingsPausedNotification(): NotificationModel { + // Since Notification trampolines are no longer allowed, the notification + // must directly launch the accessibility settings instead of relaying the request + // through a broadcast receiver that eventually calls the ServiceAdapter. val stopServiceAction = if (controlAccessibilityService.isUserInteractionRequired()) { - NotificationIntentType.Activity(Settings.ACTION_ACCESSIBILITY_SETTINGS) + KMNotificationAction.Activity.AccessibilitySettings } else { - NotificationIntentType.Broadcast(actionStopService) + KMNotificationAction.Broadcast.StopAccessibilityService } return NotificationModel( id = ID_TOGGLE_MAPPINGS, - channel = CHANNEL_TOGGLE_KEYMAPS, + channel = CHANNEL_TOGGLE_KEY_MAPS, title = getString(R.string.notification_keymaps_paused_title), text = getString(R.string.notification_keymaps_paused_text), icon = R.drawable.ic_notification_play, - onClickAction = NotificationIntentType.MainActivity(), + onClickAction = KMNotificationAction.Activity.MainActivity(), showOnLockscreen = true, onGoing = true, priority = NotificationCompat.PRIORITY_MIN, actions = listOf( - NotificationModel.Action( + KMNotificationAction.Broadcast.ResumeKeyMaps to getString(R.string.notification_action_resume), - NotificationIntentType.Broadcast(actionResumeMappings), - ), - NotificationModel.Action( + KMNotificationAction.Broadcast.DismissToggleKeyMapsNotification to getString(R.string.notification_action_dismiss), - NotificationIntentType.Broadcast(actionDismissToggleMappings), - ), - NotificationModel.Action( - getString(R.string.notification_action_stop_acc_service), - stopServiceAction, - ), + stopServiceAction to getString(R.string.notification_action_stop_acc_service), ), ) } @@ -312,34 +283,27 @@ class NotificationController @Inject constructor( // must directly launch the accessibility settings instead of relaying the request // through a broadcast receiver that eventually calls the ServiceAdapter. val stopServiceAction = if (controlAccessibilityService.isUserInteractionRequired()) { - NotificationIntentType.Activity(Settings.ACTION_ACCESSIBILITY_SETTINGS) + KMNotificationAction.Activity.AccessibilitySettings } else { - NotificationIntentType.Broadcast(actionStopService) + KMNotificationAction.Broadcast.StopAccessibilityService } return NotificationModel( id = ID_TOGGLE_MAPPINGS, - channel = CHANNEL_TOGGLE_KEYMAPS, + channel = CHANNEL_TOGGLE_KEY_MAPS, title = getString(R.string.notification_keymaps_resumed_title), text = getString(R.string.notification_keymaps_resumed_text), icon = R.drawable.ic_notification_pause, - onClickAction = NotificationIntentType.MainActivity(), + onClickAction = KMNotificationAction.Activity.MainActivity(), showOnLockscreen = true, onGoing = true, priority = NotificationCompat.PRIORITY_MIN, actions = listOf( - NotificationModel.Action( + KMNotificationAction.Broadcast.PauseKeyMaps to getString(R.string.notification_action_pause), - NotificationIntentType.Broadcast(actionPauseMappings), - ), - NotificationModel.Action( + KMNotificationAction.Broadcast.DismissToggleKeyMapsNotification to getString(R.string.notification_action_dismiss), - NotificationIntentType.Broadcast(actionDismissToggleMappings), - ), - NotificationModel.Action( - getString(R.string.notification_action_stop_acc_service), - stopServiceAction, - ), + stopServiceAction to getString(R.string.notification_action_stop_acc_service), ), ) } @@ -348,27 +312,26 @@ class NotificationController @Inject constructor( // Since Notification trampolines are no longer allowed, the notification // must directly launch the accessibility settings instead of relaying the request // through a broadcast receiver that eventually calls the ServiceAdapter. - val onClickAction = if (controlAccessibilityService.isUserInteractionRequired()) { - NotificationIntentType.Activity(Settings.ACTION_ACCESSIBILITY_SETTINGS) + val startServiceAction = if (controlAccessibilityService.isUserInteractionRequired()) { + KMNotificationAction.Activity.AccessibilitySettings } else { - NotificationIntentType.Broadcast(actionStartService) + KMNotificationAction.Broadcast.StartAccessibilityService } return NotificationModel( id = ID_TOGGLE_MAPPINGS, - channel = CHANNEL_TOGGLE_KEYMAPS, + channel = CHANNEL_TOGGLE_KEY_MAPS, title = getString(R.string.notification_accessibility_service_disabled_title), text = getString(R.string.notification_accessibility_service_disabled_text), icon = R.drawable.ic_notification_pause, - onClickAction = onClickAction, + onClickAction = startServiceAction, showOnLockscreen = true, onGoing = true, priority = NotificationCompat.PRIORITY_MIN, actions = listOf( - NotificationModel.Action( + KMNotificationAction.Broadcast.DismissToggleKeyMapsNotification to getString(R.string.notification_action_dismiss), - NotificationIntentType.Broadcast(actionDismissToggleMappings), - ), + ), ) } @@ -377,44 +340,30 @@ class NotificationController @Inject constructor( // Since Notification trampolines are no longer allowed, the notification // must directly launch the accessibility settings instead of relaying the request // through a broadcast receiver that eventually calls the ServiceAdapter. - val onClickAction = if (controlAccessibilityService.isUserInteractionRequired()) { - NotificationIntentType.Activity(Settings.ACTION_ACCESSIBILITY_SETTINGS) + val restartServiceAction = if (controlAccessibilityService.isUserInteractionRequired()) { + KMNotificationAction.Activity.AccessibilitySettings } else { - NotificationIntentType.Broadcast(actionRestartService) + KMNotificationAction.Broadcast.RestartAccessibilityService } return NotificationModel( id = ID_TOGGLE_MAPPINGS, - channel = CHANNEL_TOGGLE_KEYMAPS, + channel = CHANNEL_TOGGLE_KEY_MAPS, title = getString(R.string.notification_accessibility_service_crashed_title), text = getString(R.string.notification_accessibility_service_crashed_text), icon = R.drawable.ic_notification_pause, - onClickAction = onClickAction, + onClickAction = restartServiceAction, showOnLockscreen = true, onGoing = true, priority = NotificationCompat.PRIORITY_MIN, bigTextStyle = true, actions = listOf( - NotificationModel.Action( + restartServiceAction to getString(R.string.notification_action_restart_accessibility_service), - onClickAction, - ), ), ) } - private fun imePickerNotification(): NotificationModel = NotificationModel( - id = ID_IME_PICKER, - channel = CHANNEL_IME_PICKER, - title = getString(R.string.notification_ime_persistent_title), - text = getString(R.string.notification_ime_persistent_text), - icon = R.drawable.ic_notification_keyboard, - onClickAction = NotificationIntentType.Broadcast(actionShowImePicker), - showOnLockscreen = false, - onGoing = true, - priority = NotificationCompat.PRIORITY_MIN, - ) - private fun toggleImeNotification(): NotificationModel = NotificationModel( id = ID_TOGGLE_KEYBOARD, channel = CHANNEL_TOGGLE_KEYBOARD, @@ -425,10 +374,8 @@ class NotificationController @Inject constructor( onGoing = true, priority = NotificationCompat.PRIORITY_MIN, actions = listOf( - NotificationModel.Action( + KMNotificationAction.Broadcast.TogglerKeyMapperIme to getString(R.string.notification_toggle_keyboard_action), - intentType = NotificationIntentType.Broadcast(actionToggleKeyboard), - ), ), ) @@ -438,7 +385,7 @@ class NotificationController @Inject constructor( title = getString(R.string.notification_keyboard_hidden_title), text = getString(R.string.notification_keyboard_hidden_text), icon = R.drawable.ic_notification_keyboard_hide, - onClickAction = NotificationIntentType.Broadcast(actionShowKeyboard), + onClickAction = KMNotificationAction.Broadcast.ShowKeyboard, showOnLockscreen = false, onGoing = true, priority = NotificationCompat.PRIORITY_LOW, @@ -450,11 +397,29 @@ class NotificationController @Inject constructor( title = getString(R.string.notification_floating_buttons_feature_title), text = getString(R.string.notification_floating_buttons_feature_text), icon = R.drawable.outline_bubble_chart_24, - onClickAction = NotificationIntentType.MainActivity(BaseMainActivity.ACTION_USE_FLOATING_BUTTONS), + onClickAction = KMNotificationAction.Activity.MainActivity( + BaseMainActivity.ACTION_USE_FLOATING_BUTTONS, + ), priority = NotificationCompat.PRIORITY_LOW, autoCancel = true, onGoing = false, showOnLockscreen = false, bigTextStyle = true, ) + + private fun showSystemBridgeStartedNotification() { + val model = NotificationModel( + id = ID_SYSTEM_BRIDGE_STATUS, + title = getString(R.string.pro_mode_setup_notification_system_bridge_started_title), + text = getString(R.string.pro_mode_setup_notification_system_bridge_started_text), + channel = CHANNEL_SETUP_ASSISTANT, + icon = R.drawable.pro_mode, + onGoing = false, + showOnLockscreen = false, + autoCancel = true, + timeout = 5000, + ) + + manageNotifications.show(model) + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/AutoGrantPermissionController.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/AutoGrantPermissionController.kt index e00c42515d..6e6cb173ae 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/AutoGrantPermissionController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/AutoGrantPermissionController.kt @@ -1,43 +1,44 @@ package io.github.sds100.keymapper.base.system.permissions import android.Manifest -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.ResourceProvider -import io.github.sds100.keymapper.common.utils.onSuccess import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter -import io.github.sds100.keymapper.system.popup.ToastAdapter +import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn -import javax.inject.Inject +import timber.log.Timber +@Singleton class AutoGrantPermissionController @Inject constructor( private val coroutineScope: CoroutineScope, private val permissionAdapter: PermissionAdapter, - private val popupAdapter: ToastAdapter, - private val resourceProvider: ResourceProvider, + private val shizukuAdapter: ShizukuAdapter, ) { fun start() { // automatically grant WRITE_SECURE_SETTINGS if Key Mapper has root or shizuku permission - combine( - permissionAdapter.isGrantedFlow(Permission.ROOT), - permissionAdapter.isGrantedFlow(Permission.SHIZUKU), - permissionAdapter.isGrantedFlow(Permission.WRITE_SECURE_SETTINGS), - ) { isRootGranted, isShizukuGranted, isWriteSecureSettingsGranted -> + permissionAdapter.isGrantedFlow(Permission.WRITE_SECURE_SETTINGS) + .flatMapLatest { isGranted -> + if (isGranted) { + emptyFlow() + } else { + combine( + permissionAdapter.isGrantedFlow(Permission.ROOT), + permissionAdapter.isGrantedFlow(Permission.SHIZUKU), + shizukuAdapter.isStarted, + ) { isRootGranted, isShizukuGranted, isShizukuStarted -> - if (!isWriteSecureSettingsGranted && (isRootGranted || isShizukuGranted)) { - permissionAdapter.grant(Manifest.permission.WRITE_SECURE_SETTINGS).onSuccess { - val stringRes = if (isRootGranted) { - R.string.toast_granted_itself_write_secure_settings_with_root - } else { - R.string.toast_granted_itself_write_secure_settings_with_shizuku + if (isRootGranted || (isShizukuGranted && isShizukuStarted)) { + Timber.i("Auto-granting WRITE_SECURE_SETTINGS permission") + permissionAdapter.grant(Manifest.permission.WRITE_SECURE_SETTINGS) + } } - - popupAdapter.show(resourceProvider.getString(stringRes)) } - } - }.launchIn(coroutineScope) + }.launchIn(coroutineScope) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt index 4b3c3d8dbd..7db51d9faa 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt @@ -14,7 +14,6 @@ import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.navigation.NavController -import io.github.sds100.keymapper.base.NavBaseAppDirections import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.utils.ui.str import io.github.sds100.keymapper.common.BuildConfigProvider @@ -23,7 +22,6 @@ import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapt import io.github.sds100.keymapper.system.permissions.AndroidPermissionAdapter import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter -import io.github.sds100.keymapper.system.shizuku.ShizukuUtils import io.github.sds100.keymapper.system.url.UrlUtils import splitties.alertdialog.appcompat.messageResource import splitties.alertdialog.appcompat.negativeButton @@ -65,32 +63,35 @@ class RequestPermissionDelegate( Permission.WRITE_SETTINGS -> requestWriteSettings() Permission.CAMERA -> requestPermissionLauncher.launch(Manifest.permission.CAMERA) Permission.DEVICE_ADMIN -> requestDeviceAdmin() - Permission.READ_PHONE_STATE -> requestPermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) + Permission.READ_PHONE_STATE -> requestPermissionLauncher.launch( + Manifest.permission.READ_PHONE_STATE, + ) Permission.ACCESS_NOTIFICATION_POLICY -> requestAccessNotificationPolicy() Permission.WRITE_SECURE_SETTINGS -> requestWriteSecureSettings() Permission.NOTIFICATION_LISTENER -> notificationReceiverAdapter.start() - Permission.CALL_PHONE -> requestPermissionLauncher.launch(Manifest.permission.CALL_PHONE) - Permission.ANSWER_PHONE_CALL -> requestPermissionLauncher.launch(Manifest.permission.ANSWER_PHONE_CALLS) - Permission.FIND_NEARBY_DEVICES -> requestPermissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT) - Permission.ROOT -> { - require(navController != null) { "nav controller can't be null!" } - requestRootPermission(navController) - } + Permission.CALL_PHONE -> requestPermissionLauncher.launch( + Manifest.permission.CALL_PHONE, + ) + Permission.SEND_SMS -> requestPermissionLauncher.launch(Manifest.permission.SEND_SMS) + Permission.ANSWER_PHONE_CALL -> requestPermissionLauncher.launch( + Manifest.permission.ANSWER_PHONE_CALLS, + ) + Permission.FIND_NEARBY_DEVICES -> requestPermissionLauncher.launch( + Manifest.permission.BLUETOOTH_CONNECT, + ) + Permission.ROOT -> requestRootPermission() Permission.IGNORE_BATTERY_OPTIMISATION -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - requestIgnoreBatteryOptimisations() - } + requestIgnoreBatteryOptimisations() - Permission.SHIZUKU -> - if (ShizukuUtils.isSupportedForSdkVersion()) { - shizukuAdapter.requestPermission() - } + Permission.SHIZUKU -> shizukuAdapter.requestPermission() Permission.ACCESS_FINE_LOCATION -> requestPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - Permission.POST_NOTIFICATIONS -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Permission.POST_NOTIFICATIONS -> if (Build.VERSION.SDK_INT >= + Build.VERSION_CODES.TIRAMISU + ) { val showRationale = ActivityCompat.shouldShowRequestPermissionRationale( activity, Manifest.permission.POST_NOTIFICATIONS, @@ -113,10 +114,32 @@ class RequestPermissionDelegate( } private fun requestAccessNotificationPolicy() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS) + val intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS) + + intent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or Intent.FLAG_ACTIVITY_CLEAR_TASK + or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS + // Add this flag so user only has to press back once. + or Intent.FLAG_ACTIVITY_NO_HISTORY, + ) + + try { + startActivityForResultLauncher.launch(intent) + } catch (e: Exception) { + Toast.makeText( + activity, + R.string.error_cant_find_dnd_access_settings, + Toast.LENGTH_SHORT, + ).show() + } + } + + private fun requestWriteSettings() { + Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS).apply { + data = Uri.parse("package:${buildConfigProvider.packageName}") - intent.addFlags( + addFlags( Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS @@ -125,43 +148,17 @@ class RequestPermissionDelegate( ) try { - startActivityForResultLauncher.launch(intent) + activity.startActivity(this) } catch (e: Exception) { Toast.makeText( activity, - R.string.error_cant_find_dnd_access_settings, + R.string.error_cant_find_write_settings_page, Toast.LENGTH_SHORT, ).show() } } } - private fun requestWriteSettings() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS).apply { - data = Uri.parse("package:${buildConfigProvider.packageName}") - - addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK - or Intent.FLAG_ACTIVITY_CLEAR_TASK - or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS - // Add this flag so user only has to press back once. - or Intent.FLAG_ACTIVITY_NO_HISTORY, - ) - - try { - activity.startActivity(this) - } catch (e: Exception) { - Toast.makeText( - activity, - R.string.error_cant_find_write_settings_page, - Toast.LENGTH_SHORT, - ).show() - } - } - } - } - private fun requestWriteSecureSettings() { if (permissionAdapter.isGranted(Permission.SHIZUKU) || permissionAdapter.isGranted(Permission.ROOT) @@ -190,23 +187,18 @@ class RequestPermissionDelegate( } } - private fun requestRootPermission(navController: NavController) { + private fun requestRootPermission() { if (showDialogs) { activity.materialAlertDialog { titleResource = R.string.dialog_title_root_prompt messageResource = R.string.dialog_message_root_prompt setIcon(R.drawable.ic_baseline_warning_24) - okButton { - navController.navigate(NavBaseAppDirections.toSettingsFragment()) - } - + okButton() negativeButton(R.string.neg_cancel) { it.cancel() } show() } - } else { - navController.navigate(NavBaseAppDirections.toSettingsFragment()) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/AssistantTriggerKey.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/AssistantTriggerKey.kt index 0acb6cde87..4a88381e3b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/AssistantTriggerKey.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/AssistantTriggerKey.kt @@ -3,9 +3,9 @@ package io.github.sds100.keymapper.base.trigger import io.github.sds100.keymapper.base.keymaps.ClickType import io.github.sds100.keymapper.data.entities.AssistantTriggerKeyEntity import io.github.sds100.keymapper.data.entities.TriggerKeyEntity +import java.util.UUID import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.util.UUID @Serializable data class AssistantTriggerKey( @@ -18,10 +18,6 @@ data class AssistantTriggerKey( override val clickType: ClickType, ) : TriggerKey() { - // This is always true for an assistant key event because Key Mapper can't forward the - // assistant event to another app (or can it??). - override val consumeEvent: Boolean = true - override val allowedLongPress: Boolean = false override val allowedDoublePress: Boolean = false @@ -37,9 +33,7 @@ data class AssistantTriggerKey( } companion object { - fun fromEntity( - entity: AssistantTriggerKeyEntity, - ): TriggerKey { + fun fromEntity(entity: AssistantTriggerKeyEntity): TriggerKey { val type: AssistantTriggerType = when (entity.type) { AssistantTriggerKeyEntity.ASSISTANT_TYPE_VOICE -> AssistantTriggerType.VOICE AssistantTriggerKeyEntity.ASSISTANT_TYPE_DEVICE -> AssistantTriggerType.DEVICE diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt index 0244f5266b..439ed29438 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt @@ -1,82 +1,73 @@ package io.github.sds100.keymapper.base.trigger import android.view.KeyEvent -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Assistant -import androidx.compose.material.icons.rounded.BubbleChart -import androidx.compose.material.icons.rounded.Fingerprint import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.input.InputEventDetectionSource import io.github.sds100.keymapper.base.keymaps.ClickType import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapOptionsViewModel -import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapUseCase -import io.github.sds100.keymapper.base.keymaps.CreateKeyMapShortcutUseCase import io.github.sds100.keymapper.base.keymaps.DisplayKeyMapUseCase import io.github.sds100.keymapper.base.keymaps.FingerprintGesturesSupportedUseCase import io.github.sds100.keymapper.base.keymaps.KeyMap -import io.github.sds100.keymapper.base.keymaps.ShortcutModel -import io.github.sds100.keymapper.base.onboarding.OnboardingTapTarget +import io.github.sds100.keymapper.base.onboarding.OnboardingTipDelegate +import io.github.sds100.keymapper.base.onboarding.OnboardingTipDelegateImpl import io.github.sds100.keymapper.base.onboarding.OnboardingUseCase -import io.github.sds100.keymapper.base.purchasing.ProductId -import io.github.sds100.keymapper.base.purchasing.PurchasingManager +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate +import io.github.sds100.keymapper.base.shortcuts.CreateKeyMapShortcutUseCase import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType -import io.github.sds100.keymapper.base.utils.InputEventStrings +import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.CheckBoxListItem -import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider -import io.github.sds100.keymapper.base.utils.ui.DialogResponse import io.github.sds100.keymapper.base.utils.ui.LinkType import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.ViewModelHelper -import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo -import io.github.sds100.keymapper.base.utils.ui.showDialog -import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.models.EvdevDeviceInfo +import io.github.sds100.keymapper.common.utils.AccessibilityServiceError +import io.github.sds100.keymapper.common.utils.InputDeviceUtils import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.common.utils.dataOrNull -import io.github.sds100.keymapper.common.utils.ifIsData import io.github.sds100.keymapper.common.utils.mapData -import io.github.sds100.keymapper.common.utils.onSuccess -import io.github.sds100.keymapper.system.devices.InputDeviceUtils -import io.github.sds100.keymapper.system.inputevents.InputEventUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch abstract class BaseConfigTriggerViewModel( - private val coroutineScope: CoroutineScope, private val onboarding: OnboardingUseCase, - private val config: ConfigKeyMapUseCase, - private val recordTrigger: RecordTriggerUseCase, + private val config: ConfigTriggerUseCase, + private val recordTrigger: RecordTriggerController, private val createKeyMapShortcut: CreateKeyMapShortcutUseCase, private val displayKeyMap: DisplayKeyMapUseCase, - private val purchasingManager: PurchasingManager, - private val setupGuiKeyboard: SetupGuiKeyboardUseCase, private val fingerprintGesturesSupported: FingerprintGesturesSupportedUseCase, + private val setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, + onboardingTipDelegate: OnboardingTipDelegate, + triggerSetupDelegate: TriggerSetupDelegate, resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, -) : ResourceProvider by resourceProvider, +) : ViewModel(), + SetupAccessibilityServiceDelegate by setupAccessibilityServiceDelegate, + ResourceProvider by resourceProvider, DialogProvider by dialogProvider, - NavigationProvider by navigationProvider { + NavigationProvider by navigationProvider, + TriggerSetupDelegate by triggerSetupDelegate, + OnboardingTipDelegate by onboardingTipDelegate { companion object { private const val DEVICE_ID_ANY = "any" @@ -84,7 +75,7 @@ abstract class BaseConfigTriggerViewModel( } val optionsViewModel = ConfigKeyMapOptionsViewModel( - coroutineScope, + viewModelScope, config, displayKeyMap, createKeyMapShortcut, @@ -92,83 +83,26 @@ abstract class BaseConfigTriggerViewModel( resourceProvider, ) - private val triggerKeyShortcuts = combine( - fingerprintGesturesSupported.isSupported, - purchasingManager.purchases, - ) { isFingerprintGesturesSupported, purchasesState -> - val newShortcuts = mutableSetOf>() - - if (isFingerprintGesturesSupported == true) { - newShortcuts.add( - ShortcutModel( - icon = ComposeIconInfo.Vector(Icons.Rounded.Fingerprint), - text = getString(R.string.trigger_key_shortcut_add_fingerprint_gesture), - data = TriggerKeyShortcut.FINGERPRINT_GESTURE, - ), - ) - } - - purchasesState.ifIsData { result -> - result.onSuccess { purchases -> - if (purchases.contains(ProductId.ASSISTANT_TRIGGER)) { - newShortcuts.add( - ShortcutModel( - icon = ComposeIconInfo.Vector(Icons.Rounded.Assistant), - text = getString(R.string.trigger_key_shortcut_add_assistant), - data = TriggerKeyShortcut.ASSISTANT, - ), - ) - } - - if (purchases.contains(ProductId.FLOATING_BUTTONS)) { - newShortcuts.add( - ShortcutModel( - icon = ComposeIconInfo.Vector(Icons.Rounded.BubbleChart), - text = getString(R.string.trigger_key_shortcut_add_floating_button), - data = TriggerKeyShortcut.FLOATING_BUTTON, - ), - ) - } - } - } - - newShortcuts - } - private val _state: MutableStateFlow> = MutableStateFlow(State.Loading) val state: StateFlow> = _state.asStateFlow() val recordTriggerState: StateFlow = recordTrigger.state.stateIn( - coroutineScope, + viewModelScope, SharingStarted.Lazily, RecordTriggerState.Idle, ) - var showAdvancedTriggersBottomSheet: Boolean by mutableStateOf(false) - var showDpadTriggerSetupBottomSheet: Boolean by mutableStateOf(false) - var showNoKeysRecordedBottomSheet: Boolean by mutableStateOf(false) - - val setupGuiKeyboardState: StateFlow = combine( - setupGuiKeyboard.isInstalled, - setupGuiKeyboard.isEnabled, - setupGuiKeyboard.isChosen, - ) { isInstalled, isEnabled, isChosen -> - SetupGuiKeyboardState( - isInstalled, - isEnabled, - isChosen, - ) - }.stateIn( - coroutineScope, - SharingStarted.Lazily, - SetupGuiKeyboardState.DEFAULT, - ) + val showFingerprintGesturesShortcut: StateFlow = + fingerprintGesturesSupported.isSupported.map { it ?: false } + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + var showDiscoverTriggersBottomSheet: Boolean by mutableStateOf(false) val triggerKeyOptionsUid = MutableStateFlow(null) val triggerKeyOptionsState: StateFlow = combine(config.keyMap, triggerKeyOptionsUid, transform = ::buildKeyOptionsUiState) - .stateIn(coroutineScope, SharingStarted.Lazily, null) + .stateIn(viewModelScope, SharingStarted.Lazily, null) /** * Check whether the user stopped the trigger recording countdown. This @@ -176,50 +110,32 @@ abstract class BaseConfigTriggerViewModel( * when no buttons are recorded is not shown. */ private var isRecordingCompletionUserInitiated: Boolean = false + private val midDot = getString(R.string.middot) init { - val showTapTargetsPairFlow: Flow> = combine( - onboarding.showTapTarget(OnboardingTapTarget.RECORD_TRIGGER), - onboarding.showTapTarget(OnboardingTapTarget.ADVANCED_TRIGGERS), - ) { recordTriggerTapTarget, advancedTriggersTapTarget -> - Pair(recordTriggerTapTarget, advancedTriggersTapTarget) - } - // IMPORTANT! Do not flow on another thread because this causes the drag and drop // animations to be more janky. combine( displayKeyMap.triggerErrorSnapshot, config.keyMap, displayKeyMap.showDeviceDescriptors, - triggerKeyShortcuts, - showTapTargetsPairFlow, - ) { triggerErrorSnapshot, keyMap, showDeviceDescriptors, shortcuts, showTapTargetsPair -> + ) { triggerErrorSnapshot, keyMap, showDeviceDescriptors -> _state.update { buildUiState( keyMap, showDeviceDescriptors, - shortcuts, triggerErrorSnapshot, - showTapTargetsPair.first, - showTapTargetsPair.second, ) } - }.launchIn(coroutineScope) - - coroutineScope.launch { - recordTrigger.onRecordKey.collectLatest { - onRecordTriggerKey(it) - } - } + }.launchIn(viewModelScope) - coroutineScope.launch { - config.keyMap - .mapNotNull { it.dataOrNull()?.trigger?.mode } - .distinctUntilChanged() - .drop(1) - .collectLatest { mode -> - onTriggerModeChanged(mode) + viewModelScope.launch { + recordTrigger.onRecordKey.collect { key -> + when (key) { + is RecordedKey.EvdevEvent -> onRecordEvdevEvent(key) + is RecordedKey.KeyEvent -> onRecordKeyEvent(key) } + } } // Drop the first state in case it is in the Completed state so the @@ -228,64 +144,54 @@ abstract class BaseConfigTriggerViewModel( recordTrigger.state.drop(1).onEach { state -> if (state is RecordTriggerState.Completed && state.recordedKeys.isEmpty() && - onboarding.showNoKeysDetectedBottomSheet.first() && !isRecordingCompletionUserInitiated ) { - showNoKeysRecordedBottomSheet = true + showTriggerSetup(TriggerSetupShortcut.NOT_DETECTED) } // reset this field when recording has completed isRecordingCompletionUserInitiated = false - }.launchIn(coroutineScope) + }.launchIn(viewModelScope) } - open fun onClickTriggerKeyShortcut(shortcut: TriggerKeyShortcut) { - if (shortcut == TriggerKeyShortcut.FINGERPRINT_GESTURE) { - coroutineScope.launch { - val listItems = listOf( - FingerprintGestureType.SWIPE_DOWN to getString(R.string.fingerprint_gesture_down), - FingerprintGestureType.SWIPE_UP to getString(R.string.fingerprint_gesture_up), - FingerprintGestureType.SWIPE_LEFT to getString(R.string.fingerprint_gesture_left), - FingerprintGestureType.SWIPE_RIGHT to getString(R.string.fingerprint_gesture_right), - ) - - val selectedType = showDialog("pick_assistant_type", DialogModel.SingleChoice(listItems)) - ?: return@launch + override fun onCleared() { + isRecordingCompletionUserInitiated = true + recordTrigger.stopRecording() - config.addFingerprintGesture(type = selectedType) - } - } + super.onCleared() } fun onAdvancedTriggersClick() { onboarding.viewedAdvancedTriggers() - showAdvancedTriggersBottomSheet = true + + viewModelScope.launch { + navigateToAdvancedTriggers("advanced_triggers_click") + } + } + + suspend fun navigateToAdvancedTriggers(navKey: String) { + val result: TriggerSetupShortcut = + navigate(navKey, NavDestination.AdvancedTriggers) ?: return + + showTriggerSetup(result) } private fun buildUiState( keyMapState: State, showDeviceDescriptors: Boolean, - triggerKeyShortcuts: Set>, triggerErrorSnapshot: TriggerErrorSnapshot, - showRecordTriggerTapTarget: Boolean, - showAdvancedTriggersTapTarget: Boolean, ): State { return keyMapState.mapData { keyMap -> val trigger = keyMap.trigger if (trigger.keys.isEmpty()) { - return@mapData ConfigTriggerState.Empty( - triggerKeyShortcuts, - showRecordTriggerTapTarget = showRecordTriggerTapTarget, - showAdvancedTriggersTapTarget = showAdvancedTriggersTapTarget, - ) + return@mapData ConfigTriggerState.Empty } val triggerKeys = createListItems( keyMap, showDeviceDescriptors, - triggerKeyShortcuts.size, triggerErrorSnapshot, ) val isReorderingEnabled = trigger.keys.size > 1 @@ -310,7 +216,10 @@ abstract class BaseConfigTriggerViewModel( clickTypeButtons.add(ClickType.DOUBLE_PRESS) } - if (trigger.keys.isNotEmpty() && trigger.mode !is TriggerMode.Sequence && trigger.keys.all { it.allowedLongPress }) { + if (trigger.keys.isNotEmpty() && + trigger.mode !is TriggerMode.Sequence && + trigger.keys.all { it.allowedLongPress } + ) { clickTypeButtons.add(ClickType.SHORT_PRESS) clickTypeButtons.add(ClickType.LONG_PRESS) } @@ -329,8 +238,6 @@ abstract class BaseConfigTriggerViewModel( triggerModeButtonsEnabled = triggerModeButtonsEnabled, triggerModeButtonsVisible = triggerModeButtonsVisible, checkedTriggerMode = trigger.mode, - shortcuts = triggerKeyShortcuts, - showAdvancedTriggersTapTarget = showAdvancedTriggersTapTarget, ) } } @@ -353,11 +260,11 @@ abstract class BaseConfigTriggerViewModel( val showClickTypes = trigger.mode is TriggerMode.Sequence when (key) { - is KeyCodeTriggerKey -> { + is KeyEventTriggerKey -> { val showDeviceDescriptors = displayKeyMap.showDeviceDescriptors.first() val deviceListItems: List = config.getAvailableTriggerKeyDevices() - .map { device: TriggerKeyDevice -> + .map { device: KeyEventTriggerDevice -> buildDeviceListItem( device = device, showDeviceDescriptors = showDeviceDescriptors, @@ -365,11 +272,15 @@ abstract class BaseConfigTriggerViewModel( ) } - return TriggerKeyOptionsState.KeyCode( + return TriggerKeyOptionsState.KeyEvent( doNotRemapChecked = !key.consumeEvent, clickType = key.clickType, showClickTypes = showClickTypes, devices = deviceListItems, + keyCode = key.keyCode, + scanCode = key.scanCode, + isScanCodeDetectionSelected = key.detectWithScancode(), + isScanCodeSettingEnabled = key.isScanCodeDetectionUserConfigurable(), ) } @@ -394,30 +305,42 @@ abstract class BaseConfigTriggerViewModel( clickType = key.clickType, ) } + + is EvdevTriggerKey -> { + return TriggerKeyOptionsState.EvdevEvent( + doNotRemapChecked = !key.consumeEvent, + clickType = key.clickType, + showClickTypes = showClickTypes, + keyCode = key.keyCode, + scanCode = key.scanCode, + isScanCodeDetectionSelected = key.detectWithScancode(), + isScanCodeSettingEnabled = key.isScanCodeDetectionUserConfigurable(), + ) + } } } } } private fun buildDeviceListItem( - device: TriggerKeyDevice, + device: KeyEventTriggerDevice, isChecked: Boolean, showDeviceDescriptors: Boolean, ): CheckBoxListItem { return when (device) { - TriggerKeyDevice.Any -> CheckBoxListItem( + KeyEventTriggerDevice.Any -> CheckBoxListItem( id = DEVICE_ID_ANY, isChecked = isChecked, label = getString(R.string.any_device), ) - TriggerKeyDevice.Internal -> CheckBoxListItem( + KeyEventTriggerDevice.Internal -> CheckBoxListItem( id = DEVICE_ID_INTERNAL, isChecked = isChecked, label = getString(R.string.this_device), ) - is TriggerKeyDevice.External -> { + is KeyEventTriggerDevice.External -> { val name = if (showDeviceDescriptors) { InputDeviceUtils.appendDeviceDescriptorToName( device.descriptor, @@ -436,92 +359,37 @@ abstract class BaseConfigTriggerViewModel( } } - private suspend fun onTriggerModeChanged(mode: TriggerMode) { - if (mode is TriggerMode.Parallel) { - if (onboarding.shownParallelTriggerOrderExplanation) { - return - } - - val dialog = DialogModel.Ok( - message = getString(R.string.dialog_message_parallel_trigger_order), - ) - - showDialog("parallel_trigger_order", dialog) ?: return - - onboarding.shownParallelTriggerOrderExplanation = true + private suspend fun onRecordKeyEvent(key: RecordedKey.KeyEvent) { + val triggerDevice = if (key.isExternalDevice) { + KeyEventTriggerDevice.External(key.deviceDescriptor, key.deviceName) + } else { + KeyEventTriggerDevice.Internal } - if (mode is TriggerMode.Sequence) { - if (onboarding.shownSequenceTriggerExplanation) { - return - } - - val dialog = DialogModel.Ok( - message = getString(R.string.dialog_message_sequence_trigger_explanation), - ) - - showDialog("sequence_trigger_explanation", dialog) - ?: return - - onboarding.shownSequenceTriggerExplanation = true - } + config.addKeyEventTriggerKey( + key.keyCode, + key.scanCode, + triggerDevice, + key.detectionSource != InputEventDetectionSource.ACCESSIBILITY_SERVICE, + ) } - private suspend fun onRecordTriggerKey(key: RecordedKey) { - // Add the trigger key before showing the dialog so it doesn't - // need to be dismissed before it is added. - config.addKeyCodeTriggerKey(key.keyCode, key.device, key.detectionSource) - - if (key.keyCode >= InputEventUtils.KEYCODE_TO_SCANCODE_OFFSET || key.keyCode < 0) { - if (onboarding.shownKeyCodeToScanCodeTriggerExplanation) { - return - } - - val dialog = DialogModel.Alert( - title = getString(R.string.dialog_title_keycode_to_scancode_trigger_explanation), - message = getString(R.string.dialog_message_keycode_to_scancode_trigger_explanation), - positiveButtonText = getString(R.string.pos_understood), - ) - - val response = showDialog("keycode_to_scancode_message", dialog) - - if (response == DialogResponse.POSITIVE) { - onboarding.shownKeyCodeToScanCodeTriggerExplanation = true - } - } - - if (key.keyCode == KeyEvent.KEYCODE_CAPS_LOCK) { - val dialog = DialogModel.Ok( - message = getString(R.string.dialog_message_enable_physical_keyboard_caps_lock_a_keyboard_layout), - ) - - showDialog("caps_lock_message", dialog) - } - - if (key.keyCode == KeyEvent.KEYCODE_BACK) { - val dialog = DialogModel.Ok( - message = getString(R.string.dialog_message_screen_pinning_warning), - ) - - showDialog("screen_pinning_message", dialog) - } - - // Issue #491. Some key codes can only be detected through an input method. This will - // be shown to the user by showing a keyboard icon next to the trigger key name so - // explain this to the user. - if (key.detectionSource == KeyEventDetectionSource.INPUT_METHOD && displayKeyMap.showTriggerKeyboardIconExplanation.first()) { - val dialog = DialogModel.Alert( - title = getString(R.string.dialog_title_keyboard_icon_means_ime_detection), - message = getString(R.string.dialog_message_keyboard_icon_means_ime_detection), - negativeButtonText = getString(R.string.neg_dont_show_again), - positiveButtonText = getString(R.string.pos_ok), - ) - - val response = showDialog("keyboard_icon_explanation", dialog) + private suspend fun onRecordEvdevEvent(key: RecordedKey.EvdevEvent) { + config.addEvdevTriggerKey( + key.keyCode, + key.scanCode, + EvdevDeviceInfo( + name = key.device.name, + bus = key.device.bus, + vendor = key.device.vendor, + product = key.device.product, + ), + ) - if (response == DialogResponse.NEGATIVE) { - displayKeyMap.neverShowTriggerKeyboardIconExplanation() - } + if (key.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + key.keyCode == KeyEvent.KEYCODE_VOLUME_UP + ) { + neverShowTipAgain(OnboardingTipDelegateImpl.VOLUME_BUTTONS_PRO_MODE_TIP_ID) } } @@ -563,15 +431,15 @@ abstract class BaseConfigTriggerViewModel( fun onSelectTriggerKeyDevice(descriptor: String) { triggerKeyOptionsUid.value?.let { triggerKeyUid -> val device = when (descriptor) { - DEVICE_ID_ANY -> TriggerKeyDevice.Any - DEVICE_ID_INTERNAL -> TriggerKeyDevice.Internal + DEVICE_ID_ANY -> KeyEventTriggerDevice.Any + DEVICE_ID_INTERNAL -> KeyEventTriggerDevice.Internal else -> { val device = config.getAvailableTriggerKeyDevices() - .filterIsInstance() + .filterIsInstance() .firstOrNull { it.descriptor == descriptor } ?: return - TriggerKeyDevice.External( + KeyEventTriggerDevice.External( device.descriptor, device.name, ) @@ -597,11 +465,17 @@ abstract class BaseConfigTriggerViewModel( } } + fun onSelectScanCodeDetection(isSelected: Boolean) { + triggerKeyOptionsUid.value?.let { triggerKeyUid -> + config.setScanCodeDetectionEnabled(triggerKeyUid, isSelected) + } + } + fun onRecordTriggerButtonClick() { - coroutineScope.launch { + viewModelScope.launch { val recordTriggerState = recordTrigger.state.firstOrNull() ?: return@launch - val result = when (recordTriggerState) { + val result: KMResult<*> = when (recordTriggerState) { is RecordTriggerState.CountingDown -> { isRecordingCompletionUserInitiated = true recordTrigger.stopRecording() @@ -609,7 +483,7 @@ abstract class BaseConfigTriggerViewModel( is RecordTriggerState.Completed, RecordTriggerState.Idle, - -> recordTrigger.startRecording() + -> recordTrigger.startRecording(enableEvdevRecording = false) } // Show dialog if the accessibility service is disabled or crashed @@ -617,37 +491,39 @@ abstract class BaseConfigTriggerViewModel( } } - suspend fun handleServiceEventResult(result: KMResult<*>) { - if (result is KMError.AccessibilityServiceDisabled) { - ViewModelHelper.handleAccessibilityServiceStoppedDialog( - resourceProvider = this@BaseConfigTriggerViewModel, - dialogProvider = this@BaseConfigTriggerViewModel, - startService = displayKeyMap::startAccessibilityService, - ) + fun handleServiceEventResult(result: KMResult<*>) { + if (result is AccessibilityServiceError) { + showFixAccessibilityServiceDialog(result) } + } - if (result is KMError.AccessibilityServiceCrashed) { - ViewModelHelper.handleAccessibilityServiceCrashedDialog( - resourceProvider = this@BaseConfigTriggerViewModel, - dialogProvider = this@BaseConfigTriggerViewModel, - restartService = displayKeyMap::restartAccessibilityService, - ) + override fun onTipButtonClick(tipId: String) { + when (tipId) { + OnboardingTipDelegateImpl.CAPS_LOCK_PRO_MODE_COMPATIBILITY_TIP_ID -> { + showTriggerSetup(TriggerSetupShortcut.KEYBOARD, forceProMode = true) + } + + OnboardingTipDelegateImpl.VOLUME_BUTTONS_PRO_MODE_TIP_ID -> { + showTriggerSetup(TriggerSetupShortcut.VOLUME, forceProMode = true) + } } } open fun onTriggerErrorClick(error: TriggerError) { - coroutineScope.launch { + viewModelScope.launch { when (error) { TriggerError.DND_ACCESS_DENIED -> ViewModelHelper.showDialogExplainingDndAccessBeingUnavailable( resourceProvider = this@BaseConfigTriggerViewModel, dialogProvider = this@BaseConfigTriggerViewModel, - neverShowDndTriggerErrorAgain = { displayKeyMap.neverShowDndTriggerError() }, + neverShowDndTriggerErrorAgain = { + displayKeyMap.neverShowDndTriggerError() + }, fixError = { displayKeyMap.fixTriggerError(error) }, ) TriggerError.DPAD_IME_NOT_SELECTED -> { - showDpadTriggerSetupBottomSheet = true + showTriggerSetup(TriggerSetupShortcut.GAMEPAD) } else -> displayKeyMap.fixTriggerError(error) @@ -658,7 +534,6 @@ abstract class BaseConfigTriggerViewModel( private fun createListItems( keyMap: KeyMap, showDeviceDescriptors: Boolean, - shortcutCount: Int, triggerErrorSnapshot: TriggerErrorSnapshot, ): List { val trigger = keyMap.trigger @@ -672,10 +547,9 @@ abstract class BaseConfigTriggerViewModel( key.clickType } - val linkType = when { - trigger.mode is TriggerMode.Sequence && (index < trigger.keys.lastIndex) -> LinkType.ARROW - (index < trigger.keys.lastIndex) -> LinkType.PLUS - else -> LinkType.HIDDEN + val linkType = when (trigger.mode) { + is TriggerMode.Sequence -> LinkType.ARROW + else -> LinkType.PLUS } when (key) { @@ -695,11 +569,11 @@ abstract class BaseConfigTriggerViewModel( error = error, ) - is KeyCodeTriggerKey -> TriggerKeyListItemModel.KeyCode( + is KeyEventTriggerKey -> TriggerKeyListItemModel.KeyEvent( id = key.uid, keyName = getTriggerKeyName(key), clickType = clickType, - extraInfo = getTriggerKeyExtraInfo( + extraInfo = getKeyEventTriggerKeyExtraInfo( key, showDeviceDescriptors, ).takeIf { it.isNotBlank() }, @@ -725,17 +599,35 @@ abstract class BaseConfigTriggerViewModel( ) } } + + is EvdevTriggerKey -> TriggerKeyListItemModel.EvdevEvent( + id = key.uid, + keyName = key.getCodeLabel(this), + clickType = clickType, + extraInfo = getEvdevTriggerKeyExtraInfo(key), + linkType = linkType, + error = error, + ) + } + } + } + + private fun getEvdevTriggerKeyExtraInfo(key: EvdevTriggerKey): String { + return buildString { + append(key.device.name) + + if (!key.consumeEvent) { + append(" $midDot ${getString(R.string.flag_dont_override_default_action)}") } } } - private fun getTriggerKeyExtraInfo( - key: KeyCodeTriggerKey, + private fun getKeyEventTriggerKeyExtraInfo( + key: KeyEventTriggerKey, showDeviceDescriptors: Boolean, ): String { return buildString { append(getTriggerKeyDeviceName(key.device, showDeviceDescriptors)) - val midDot = getString(R.string.middot) if (!key.consumeEvent) { append(" $midDot ${getString(R.string.flag_dont_override_default_action)}") @@ -743,24 +635,23 @@ abstract class BaseConfigTriggerViewModel( } } - private fun getTriggerKeyName(key: KeyCodeTriggerKey): String { + private fun getTriggerKeyName(key: KeyEventTriggerKey): String { return buildString { - append(InputEventStrings.keyCodeToString(key.keyCode)) + append(key.getCodeLabel(this@BaseConfigTriggerViewModel)) - if (key.detectionSource == KeyEventDetectionSource.INPUT_METHOD) { - val midDot = getString(R.string.middot) + if (key.requiresIme) { append(" $midDot ${getString(R.string.flag_detect_from_input_method)}") } } } private fun getTriggerKeyDeviceName( - device: TriggerKeyDevice, + device: KeyEventTriggerDevice, showDeviceDescriptors: Boolean, ): String = when (device) { - is TriggerKeyDevice.Internal -> getString(R.string.this_device) - is TriggerKeyDevice.Any -> getString(R.string.any_device) - is TriggerKeyDevice.External -> { + is KeyEventTriggerDevice.Internal -> getString(R.string.this_device) + is KeyEventTriggerDevice.Any -> getString(R.string.any_device) + is KeyEventTriggerDevice.External -> { if (showDeviceDescriptors) { InputDeviceUtils.appendDeviceDescriptorToName( device.descriptor, @@ -772,49 +663,12 @@ abstract class BaseConfigTriggerViewModel( } } - fun onEnableGuiKeyboardClick() { - coroutineScope.launch { - setupGuiKeyboard.enableInputMethod() - } - } - - fun onChooseGuiKeyboardClick() { - setupGuiKeyboard.chooseInputMethod() - } - - fun onNeverShowSetupDpadClick() { - displayKeyMap.neverShowDpadImeSetupError() - } - - fun onNeverShowNoKeysRecordedClick() { - onboarding.neverShowNoKeysRecordedBottomSheet() - } - - fun onRecordTriggerTapTargetCompleted() { - onboarding.completedTapTarget(OnboardingTapTarget.RECORD_TRIGGER) - } - - fun onSkipTapTargetClick() { - onboarding.skipTapTargetOnboarding() - } - - fun onAdvancedTriggersTapTargetCompleted() { - onboarding.completedTapTarget(OnboardingTapTarget.ADVANCED_TRIGGERS) - } - abstract fun onEditFloatingButtonClick() abstract fun onEditFloatingLayoutClick() } sealed class ConfigTriggerState { - abstract val shortcuts: Set> - abstract val showAdvancedTriggersTapTarget: Boolean - - data class Empty( - override val shortcuts: Set> = emptySet(), - val showRecordTriggerTapTarget: Boolean = false, - override val showAdvancedTriggersTapTarget: Boolean = false, - ) : ConfigTriggerState() + data object Empty : ConfigTriggerState() data class Loaded( val triggerKeys: List = emptyList(), @@ -824,8 +678,6 @@ sealed class ConfigTriggerState { val checkedTriggerMode: TriggerMode = TriggerMode.Undefined, val triggerModeButtonsEnabled: Boolean = false, val triggerModeButtonsVisible: Boolean = false, - override val shortcuts: Set> = emptySet(), - override val showAdvancedTriggersTapTarget: Boolean = false, ) : ConfigTriggerState() } @@ -835,7 +687,16 @@ sealed class TriggerKeyListItemModel { abstract val error: TriggerError? abstract val clickType: ClickType - data class KeyCode( + data class KeyEvent( + override val id: String, + override val linkType: LinkType, + val keyName: String, + override val clickType: ClickType, + val extraInfo: String?, + override val error: TriggerError?, + ) : TriggerKeyListItemModel() + + data class EvdevEvent( override val id: String, override val linkType: LinkType, val keyName: String, @@ -884,11 +745,31 @@ sealed class TriggerKeyOptionsState { abstract val showClickTypes: Boolean abstract val showLongPressClickType: Boolean - data class KeyCode( + data class KeyEvent( val doNotRemapChecked: Boolean = false, override val clickType: ClickType, override val showClickTypes: Boolean, val devices: List, + val keyCode: Int, + val scanCode: Int?, + // Whether scan code is checked. + val isScanCodeDetectionSelected: Boolean, + // Whether the setting should be enabled and allow user interaction. + val isScanCodeSettingEnabled: Boolean, + ) : TriggerKeyOptionsState() { + override val showLongPressClickType: Boolean = true + } + + data class EvdevEvent( + val doNotRemapChecked: Boolean = false, + override val clickType: ClickType, + override val showClickTypes: Boolean, + val keyCode: Int, + val scanCode: Int, + // Whether scan code is checked. + val isScanCodeDetectionSelected: Boolean, + // Whether the setting should be enabled and allow user interaction. + val isScanCodeSettingEnabled: Boolean, ) : TriggerKeyOptionsState() { override val showLongPressClickType: Boolean = true } 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 48b54e7ae8..f6f80c4fdd 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 @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.trigger +import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,28 +11,24 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Fingerprint import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Icon import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope 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.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -41,44 +38,51 @@ import androidx.window.core.layout.WindowWidthSizeClass import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.keymaps.ClickType -import io.github.sds100.keymapper.base.keymaps.ShortcutModel -import io.github.sds100.keymapper.base.keymaps.ShortcutRow +import io.github.sds100.keymapper.base.keymaps.ShortcutButton +import io.github.sds100.keymapper.base.onboarding.TipCard import io.github.sds100.keymapper.base.utils.ui.LinkType -import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.compose.DraggableItem -import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow +import io.github.sds100.keymapper.base.utils.ui.compose.icons.ActionKey +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons import io.github.sds100.keymapper.base.utils.ui.compose.rememberDragDropState import io.github.sds100.keymapper.common.utils.State +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun BaseTriggerScreen(modifier: Modifier = Modifier, viewModel: BaseConfigTriggerViewModel) { +fun BaseTriggerScreen( + modifier: Modifier = Modifier, + viewModel: BaseConfigTriggerViewModel, + discoverScreenContent: @Composable () -> Unit = {}, +) { + val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val setupGuiKeyboardState by viewModel.setupGuiKeyboardState.collectAsStateWithLifecycle() val recordTriggerState by viewModel.recordTriggerState.collectAsStateWithLifecycle() + val showFingerprintGestures: Boolean by + viewModel.showFingerprintGesturesShortcut.collectAsStateWithLifecycle() - if (viewModel.showDpadTriggerSetupBottomSheet) { - DpadTriggerSetupBottomSheet( - modifier = Modifier.systemBarsPadding(), - onDismissRequest = { - viewModel.showDpadTriggerSetupBottomSheet = false - }, - guiKeyboardState = setupGuiKeyboardState, - onEnableKeyboardClick = viewModel::onEnableGuiKeyboardClick, - onChooseKeyboardClick = viewModel::onChooseGuiKeyboardClick, - onNeverShowAgainClick = viewModel::onNeverShowSetupDpadClick, - sheetState = sheetState, - ) - } + HandleTriggerSetupBottomSheet(viewModel) - if (viewModel.showNoKeysRecordedBottomSheet) { - NoKeysRecordedBottomSheet( - modifier = Modifier.systemBarsPadding(), + if (viewModel.showDiscoverTriggersBottomSheet) { + TriggerDiscoverBottomSheet( + sheetState = sheetState, onDismissRequest = { - viewModel.showNoKeysRecordedBottomSheet = false + viewModel.showDiscoverTriggersBottomSheet = false + }, + content = { + TriggerDiscoverScreen( + showFloatingButtons = true, + showFingerprintGestures = showFingerprintGestures, + onShortcutClick = { shortcut -> + scope.launch { + sheetState.hide() + viewModel.showDiscoverTriggersBottomSheet = false + viewModel.showTriggerSetup(shortcut) + } + }, + ) }, - viewModel = viewModel, - sheetState = sheetState, ) } @@ -86,7 +90,6 @@ fun BaseTriggerScreen(modifier: Modifier = Modifier, viewModel: BaseConfigTrigge if (triggerKeyOptionsState != null) { TriggerKeyOptionsBottomSheet( - modifier = Modifier.systemBarsPadding(), sheetState = sheetState, state = triggerKeyOptionsState!!, onDismissRequest = viewModel::onDismissTriggerKeyOptions, @@ -97,14 +100,34 @@ fun BaseTriggerScreen(modifier: Modifier = Modifier, viewModel: BaseConfigTrigge onEditFloatingButtonClick = viewModel::onEditFloatingButtonClick, onEditFloatingLayoutClick = viewModel::onEditFloatingLayoutClick, onSelectFingerprintGestureType = viewModel::onSelectFingerprintGestureType, + onScanCodeDetectionChanged = viewModel::onSelectScanCodeDetection, ) } val configState by viewModel.state.collectAsStateWithLifecycle() + val tipModel by viewModel.triggerTip.collectAsStateWithLifecycle() when (val state = configState) { is State.Loading -> Loading(modifier = modifier) is State.Data -> { + val tipContent: @Composable () -> Unit = { + tipModel?.let { tip -> + TipCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = tip.title, + message = tip.message, + isDismissable = tip.isDismissable, + onDismiss = viewModel::onTriggerTipDismissClick, + buttonText = tip.buttonText, + onButtonClick = { viewModel.onTipButtonClick(tip.id) }, + ) + + Spacer(Modifier.height(8.dp)) + } + } + if (isHorizontalLayout()) { TriggerScreenHorizontal( modifier = modifier, @@ -119,10 +142,11 @@ fun BaseTriggerScreen(modifier: Modifier = Modifier, viewModel: BaseConfigTrigge onSelectSequenceMode = viewModel::onSequenceRadioButtonChecked, onMoveTriggerKey = viewModel::onMoveTriggerKey, onFixErrorClick = viewModel::onTriggerErrorClick, - onClickShortcut = viewModel::onClickTriggerKeyShortcut, - onRecordTriggerTapTargetCompleted = viewModel::onRecordTriggerTapTargetCompleted, - onSkipTapTarget = viewModel::onSkipTapTargetClick, - onAdvancedTriggerTapTargetCompleted = viewModel::onAdvancedTriggersTapTargetCompleted, + onAddMoreTriggerKeysClick = { + viewModel.showDiscoverTriggersBottomSheet = true + }, + discoverScreenContent = discoverScreenContent, + tipContent = tipContent, ) } else { TriggerScreenVertical( @@ -138,10 +162,11 @@ fun BaseTriggerScreen(modifier: Modifier = Modifier, viewModel: BaseConfigTrigge onSelectSequenceMode = viewModel::onSequenceRadioButtonChecked, onMoveTriggerKey = viewModel::onMoveTriggerKey, onFixErrorClick = viewModel::onTriggerErrorClick, - onClickShortcut = viewModel::onClickTriggerKeyShortcut, - onRecordTriggerTapTargetCompleted = viewModel::onRecordTriggerTapTargetCompleted, - onSkipTapTarget = viewModel::onSkipTapTargetClick, - onAdvancedTriggerTapTargetCompleted = viewModel::onAdvancedTriggersTapTargetCompleted, + onAddMoreTriggerKeysClick = { + viewModel.showDiscoverTriggersBottomSheet = true + }, + discoverScreenContent = discoverScreenContent, + tipContent = tipContent, ) } } @@ -159,7 +184,8 @@ private fun isHorizontalLayout(): Boolean { private fun isVerticalCompactLayout(): Boolean { val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - return windowSizeClass.windowHeightSizeClass == WindowHeightSizeClass.COMPACT && windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT + return windowSizeClass.windowHeightSizeClass == WindowHeightSizeClass.COMPACT && + windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT } @Composable @@ -183,95 +209,85 @@ private fun TriggerScreenVertical( onAdvancedTriggersClick: () -> Unit = {}, onMoveTriggerKey: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, onFixErrorClick: (TriggerError) -> Unit = {}, - onClickShortcut: (TriggerKeyShortcut) -> Unit = {}, - onRecordTriggerTapTargetCompleted: () -> Unit = {}, - onSkipTapTarget: () -> Unit = {}, - onAdvancedTriggerTapTargetCompleted: () -> Unit = {}, + onAddMoreTriggerKeysClick: () -> Unit = {}, + discoverScreenContent: @Composable () -> Unit = {}, + tipContent: @Composable () -> Unit = {}, ) { Surface(modifier = modifier) { Column { + val isCompact = isVerticalCompactLayout() + when (configState) { is ConfigTriggerState.Empty -> { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(state = rememberScrollState()), - verticalArrangement = Arrangement.Center, - ) { - Text( - modifier = Modifier.padding(32.dp), - text = stringResource(R.string.triggers_recyclerview_placeholder), - textAlign = TextAlign.Center, - ) - - if (configState.shortcuts.isNotEmpty()) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(R.string.trigger_shortcuts_header), - style = MaterialTheme.typography.titleSmall, - ) - - Spacer(Modifier.height(8.dp)) - - ShortcutRow( - modifier = Modifier - .padding(horizontal = 32.dp) - .fillMaxWidth(), - shortcuts = configState.shortcuts, - onClick = onClickShortcut, - ) - } + Column { + Box( + modifier = Modifier + .weight(1f) + .padding(16.dp), + ) { + discoverScreenContent() } + + RecordTriggerButtonRow( + modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp), + onRecordTriggerClick = onRecordTriggerClick, + recordTriggerState = recordTriggerState, + onAdvancedTriggersClick = onAdvancedTriggersClick, + ) } } is ConfigTriggerState.Loaded -> { - val isCompact = isVerticalCompactLayout() Spacer(Modifier.height(8.dp)) + tipContent() + TriggerList( modifier = Modifier.weight(1f), triggerList = configState.triggerKeys, - shortcuts = configState.shortcuts, isReorderingEnabled = configState.isReorderingEnabled, onEditClick = onEditClick, onRemoveClick = onRemoveClick, onMove = onMoveTriggerKey, - onClickShortcut = onClickShortcut, onFixErrorClick = onFixErrorClick, + onAddMoreClick = onAddMoreTriggerKeysClick, ) if (configState.clickTypeButtons.isNotEmpty()) { - ClickTypeRadioGroup( - modifier = Modifier.padding(horizontal = 8.dp), + ClickTypeSegmentedButtons( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), clickTypes = configState.clickTypeButtons, checkedClickType = configState.checkedClickType, onSelectClickType = onSelectClickType, - maxLines = if (isCompact) 1 else 2, + isCompact = isCompact, ) - } - if (configState.triggerModeButtonsVisible) { if (!isCompact) { - Text( - modifier = Modifier.padding(horizontal = 8.dp), - text = stringResource(R.string.press_dot_dot_dot), - style = MaterialTheme.typography.labelLarge, - ) + Spacer(Modifier.height(8.dp)) } + } - TriggerModeRadioGroup( - modifier = Modifier.padding(horizontal = 8.dp), + if (configState.triggerModeButtonsVisible) { + TriggerModeSegmentedButtons( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), mode = configState.checkedTriggerMode, isEnabled = configState.triggerModeButtonsEnabled, onSelectParallelMode = onSelectParallelMode, onSelectSequenceMode = onSelectSequenceMode, - maxLines = if (isCompact) 1 else 2, + isCompact = isCompact, ) } } } + if (!isCompact) { + Spacer(Modifier.height(8.dp)) + } + RecordTriggerButtonRow( modifier = Modifier .fillMaxWidth() @@ -279,12 +295,6 @@ private fun TriggerScreenVertical( onRecordTriggerClick = onRecordTriggerClick, recordTriggerState = recordTriggerState, onAdvancedTriggersClick = onAdvancedTriggersClick, - showRecordTriggerTapTarget = (configState as? ConfigTriggerState.Empty)?.showRecordTriggerTapTarget - ?: false, - onRecordTriggerTapTargetCompleted = onRecordTriggerTapTargetCompleted, - onSkipTapTarget = onSkipTapTarget, - showAdvancedTriggerTapTarget = configState.showAdvancedTriggersTapTarget, - onAdvancedTriggerTapTargetCompleted = onAdvancedTriggerTapTargetCompleted, ) } } @@ -304,60 +314,30 @@ private fun TriggerScreenHorizontal( onAdvancedTriggersClick: () -> Unit = {}, onMoveTriggerKey: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, onFixErrorClick: (TriggerError) -> Unit = {}, - onClickShortcut: (TriggerKeyShortcut) -> Unit = {}, - onRecordTriggerTapTargetCompleted: () -> Unit = {}, - onSkipTapTarget: () -> Unit = {}, - onAdvancedTriggerTapTargetCompleted: () -> Unit = {}, + onAddMoreTriggerKeysClick: () -> Unit = {}, + discoverScreenContent: @Composable () -> Unit = {}, + tipContent: @Composable () -> Unit = {}, ) { Surface(modifier = modifier) { when (configState) { is ConfigTriggerState.Empty -> Row { - Text( + Box( modifier = Modifier - .widthIn(max = 400.dp) - .padding(32.dp) - .verticalScroll(state = rememberScrollState()), - text = stringResource(R.string.triggers_recyclerview_placeholder), - textAlign = TextAlign.Center, - ) - Column { - if (configState.shortcuts.isNotEmpty()) { - Column( - modifier = Modifier - .weight(1f) - .verticalScroll(state = rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource(R.string.trigger_shortcuts_header), - style = MaterialTheme.typography.titleSmall, - ) - - Spacer(Modifier.height(8.dp)) - - ShortcutRow( - modifier = Modifier - .padding(horizontal = 32.dp) - .fillMaxWidth(), - shortcuts = configState.shortcuts, - onClick = onClickShortcut, - ) - } - } - - RecordTriggerButtonRow( - modifier = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 8.dp), - onRecordTriggerClick = onRecordTriggerClick, - recordTriggerState = recordTriggerState, - onAdvancedTriggersClick = onAdvancedTriggersClick, - showRecordTriggerTapTarget = (configState as? ConfigTriggerState.Empty)?.showRecordTriggerTapTarget - ?: false, - onRecordTriggerTapTargetCompleted = onRecordTriggerTapTargetCompleted, - onSkipTapTarget = onSkipTapTarget, - showAdvancedTriggerTapTarget = configState.showAdvancedTriggersTapTarget, - ) + .weight(1f) + .padding(16.dp), + ) { + discoverScreenContent() } + + RecordTriggerButtonRow( + modifier = Modifier + .align(Alignment.Bottom) + .weight(1f) + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), + onRecordTriggerClick = onRecordTriggerClick, + recordTriggerState = recordTriggerState, + onAdvancedTriggersClick = onAdvancedTriggersClick, + ) } is ConfigTriggerState.Loaded -> Row { @@ -366,13 +346,12 @@ private fun TriggerScreenHorizontal( .fillMaxHeight() .widthIn(max = 400.dp), triggerList = configState.triggerKeys, - shortcuts = configState.shortcuts, isReorderingEnabled = configState.isReorderingEnabled, onEditClick = onEditClick, onRemoveClick = onRemoveClick, onMove = onMoveTriggerKey, - onClickShortcut = onClickShortcut, onFixErrorClick = onFixErrorClick, + onAddMoreClick = onAddMoreTriggerKeysClick, ) Spacer(Modifier.height(8.dp)) @@ -386,30 +365,38 @@ private fun TriggerScreenHorizontal( .weight(1f) .verticalScroll(rememberScrollState()), ) { + Spacer(modifier = Modifier.height(16.dp)) + + tipContent() + if (configState.clickTypeButtons.isNotEmpty()) { - ClickTypeRadioGroup( - modifier = Modifier.padding(horizontal = 8.dp), + ClickTypeSegmentedButtons( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), clickTypes = configState.clickTypeButtons, checkedClickType = configState.checkedClickType, onSelectClickType = onSelectClickType, + isCompact = false, ) } - Text( - modifier = Modifier.padding(horizontal = 8.dp), - text = stringResource(R.string.press_dot_dot_dot), - style = MaterialTheme.typography.labelLarge, - ) + Spacer(modifier = Modifier.height(8.dp)) if (configState.triggerModeButtonsVisible) { - TriggerModeRadioGroup( - modifier = Modifier.padding(horizontal = 8.dp), + TriggerModeSegmentedButtons( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), mode = configState.checkedTriggerMode, isEnabled = configState.triggerModeButtonsEnabled, onSelectParallelMode = onSelectParallelMode, onSelectSequenceMode = onSelectSequenceMode, + isCompact = false, ) } + + Spacer(modifier = Modifier.height(16.dp)) } RecordTriggerButtonRow( @@ -417,11 +404,6 @@ private fun TriggerScreenHorizontal( onRecordTriggerClick = onRecordTriggerClick, recordTriggerState = recordTriggerState, onAdvancedTriggersClick = onAdvancedTriggersClick, - showRecordTriggerTapTarget = false, - onRecordTriggerTapTargetCompleted = onRecordTriggerTapTargetCompleted, - onSkipTapTarget = onSkipTapTarget, - showAdvancedTriggerTapTarget = configState.showAdvancedTriggersTapTarget, - onAdvancedTriggerTapTargetCompleted = onAdvancedTriggerTapTargetCompleted, ) } } @@ -433,20 +415,19 @@ private fun TriggerScreenHorizontal( private fun TriggerList( modifier: Modifier = Modifier, triggerList: List, - shortcuts: Set>, isReorderingEnabled: Boolean, onRemoveClick: (String) -> Unit, onEditClick: (String) -> Unit, onFixErrorClick: (TriggerError) -> Unit, onMove: (fromIndex: Int, toIndex: Int) -> Unit, - onClickShortcut: (TriggerKeyShortcut) -> Unit, + onAddMoreClick: () -> Unit, ) { val lazyListState = rememberLazyListState() val dragDropState = rememberDragDropState( lazyListState = lazyListState, onMove = onMove, - // Do not drag and drop the row of shortcuts - ignoreLastItems = if (shortcuts.isEmpty()) { + // Do not drag and drop the "add more" button + ignoreLastItems = if (triggerList.isEmpty()) { 0 } else { 1 @@ -459,6 +440,7 @@ private fun TriggerList( modifier = modifier, state = lazyListState, contentPadding = PaddingValues(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { itemsIndexed( triggerList, @@ -484,103 +466,102 @@ private fun TriggerList( } } - if (shortcuts.isNotEmpty()) { - item(key = "shortcuts", contentType = "shortcuts") { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(R.string.trigger_shortcuts_header), - style = MaterialTheme.typography.titleSmall, - ) - - Spacer(Modifier.height(8.dp)) - - ShortcutRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - shortcuts = shortcuts, - onClick = { onClickShortcut(it) }, - ) - } + if (triggerList.isNotEmpty()) { + item(key = "add_more", contentType = "add_more") { + ShortcutButton( + onClick = onAddMoreClick, + text = stringResource(R.string.trigger_list_add_more_button), + icon = { + Icon(KeyMapperIcons.ActionKey, contentDescription = null) + }, + ) } } } } @Composable -private fun ClickTypeRadioGroup( +private fun ClickTypeSegmentedButtons( modifier: Modifier = Modifier, clickTypes: Set, checkedClickType: ClickType?, onSelectClickType: (ClickType) -> Unit, - maxLines: Int = 2, + isCompact: Boolean, ) { - Column(modifier = modifier) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - if (clickTypes.contains(ClickType.SHORT_PRESS)) { - RadioButtonText( - modifier = Modifier.weight(1f), - isSelected = checkedClickType == ClickType.SHORT_PRESS, - text = stringResource(R.string.radio_button_short_press), - onSelected = { onSelectClickType(ClickType.SHORT_PRESS) }, - maxLines = maxLines, - ) + // Always put the buttons in the same order + val clickTypeButtonContent: List> = buildList { + if (clickTypes.contains(ClickType.SHORT_PRESS)) { + val text = if (isCompact) { + stringResource(R.string.radio_button_short) + } else { + stringResource(R.string.radio_button_short_press) } - if (clickTypes.contains(ClickType.LONG_PRESS)) { - RadioButtonText( - modifier = Modifier.weight(1f), - isSelected = checkedClickType == ClickType.LONG_PRESS, - text = stringResource(R.string.radio_button_long_press), - onSelected = { onSelectClickType(ClickType.LONG_PRESS) }, - maxLines = maxLines, - ) + add(ClickType.SHORT_PRESS to text) + } + + if (clickTypes.contains(ClickType.LONG_PRESS)) { + val text = if (isCompact) { + stringResource(R.string.radio_button_long) + } else { + stringResource(R.string.radio_button_long_press) } - if (clickTypes.contains(ClickType.DOUBLE_PRESS)) { - RadioButtonText( - modifier = Modifier.weight(1f), - isSelected = checkedClickType == ClickType.DOUBLE_PRESS, - text = stringResource(R.string.radio_button_double_press), - onSelected = { onSelectClickType(ClickType.DOUBLE_PRESS) }, - maxLines = maxLines, - ) + add(ClickType.LONG_PRESS to text) + } + + if (clickTypes.contains(ClickType.DOUBLE_PRESS)) { + val text = if (isCompact) { + stringResource(R.string.radio_button_double) + } else { + stringResource(R.string.radio_button_double_press) } + add(ClickType.DOUBLE_PRESS to text) } } + + KeyMapperSegmentedButtonRow( + modifier = modifier, + buttonStates = clickTypeButtonContent, + selectedState = checkedClickType, + onStateSelected = onSelectClickType, + isCompact = isCompact, + ) } @Composable -private fun TriggerModeRadioGroup( +private fun TriggerModeSegmentedButtons( modifier: Modifier = Modifier, mode: TriggerMode, isEnabled: Boolean, onSelectParallelMode: () -> Unit, onSelectSequenceMode: () -> Unit, - maxLines: Int = 2, + isCompact: Boolean, ) { - Column(modifier = modifier) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - RadioButtonText( - modifier = Modifier.weight(1f), - isSelected = mode is TriggerMode.Parallel, - isEnabled = isEnabled, - text = stringResource(R.string.radio_button_parallel), - onSelected = onSelectParallelMode, - maxLines = maxLines, - ) - RadioButtonText( - modifier = Modifier.weight(1f), - isSelected = mode == TriggerMode.Sequence, - isEnabled = isEnabled, - text = stringResource(R.string.radio_button_sequence), - onSelected = onSelectSequenceMode, - maxLines = maxLines, - ) - } - } + val triggerModeButtonContent = listOf( + "parallel" to stringResource(R.string.radio_button_parallel), + "sequence" to stringResource(R.string.radio_button_sequence), + ) + + KeyMapperSegmentedButtonRow( + modifier = modifier, + buttonStates = triggerModeButtonContent, + selectedState = when (mode) { + is TriggerMode.Parallel -> "parallel" + TriggerMode.Sequence -> "sequence" + TriggerMode.Undefined -> null + }, + onStateSelected = { selectedMode -> + when (selectedMode) { + "parallel" -> onSelectParallelMode() + "sequence" -> onSelectSequenceMode() + } + }, + isCompact = isCompact, + isEnabled = isEnabled, + ) } private val sampleList = listOf( - TriggerKeyListItemModel.KeyCode( + TriggerKeyListItemModel.KeyEvent( id = "id1", keyName = "Volume Up", clickType = ClickType.SHORT_PRESS, @@ -600,7 +581,7 @@ private val sampleList = listOf( id = "id3", assistantType = AssistantTriggerType.DEVICE, clickType = ClickType.DOUBLE_PRESS, - linkType = LinkType.HIDDEN, + linkType = LinkType.PLUS, error = null, ), ) @@ -618,13 +599,6 @@ private val previewState = checkedTriggerMode = TriggerMode.Sequence, triggerModeButtonsEnabled = true, triggerModeButtonsVisible = true, - shortcuts = setOf( - ShortcutModel( - icon = ComposeIconInfo.Vector(Icons.Rounded.Fingerprint), - text = "Fingerprint gesture", - data = TriggerKeyShortcut.FINGERPRINT_GESTURE, - ), - ), ) @Preview(device = Devices.PIXEL) @@ -634,17 +608,23 @@ private fun VerticalPreview() { TriggerScreenVertical( configState = previewState, recordTriggerState = RecordTriggerState.Idle, + discoverScreenContent = { + TriggerDiscoverScreen() + }, ) } } -@Preview(heightDp = 400, widthDp = 300) +@Preview(heightDp = 300, widthDp = 300) @Composable private fun VerticalPreviewTiny() { KeyMapperTheme { TriggerScreenVertical( configState = previewState, recordTriggerState = RecordTriggerState.Idle, + discoverScreenContent = { + TriggerDiscoverScreen() + }, ) } } @@ -654,16 +634,25 @@ private fun VerticalPreviewTiny() { private fun VerticalEmptyPreview() { KeyMapperTheme { TriggerScreenVertical( - configState = ConfigTriggerState.Empty( - shortcuts = setOf( - ShortcutModel( - icon = ComposeIconInfo.Vector(Icons.Rounded.Fingerprint), - text = "Fingerprint gesture", - data = TriggerKeyShortcut.FINGERPRINT_GESTURE, - ), - ), - ), + configState = ConfigTriggerState.Empty, recordTriggerState = RecordTriggerState.Idle, + discoverScreenContent = { + TriggerDiscoverScreen() + }, + ) + } +} + +@Preview(device = Devices.PIXEL, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun VerticalEmptyDarkPreview() { + KeyMapperTheme { + TriggerScreenVertical( + configState = ConfigTriggerState.Empty, + recordTriggerState = RecordTriggerState.Idle, + discoverScreenContent = { + TriggerDiscoverScreen() + }, ) } } @@ -675,6 +664,24 @@ private fun HorizontalPreview() { TriggerScreenHorizontal( configState = previewState, recordTriggerState = RecordTriggerState.Idle, + discoverScreenContent = { + TriggerDiscoverScreen() + }, + tipContent = { + TipCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = "Tip Title", + message = """ + This is a tip message to help the user understand something about the + current screen. It can be quite long so it should wrap properly. + """.trimIndent(), + onDismiss = {}, + ) + + Spacer(Modifier.height(8.dp)) + }, ) } } @@ -684,17 +691,11 @@ private fun HorizontalPreview() { private fun HorizontalEmptyPreview() { KeyMapperTheme { TriggerScreenHorizontal( - configState = ConfigTriggerState.Empty( - shortcuts = setOf( - ShortcutModel( - icon = ComposeIconInfo.Vector(Icons.Rounded.Fingerprint), - text = "Fingerprint gesture", - data = TriggerKeyShortcut.FINGERPRINT_GESTURE, - ), - ), - - ), + configState = ConfigTriggerState.Empty, recordTriggerState = RecordTriggerState.Idle, + discoverScreenContent = { + TriggerDiscoverScreen() + }, ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ChooseTriggerKeyDeviceModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ChooseTriggerKeyDeviceModel.kt index efc216cd3f..be95ca0e54 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ChooseTriggerKeyDeviceModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ChooseTriggerKeyDeviceModel.kt @@ -2,5 +2,5 @@ package io.github.sds100.keymapper.base.trigger data class ChooseTriggerKeyDeviceModel( val triggerKeyUid: String, - val devices: List, + val devices: List, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt new file mode 100644 index 0000000000..65c30dfc77 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt @@ -0,0 +1,509 @@ +package io.github.sds100.keymapper.base.trigger + +import io.github.sds100.keymapper.base.floating.FloatingButtonData +import io.github.sds100.keymapper.base.keymaps.ClickType +import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.common.models.EvdevDeviceInfo +import io.github.sds100.keymapper.system.inputevents.KeyEventUtils + +/** + * This extracts the core logic when configuring a trigger which makes it easier to write tests. + */ +class ConfigTriggerDelegate { + + fun addFloatingButtonTriggerKey( + trigger: Trigger, + buttonUid: String, + button: FloatingButtonData?, + ): Trigger { + val clickType = when (trigger.mode) { + is TriggerMode.Parallel -> trigger.mode.clickType + TriggerMode.Sequence -> ClickType.SHORT_PRESS + TriggerMode.Undefined -> ClickType.SHORT_PRESS + } + + val triggerKey = FloatingButtonKey( + buttonUid = buttonUid, + button = button, + clickType = clickType, + ) + + return addTriggerKey(trigger, triggerKey) + } + + fun addAssistantTriggerKey(trigger: Trigger, type: AssistantTriggerType): Trigger { + val clickType = when (trigger.mode) { + is TriggerMode.Parallel -> trigger.mode.clickType + TriggerMode.Sequence -> ClickType.SHORT_PRESS + TriggerMode.Undefined -> ClickType.SHORT_PRESS + } + + val triggerKey = AssistantTriggerKey(type = type, clickType = clickType) + + return addTriggerKey(trigger, triggerKey) + } + + fun addFingerprintGesture(trigger: Trigger, type: FingerprintGestureType): Trigger { + val clickType = when (trigger.mode) { + is TriggerMode.Parallel -> trigger.mode.clickType + TriggerMode.Sequence -> ClickType.SHORT_PRESS + TriggerMode.Undefined -> ClickType.SHORT_PRESS + } + + val triggerKey = FingerprintTriggerKey(type = type, clickType = clickType) + + return addTriggerKey(trigger, triggerKey) + } + + /** + * @param otherTriggerKeys This needs to check the other triggers in the app so that it can + * enable scancode detection by default in some situations. + */ + fun addKeyEventTriggerKey( + trigger: Trigger, + keyCode: Int, + scanCode: Int, + device: KeyEventTriggerDevice, + requiresIme: Boolean, + otherTriggerKeys: List = emptyList(), + ): Trigger { + val isPowerKey = KeyEventUtils.isPowerButtonKey(keyCode, scanCode) + + val clickType = if (isPowerKey) { + ClickType.LONG_PRESS + } else { + when (trigger.mode) { + is TriggerMode.Parallel -> trigger.mode.clickType + TriggerMode.Sequence -> ClickType.SHORT_PRESS + TriggerMode.Undefined -> ClickType.SHORT_PRESS + } + } + + var consumeKeyEvent = true + + // Issue #753 + if (KeyEventUtils.isModifierKey(keyCode)) { + consumeKeyEvent = false + } + + // Scan code detection should be turned on by default if there are other + // keys from the same device that report the same key code but have a different scan code. + val logicallyEqualKeys = otherTriggerKeys.plus(trigger.keys) + .filterIsInstance() + // Assume that keys without a scan code come from the same device so ignore them. + // The scan code was not saved on versions older than 4.0 + .filter { it.scanCode != null } + .filter { + it.keyCode == keyCode && + it.scanCode != scanCode && + it.device == device + } + + val triggerKey = KeyEventTriggerKey( + keyCode = keyCode, + device = device, + clickType = clickType, + scanCode = scanCode, + consumeEvent = consumeKeyEvent, + requiresIme = requiresIme, + detectWithScanCodeUserSetting = logicallyEqualKeys.isNotEmpty(), + ) + + var newKeys = trigger.keys.filter { it !is EvdevTriggerKey } + + if (isPowerKey && trigger.mode is TriggerMode.Parallel) { + newKeys = newKeys.map { it.setClickType(ClickType.LONG_PRESS) } + } + + val newMode = if (isPowerKey && trigger.mode is TriggerMode.Parallel) { + TriggerMode.Parallel(ClickType.LONG_PRESS) + } else { + trigger.mode + } + + return addTriggerKey(trigger.copy(mode = newMode, keys = newKeys), triggerKey) + } + + fun addEvdevTriggerKey( + trigger: Trigger, + keyCode: Int, + scanCode: Int, + device: EvdevDeviceInfo, + otherTriggerKeys: List = emptyList(), + ): Trigger { + val isPowerKey = KeyEventUtils.isPowerButtonKey(keyCode, scanCode) + + val clickType = if (isPowerKey) { + ClickType.LONG_PRESS + } else { + when (trigger.mode) { + is TriggerMode.Parallel -> trigger.mode.clickType + TriggerMode.Sequence -> ClickType.SHORT_PRESS + TriggerMode.Undefined -> ClickType.SHORT_PRESS + } + } + + // Scan code detection should be turned on by default if there are other + // keys from the same device that report the same key code but have a different scan code. + val conflictingKeys = otherTriggerKeys.plus(trigger.keys) + .filterIsInstance() + .filter { + it.keyCode == keyCode && + it.scanCode != scanCode && + it.device == device + } + + val triggerKey = EvdevTriggerKey( + keyCode = keyCode, + scanCode = scanCode, + device = device, + clickType = clickType, + consumeEvent = true, + detectWithScanCodeUserSetting = conflictingKeys.isNotEmpty(), + ) + + var newKeys = trigger.keys.filter { it !is KeyEventTriggerKey } + + if (isPowerKey && trigger.mode is TriggerMode.Parallel) { + newKeys = newKeys.map { it.setClickType(ClickType.LONG_PRESS) } + } + + val newMode = if (isPowerKey && trigger.mode is TriggerMode.Parallel) { + TriggerMode.Parallel(ClickType.LONG_PRESS) + } else { + trigger.mode + } + + return addTriggerKey(trigger.copy(mode = newMode, keys = newKeys), triggerKey) + } + + private fun addTriggerKey(trigger: Trigger, key: TriggerKey): Trigger { + // Check whether the trigger already contains the key because if so + // then it must be converted to a sequence trigger. + val containsKey = trigger.keys.any { otherKey -> key.isLogicallyEqual(otherKey) } + + var newKeys: List = trigger.keys.plus(key) + + val newMode = when { + trigger.mode != TriggerMode.Sequence && containsKey -> TriggerMode.Sequence + newKeys.size <= 1 -> TriggerMode.Undefined + + /* Automatically make it a parallel trigger when the user makes a trigger with more than one key + because this is what most users are expecting when they make a trigger with multiple keys */ + newKeys.size == 2 && !containsKey -> { + newKeys = newKeys.map { it.setClickType(key.clickType) } + TriggerMode.Parallel(key.clickType) + } + + else -> trigger.mode + } + + return trigger.copy(keys = newKeys, mode = newMode).validate() + } + + fun removeTriggerKey(trigger: Trigger, uid: String): Trigger { + val newKeys = trigger.keys.toMutableList().apply { + removeAll { it.uid == uid } + } + + val newMode = when { + newKeys.size <= 1 -> TriggerMode.Undefined + else -> trigger.mode + } + + return trigger.copy(keys = newKeys, mode = newMode).validate() + } + + fun moveTriggerKey(trigger: Trigger, fromIndex: Int, toIndex: Int): Trigger { + // Don't need to validate. This should be low latency so moving is responsive. + return trigger.copy( + keys = trigger.keys.toMutableList().apply { + add(toIndex, removeAt(fromIndex)) + }, + ) + } + + fun setParallelTriggerMode(trigger: Trigger): Trigger { + if (trigger.mode is TriggerMode.Parallel) { + return trigger + } + + // undefined mode only allowed if one or no keys + if (trigger.keys.size <= 1) { + return trigger.copy(mode = TriggerMode.Undefined) + } + + val oldKeys = trigger.keys + val newKeys: MutableList = mutableListOf() + + // In a parallel trigger keys must be triggered by different key events + outerLoop@ for (key in oldKeys) { + for (other in newKeys) { + if (key.isLogicallyEqual(other)) { + continue@outerLoop + } + } + + // set all the keys to a short press if coming from a non-parallel trigger + // because they must all be the same click type and can't all be double pressed + newKeys.add(key.setClickType(ClickType.SHORT_PRESS)) + } + + return trigger + .copy(keys = newKeys, mode = TriggerMode.Parallel(ClickType.SHORT_PRESS)) + .validate() + } + + fun setSequenceTriggerMode(trigger: Trigger): Trigger { + if (trigger.mode == TriggerMode.Sequence) return trigger + // undefined mode only allowed if one or no keys + if (trigger.keys.size <= 1) { + return trigger.copy(mode = TriggerMode.Undefined) + } + + return trigger.copy(mode = TriggerMode.Sequence).validate() + } + + fun setUndefinedTriggerMode(trigger: Trigger): Trigger { + if (trigger.mode == TriggerMode.Undefined) return trigger + + // undefined mode only allowed if one or no keys + if (trigger.keys.size > 1) { + return trigger + } + + return trigger.copy(mode = TriggerMode.Undefined).validate() + } + + fun setTriggerShortPress(trigger: Trigger): Trigger { + if (trigger.mode == TriggerMode.Sequence) { + return trigger + } + + val newKeys = trigger.keys.map { it.setClickType(clickType = ClickType.SHORT_PRESS) } + val newMode = if (newKeys.size <= 1) { + TriggerMode.Undefined + } else { + TriggerMode.Parallel(ClickType.SHORT_PRESS) + } + return trigger.copy(keys = newKeys, mode = newMode).validate() + } + + fun setTriggerLongPress(trigger: Trigger): Trigger { + if (trigger.mode == TriggerMode.Sequence) { + return trigger + } + + // You can't set the trigger to a long press if it contains a key + // that isn't detected with key codes. This is because there aren't + // separate key events for the up and down press that can be timed. + if (trigger.keys.any { !it.allowedLongPress }) { + return trigger + } + + val newKeys = trigger.keys.map { it.setClickType(clickType = ClickType.LONG_PRESS) } + val newMode = if (newKeys.size <= 1) { + TriggerMode.Undefined + } else { + TriggerMode.Parallel(ClickType.LONG_PRESS) + } + + return trigger.copy(keys = newKeys, mode = newMode).validate() + } + + fun setTriggerDoublePress(trigger: Trigger): Trigger { + if (trigger.mode != TriggerMode.Undefined) { + return trigger + } + + if (trigger.keys.any { !it.allowedDoublePress }) { + return trigger + } + + val newKeys = trigger.keys.map { it.setClickType(clickType = ClickType.DOUBLE_PRESS) } + val newMode = TriggerMode.Undefined + + return trigger.copy(keys = newKeys, mode = newMode).validate() + } + + fun setTriggerKeyClickType(trigger: Trigger, keyUid: String, clickType: ClickType): Trigger { + val newKeys = trigger.keys.map { + if (it.uid == keyUid) { + it.setClickType(clickType = clickType) + } else { + it + } + } + + return trigger.copy(keys = newKeys).validate() + } + + fun setTriggerKeyDevice( + trigger: Trigger, + keyUid: String, + device: KeyEventTriggerDevice, + ): Trigger { + val newKeys = trigger.keys.map { key -> + if (key.uid == keyUid) { + if (key !is KeyEventTriggerKey) { + throw IllegalArgumentException( + "You can not set the device for non KeyEventTriggerKeys.", + ) + } + + key.copy(device = device) + } else { + key + } + } + + return trigger.copy(keys = newKeys).validate() + } + + fun setTriggerKeyConsumeKeyEvent( + trigger: Trigger, + keyUid: String, + consumeKeyEvent: Boolean, + ): Trigger { + val newKeys = trigger.keys.map { key -> + if (key.uid == keyUid) { + when (key) { + is KeyEventTriggerKey -> { + key.copy(consumeEvent = consumeKeyEvent) + } + + is EvdevTriggerKey -> { + key.copy(consumeEvent = consumeKeyEvent) + } + + else -> { + key + } + } + } else { + key + } + } + + return trigger.copy(keys = newKeys).validate() + } + + fun setAssistantTriggerKeyType( + trigger: Trigger, + keyUid: String, + type: AssistantTriggerType, + ): Trigger { + val newKeys = trigger.keys.map { key -> + if (key.uid == keyUid) { + if (key is AssistantTriggerKey) { + key.copy(type = type) + } else { + key + } + } else { + key + } + } + + return trigger.copy(keys = newKeys).validate() + } + + fun setFingerprintGestureType( + trigger: Trigger, + keyUid: String, + type: FingerprintGestureType, + ): Trigger { + val newKeys = trigger.keys.map { key -> + if (key.uid == keyUid) { + if (key is FingerprintTriggerKey) { + key.copy(type = type) + } else { + key + } + } else { + key + } + } + + return trigger.copy(keys = newKeys).validate() + } + + fun setVibrateEnabled(trigger: Trigger, enabled: Boolean): Trigger { + return trigger.copy(vibrate = enabled).validate() + } + + fun setVibrationDuration( + trigger: Trigger, + duration: Int, + defaultVibrateDuration: Int, + ): Trigger { + return if (duration == defaultVibrateDuration) { + trigger.copy(vibrateDuration = null).validate() + } else { + trigger.copy(vibrateDuration = duration).validate() + } + } + + fun setLongPressDelay(trigger: Trigger, delay: Int, defaultLongPressDelay: Int): Trigger { + return if (delay == defaultLongPressDelay) { + trigger.copy(longPressDelay = null).validate() + } else { + trigger.copy(longPressDelay = delay).validate() + } + } + + fun setDoublePressDelay(trigger: Trigger, delay: Int, defaultDoublePressDelay: Int): Trigger { + return if (delay == defaultDoublePressDelay) { + trigger.copy(doublePressDelay = null).validate() + } else { + trigger.copy(doublePressDelay = delay).validate() + } + } + + fun setSequenceTriggerTimeout( + trigger: Trigger, + delay: Int, + defaultSequenceTriggerTimeout: Int, + ): Trigger { + return if (delay == defaultSequenceTriggerTimeout) { + trigger.copy(sequenceTriggerTimeout = null).validate() + } else { + trigger.copy(sequenceTriggerTimeout = delay).validate() + } + } + + fun setLongPressDoubleVibrationEnabled(trigger: Trigger, enabled: Boolean): Trigger { + return trigger.copy(longPressDoubleVibration = enabled).validate() + } + + fun setTriggerFromOtherAppsEnabled(trigger: Trigger, enabled: Boolean): Trigger { + return trigger.copy(triggerFromOtherApps = enabled).validate() + } + + fun setShowToastEnabled(trigger: Trigger, enabled: Boolean): Trigger { + return trigger.copy(showToast = enabled).validate() + } + + fun setScanCodeDetectionEnabled(trigger: Trigger, keyUid: String, enabled: Boolean): Trigger { + val newKeys = trigger.keys.map { otherKey -> + if (otherKey.uid == keyUid && + otherKey is KeyCodeTriggerKey && + otherKey.isScanCodeDetectionUserConfigurable() + ) { + when (otherKey) { + is KeyEventTriggerKey -> { + otherKey.copy(detectWithScanCodeUserSetting = enabled) + } + + is EvdevTriggerKey -> { + otherKey.copy(detectWithScanCodeUserSetting = enabled) + } + } + } else { + otherKey + } + } + + return trigger.copy(keys = newKeys).validate() + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerUseCase.kt new file mode 100644 index 0000000000..9c6a10005e --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerUseCase.kt @@ -0,0 +1,345 @@ +package io.github.sds100.keymapper.base.trigger + +import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.base.floating.FloatingButtonEntityMapper +import io.github.sds100.keymapper.base.keymaps.ClickType +import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState +import io.github.sds100.keymapper.base.keymaps.GetDefaultKeyMapOptionsUseCase +import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.common.models.EvdevDeviceInfo +import io.github.sds100.keymapper.common.utils.InputDeviceUtils +import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.dataOrNull +import io.github.sds100.keymapper.common.utils.firstBlocking +import io.github.sds100.keymapper.data.Keys +import io.github.sds100.keymapper.data.entities.AssistantTriggerKeyEntity +import io.github.sds100.keymapper.data.entities.EvdevTriggerKeyEntity +import io.github.sds100.keymapper.data.entities.FingerprintTriggerKeyEntity +import io.github.sds100.keymapper.data.entities.FloatingButtonKeyEntity +import io.github.sds100.keymapper.data.entities.KeyEventTriggerKeyEntity +import io.github.sds100.keymapper.data.entities.KeyMapEntity +import io.github.sds100.keymapper.data.repositories.FloatingButtonRepository +import io.github.sds100.keymapper.data.repositories.FloatingLayoutRepository +import io.github.sds100.keymapper.data.repositories.KeyMapRepository +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.system.devices.DevicesAdapter +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +@ViewModelScoped +class ConfigTriggerUseCaseImpl @Inject constructor( + private val state: ConfigKeyMapState, + private val preferenceRepository: PreferenceRepository, + private val floatingButtonRepository: FloatingButtonRepository, + private val devicesAdapter: DevicesAdapter, + private val floatingLayoutRepository: FloatingLayoutRepository, + private val getDefaultKeyMapOptionsUseCase: GetDefaultKeyMapOptionsUseCase, + private val keyMapRepository: KeyMapRepository, +) : ConfigTriggerUseCase, + GetDefaultKeyMapOptionsUseCase by getDefaultKeyMapOptionsUseCase { + override val keyMap: StateFlow> = state.keyMap + + override val floatingButtonToUse: MutableStateFlow = state.floatingButtonToUse + + private val showDeviceDescriptors: Flow = + preferenceRepository.get(Keys.showDeviceDescriptors).map { it == true } + + private val delegate: ConfigTriggerDelegate = ConfigTriggerDelegate() + + // This class is viewmodel scoped so this will be recomputed each time + // the user starts configuring a key map + private val otherTriggerKeys: List by lazy { + keyMapRepository.keyMapList + .filterIsInstance>>() + .map { state -> state.data.flatMap { it.trigger.keys } } + .map { keys -> + keys + .mapNotNull { key -> + when (key) { + is EvdevTriggerKeyEntity -> EvdevTriggerKey.fromEntity(key) + is KeyEventTriggerKeyEntity -> KeyEventTriggerKey.fromEntity(key) + is AssistantTriggerKeyEntity, + is FingerprintTriggerKeyEntity, + is FloatingButtonKeyEntity, + -> null + } + }.filterIsInstance() + }.firstBlocking() + } + + override fun setEnabled(enabled: Boolean) { + state.update { it.copy(isEnabled = enabled) } + } + + override suspend fun getFloatingLayoutCount(): Int { + return floatingLayoutRepository.count() + } + + override suspend fun addFloatingButtonTriggerKey(buttonUid: String) { + floatingButtonToUse.update { null } + + val button = floatingButtonRepository.get(buttonUid) + ?.let { entity -> + FloatingButtonEntityMapper.fromEntity( + entity.button, + entity.layout.name, + ) + } + + updateTrigger { trigger -> + delegate.addFloatingButtonTriggerKey(trigger, buttonUid, button) + } + } + + override fun addAssistantTriggerKey(type: AssistantTriggerType) = updateTrigger { trigger -> + delegate.addAssistantTriggerKey(trigger, type) + } + + override fun addFingerprintGesture(type: FingerprintGestureType) = updateTrigger { trigger -> + delegate.addFingerprintGesture(trigger, type) + } + + override suspend fun addKeyEventTriggerKey( + keyCode: Int, + scanCode: Int, + device: KeyEventTriggerDevice, + requiresIme: Boolean, + ) = updateTrigger { trigger -> + delegate.addKeyEventTriggerKey( + trigger, + keyCode, + scanCode, + device, + requiresIme, + otherTriggerKeys = otherTriggerKeys, + ) + } + + override suspend fun addEvdevTriggerKey(keyCode: Int, scanCode: Int, device: EvdevDeviceInfo) = + updateTrigger { trigger -> + delegate.addEvdevTriggerKey( + trigger, + keyCode, + scanCode, + device, + otherTriggerKeys = otherTriggerKeys, + ) + } + + override fun removeTriggerKey(uid: String) = updateTrigger { trigger -> + delegate.removeTriggerKey(trigger, uid) + } + + override fun moveTriggerKey(fromIndex: Int, toIndex: Int) = updateTrigger { trigger -> + delegate.moveTriggerKey(trigger, fromIndex, toIndex) + } + + override fun getTriggerKey(uid: String): TriggerKey? { + return state.keyMap.value.dataOrNull()?.trigger?.keys?.find { it.uid == uid } + } + + override fun setParallelTriggerMode() = updateTrigger { trigger -> + delegate.setParallelTriggerMode(trigger) + } + + override fun setSequenceTriggerMode() = updateTrigger { trigger -> + delegate.setSequenceTriggerMode(trigger) + } + + override fun setUndefinedTriggerMode() = updateTrigger { trigger -> + delegate.setUndefinedTriggerMode(trigger) + } + + override fun setTriggerShortPress() { + updateTrigger { trigger -> + delegate.setTriggerShortPress(trigger) + } + } + + override fun setTriggerLongPress() { + updateTrigger { trigger -> + delegate.setTriggerLongPress(trigger) + } + } + + override fun setTriggerDoublePress() { + updateTrigger { trigger -> + delegate.setTriggerDoublePress(trigger) + } + } + + override fun setTriggerKeyClickType(keyUid: String, clickType: ClickType) { + updateTrigger { trigger -> + delegate.setTriggerKeyClickType(trigger, keyUid, clickType) + } + } + + override fun setTriggerKeyDevice(keyUid: String, device: KeyEventTriggerDevice) { + updateTrigger { trigger -> + delegate.setTriggerKeyDevice(trigger, keyUid, device) + } + } + + override fun setTriggerKeyConsumeKeyEvent(keyUid: String, consumeKeyEvent: Boolean) { + updateTrigger { trigger -> + delegate.setTriggerKeyConsumeKeyEvent(trigger, keyUid, consumeKeyEvent) + } + } + + override fun setAssistantTriggerKeyType(keyUid: String, type: AssistantTriggerType) { + updateTrigger { trigger -> + delegate.setAssistantTriggerKeyType(trigger, keyUid, type) + } + } + + override fun setFingerprintGestureType(keyUid: String, type: FingerprintGestureType) { + updateTrigger { trigger -> + delegate.setFingerprintGestureType(trigger, keyUid, type) + } + } + + override fun setVibrateEnabled(enabled: Boolean) = updateTrigger { trigger -> + delegate.setVibrateEnabled(trigger, enabled) + } + + override fun setVibrationDuration(duration: Int) = updateTrigger { trigger -> + delegate.setVibrationDuration(trigger, duration, defaultVibrateDuration.value) + } + + override fun setLongPressDelay(delay: Int) = updateTrigger { trigger -> + delegate.setLongPressDelay(trigger, delay, defaultLongPressDelay.value) + } + + override fun setDoublePressDelay(delay: Int) { + updateTrigger { trigger -> + delegate.setDoublePressDelay(trigger, delay, defaultDoublePressDelay.value) + } + } + + override fun setSequenceTriggerTimeout(delay: Int) { + updateTrigger { trigger -> + delegate.setSequenceTriggerTimeout(trigger, delay, defaultSequenceTriggerTimeout.value) + } + } + + override fun setLongPressDoubleVibrationEnabled(enabled: Boolean) { + updateTrigger { trigger -> + delegate.setLongPressDoubleVibrationEnabled(trigger, enabled) + } + } + + override fun setTriggerFromOtherAppsEnabled(enabled: Boolean) { + updateTrigger { trigger -> + delegate.setTriggerFromOtherAppsEnabled(trigger, enabled) + } + } + + override fun setShowToastEnabled(enabled: Boolean) { + updateTrigger { trigger -> + delegate.setShowToastEnabled(trigger, enabled) + } + } + + override fun setScanCodeDetectionEnabled(keyUid: String, enabled: Boolean) { + updateTrigger { trigger -> + delegate.setScanCodeDetectionEnabled(trigger, keyUid, enabled) + } + } + + override fun getAvailableTriggerKeyDevices(): List { + val externalKeyEventTriggerDevices = sequence { + val inputDevices = + devicesAdapter.connectedInputDevices.value.dataOrNull() ?: emptyList() + + val showDeviceDescriptors = showDeviceDescriptors.firstBlocking() + + for (device in inputDevices) { + if (device.isExternal) { + val name = if (showDeviceDescriptors) { + InputDeviceUtils.appendDeviceDescriptorToName( + device.descriptor, + device.name, + ) + } else { + device.name + } + + yield(KeyEventTriggerDevice.External(device.descriptor, name)) + } + } + } + + return sequence { + yield(KeyEventTriggerDevice.Internal) + yield(KeyEventTriggerDevice.Any) + yieldAll(externalKeyEventTriggerDevices) + }.toList() + } + + private fun updateTrigger(block: (trigger: Trigger) -> Trigger) { + state.update { keyMap -> + val newTrigger = block(keyMap.trigger) + + keyMap.copy(trigger = newTrigger) + } + } +} + +interface ConfigTriggerUseCase : GetDefaultKeyMapOptionsUseCase { + + val keyMap: StateFlow> + + fun setEnabled(enabled: Boolean) + + // trigger + suspend fun addKeyEventTriggerKey( + keyCode: Int, + scanCode: Int, + device: KeyEventTriggerDevice, + requiresIme: Boolean, + ) + + suspend fun addFloatingButtonTriggerKey(buttonUid: String) + fun addAssistantTriggerKey(type: AssistantTriggerType) + fun addFingerprintGesture(type: FingerprintGestureType) + suspend fun addEvdevTriggerKey(keyCode: Int, scanCode: Int, device: EvdevDeviceInfo) + + fun removeTriggerKey(uid: String) + fun getTriggerKey(uid: String): TriggerKey? + fun moveTriggerKey(fromIndex: Int, toIndex: Int) + + fun setParallelTriggerMode() + fun setSequenceTriggerMode() + fun setUndefinedTriggerMode() + + fun setTriggerShortPress() + fun setTriggerLongPress() + fun setTriggerDoublePress() + + fun setTriggerKeyClickType(keyUid: String, clickType: ClickType) + fun setTriggerKeyDevice(keyUid: String, device: KeyEventTriggerDevice) + fun setTriggerKeyConsumeKeyEvent(keyUid: String, consumeKeyEvent: Boolean) + fun setAssistantTriggerKeyType(keyUid: String, type: AssistantTriggerType) + fun setFingerprintGestureType(keyUid: String, type: FingerprintGestureType) + + fun setVibrateEnabled(enabled: Boolean) + fun setVibrationDuration(duration: Int) + fun setLongPressDelay(delay: Int) + fun setDoublePressDelay(delay: Int) + fun setSequenceTriggerTimeout(delay: Int) + fun setLongPressDoubleVibrationEnabled(enabled: Boolean) + fun setTriggerFromOtherAppsEnabled(enabled: Boolean) + fun setShowToastEnabled(enabled: Boolean) + fun setScanCodeDetectionEnabled(keyUid: String, enabled: Boolean) + + fun getAvailableTriggerKeyDevices(): List + + val floatingButtonToUse: MutableStateFlow + suspend fun getFloatingLayoutCount(): Int +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/EvdevTriggerKey.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/EvdevTriggerKey.kt new file mode 100644 index 0000000000..1eafadd975 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/EvdevTriggerKey.kt @@ -0,0 +1,97 @@ +package io.github.sds100.keymapper.base.trigger + +import io.github.sds100.keymapper.base.keymaps.ClickType +import io.github.sds100.keymapper.common.models.EvdevDeviceInfo +import io.github.sds100.keymapper.common.utils.hasFlag +import io.github.sds100.keymapper.common.utils.withFlag +import io.github.sds100.keymapper.data.entities.EvdevTriggerKeyEntity +import io.github.sds100.keymapper.data.entities.TriggerKeyEntity +import java.util.UUID + +/** + * This must be a different class to KeyEventTriggerKey because trigger keys from evdev events + * must come from one device, even if it is internal, and can not come from any device. The input + * devices must be grabbed so that Key Mapper can remap them. + */ +data class EvdevTriggerKey( + override val uid: String = UUID.randomUUID().toString(), + override val keyCode: Int, + override val scanCode: Int, + val device: EvdevDeviceInfo, + override val clickType: ClickType = ClickType.SHORT_PRESS, + override val consumeEvent: Boolean = true, + override val detectWithScanCodeUserSetting: Boolean = false, +) : TriggerKey(), + KeyCodeTriggerKey { + override val allowedDoublePress: Boolean = true + override val allowedLongPress: Boolean = true + + override fun isSameDevice(otherKey: KeyCodeTriggerKey): Boolean { + if (otherKey !is EvdevTriggerKey) { + return false + } + return device == otherKey.device + } + + companion object { + fun fromEntity(entity: EvdevTriggerKeyEntity): TriggerKey { + val clickType = when (entity.clickType) { + TriggerKeyEntity.SHORT_PRESS -> ClickType.SHORT_PRESS + TriggerKeyEntity.LONG_PRESS -> ClickType.LONG_PRESS + TriggerKeyEntity.DOUBLE_PRESS -> ClickType.DOUBLE_PRESS + else -> ClickType.SHORT_PRESS + } + + val consumeEvent = + !entity.flags.hasFlag(EvdevTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT) + + val detectWithScancode = + entity.flags.hasFlag(EvdevTriggerKeyEntity.FLAG_DETECT_WITH_SCAN_CODE) + + return EvdevTriggerKey( + uid = entity.uid, + keyCode = entity.keyCode, + scanCode = entity.scanCode, + device = EvdevDeviceInfo( + name = entity.deviceName, + bus = entity.deviceBus, + vendor = entity.deviceVendor, + product = entity.deviceProduct, + ), + clickType = clickType, + consumeEvent = consumeEvent, + detectWithScanCodeUserSetting = detectWithScancode, + ) + } + + fun toEntity(key: EvdevTriggerKey): EvdevTriggerKeyEntity { + val clickType = when (key.clickType) { + ClickType.SHORT_PRESS -> TriggerKeyEntity.SHORT_PRESS + ClickType.LONG_PRESS -> TriggerKeyEntity.LONG_PRESS + ClickType.DOUBLE_PRESS -> TriggerKeyEntity.DOUBLE_PRESS + } + + var flags = 0 + + if (!key.consumeEvent) { + flags = flags.withFlag(EvdevTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT) + } + + if (key.detectWithScanCodeUserSetting) { + flags = flags.withFlag(EvdevTriggerKeyEntity.FLAG_DETECT_WITH_SCAN_CODE) + } + + return EvdevTriggerKeyEntity( + keyCode = key.keyCode, + scanCode = key.scanCode, + deviceName = key.device.name, + deviceBus = key.device.bus, + deviceVendor = key.device.vendor, + deviceProduct = key.device.product, + clickType = clickType, + flags = flags, + uid = key.uid, + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/FingerprintTriggerKey.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/FingerprintTriggerKey.kt index 820d11df43..8a3c0e5ee3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/FingerprintTriggerKey.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/FingerprintTriggerKey.kt @@ -4,9 +4,9 @@ import io.github.sds100.keymapper.base.keymaps.ClickType import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType import io.github.sds100.keymapper.data.entities.FingerprintTriggerKeyEntity import io.github.sds100.keymapper.data.entities.TriggerKeyEntity +import java.util.UUID import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.util.UUID @Serializable data class FingerprintTriggerKey( @@ -17,7 +17,6 @@ data class FingerprintTriggerKey( override val clickType: ClickType, ) : TriggerKey() { - override val consumeEvent: Boolean = true override val allowedLongPress: Boolean = false override val allowedDoublePress: Boolean = false @@ -33,9 +32,7 @@ data class FingerprintTriggerKey( } companion object { - fun fromEntity( - entity: FingerprintTriggerKeyEntity, - ): TriggerKey { + fun fromEntity(entity: FingerprintTriggerKeyEntity): TriggerKey { val type: FingerprintGestureType = when (entity.type) { FingerprintTriggerKeyEntity.ID_SWIPE_DOWN -> FingerprintGestureType.SWIPE_DOWN FingerprintTriggerKeyEntity.ID_SWIPE_UP -> FingerprintGestureType.SWIPE_UP diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/FloatingButtonKey.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/FloatingButtonKey.kt index 65b51d395e..22a0916b12 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/FloatingButtonKey.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/FloatingButtonKey.kt @@ -6,8 +6,8 @@ import io.github.sds100.keymapper.base.keymaps.ClickType import io.github.sds100.keymapper.data.entities.FloatingButtonEntityWithLayout import io.github.sds100.keymapper.data.entities.FloatingButtonKeyEntity import io.github.sds100.keymapper.data.entities.TriggerKeyEntity -import kotlinx.serialization.Serializable import java.util.UUID +import kotlinx.serialization.Serializable @Serializable data class FloatingButtonKey( @@ -17,7 +17,6 @@ data class FloatingButtonKey( override val clickType: ClickType, ) : TriggerKey() { - override val consumeEvent: Boolean = true override val allowedLongPress: Boolean = true override val allowedDoublePress: Boolean = true diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyCodeTriggerKey.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyCodeTriggerKey.kt index cbb4aa976c..3b8ac68c8f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyCodeTriggerKey.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyCodeTriggerKey.kt @@ -1,125 +1,62 @@ package io.github.sds100.keymapper.base.trigger +import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.keymaps.ClickType -import io.github.sds100.keymapper.common.utils.hasFlag -import io.github.sds100.keymapper.common.utils.withFlag -import io.github.sds100.keymapper.data.entities.KeyCodeTriggerKeyEntity -import io.github.sds100.keymapper.data.entities.TriggerKeyEntity -import kotlinx.serialization.Serializable -import java.util.UUID - -@Serializable -data class KeyCodeTriggerKey( - override val uid: String = UUID.randomUUID().toString(), - val keyCode: Int, - val device: TriggerKeyDevice, - override val clickType: ClickType, - override val consumeEvent: Boolean = true, - val detectionSource: KeyEventDetectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, -) : TriggerKey() { - - override val allowedLongPress: Boolean = true - override val allowedDoublePress: Boolean = true - - override fun toString(): String { - val deviceString = when (device) { - TriggerKeyDevice.Any -> "any" - is TriggerKeyDevice.External -> "external" - TriggerKeyDevice.Internal -> "internal" - } - return "KeyCodeTriggerKey(uid=${uid.substring(0..5)}, keyCode=$keyCode, device=$deviceString, clickType=$clickType, consume=$consumeEvent) " - } - - // key code -> click type -> device -> consume key event - override fun compareTo(other: TriggerKey) = when (other) { - is KeyCodeTriggerKey -> compareValuesBy( - this, - other, - { it.keyCode }, - { it.clickType }, - { it.device }, - { it.consumeEvent }, - ) - - else -> super.compareTo(other) - } - - companion object { - fun fromEntity(entity: KeyCodeTriggerKeyEntity): TriggerKey { - val device = when (entity.deviceId) { - KeyCodeTriggerKeyEntity.DEVICE_ID_THIS_DEVICE -> TriggerKeyDevice.Internal - KeyCodeTriggerKeyEntity.DEVICE_ID_ANY_DEVICE -> TriggerKeyDevice.Any - else -> TriggerKeyDevice.External( - entity.deviceId, - entity.deviceName ?: "", - ) - } - - val clickType = when (entity.clickType) { - TriggerKeyEntity.SHORT_PRESS -> ClickType.SHORT_PRESS - TriggerKeyEntity.LONG_PRESS -> ClickType.LONG_PRESS - TriggerKeyEntity.DOUBLE_PRESS -> ClickType.DOUBLE_PRESS - else -> ClickType.SHORT_PRESS - } - - val consumeEvent = - !entity.flags.hasFlag(KeyCodeTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT) - - val detectionSource = - if (entity.flags.hasFlag(KeyCodeTriggerKeyEntity.FLAG_DETECTION_SOURCE_INPUT_METHOD)) { - KeyEventDetectionSource.INPUT_METHOD - } else { - KeyEventDetectionSource.ACCESSIBILITY_SERVICE - } - - return KeyCodeTriggerKey( - uid = entity.uid, - keyCode = entity.keyCode, - device = device, - clickType = clickType, - consumeEvent = consumeEvent, - detectionSource = detectionSource, - ) - } - - fun toEntity(key: KeyCodeTriggerKey): KeyCodeTriggerKeyEntity { - val deviceId = when (key.device) { - TriggerKeyDevice.Any -> KeyCodeTriggerKeyEntity.DEVICE_ID_ANY_DEVICE - is TriggerKeyDevice.External -> key.device.descriptor - TriggerKeyDevice.Internal -> KeyCodeTriggerKeyEntity.DEVICE_ID_THIS_DEVICE - } - - val deviceName = - if (key.device is TriggerKeyDevice.External) { - key.device.name - } else { - null - } - - val clickType = when (key.clickType) { - ClickType.SHORT_PRESS -> TriggerKeyEntity.SHORT_PRESS - ClickType.LONG_PRESS -> TriggerKeyEntity.LONG_PRESS - ClickType.DOUBLE_PRESS -> TriggerKeyEntity.DOUBLE_PRESS - } +import io.github.sds100.keymapper.base.utils.KeyCodeStrings +import io.github.sds100.keymapper.base.utils.ScancodeStrings +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.system.inputevents.KeyEventUtils + +sealed interface KeyCodeTriggerKey { + val keyCode: Int + + /** + * Scancodes were only saved to KeyEvent trigger keys in version 4.0.0 so this is null + * to be backwards compatible. + */ + val scanCode: Int? + val clickType: ClickType + + /** + * The user can specify they want to detect with the scancode instead of the key code. + */ + val detectWithScanCodeUserSetting: Boolean + + /** + * Whether the event that triggers this key will be consumed and not passed + * onto subsequent apps. E.g consuming the volume down key event will mean the volume + * doesn't change. + */ + val consumeEvent: Boolean + + fun isSameDevice(otherKey: KeyCodeTriggerKey): Boolean +} - var flags = 0 +fun KeyCodeTriggerKey.detectWithScancode(): Boolean { + return scanCode != null && (detectWithScanCodeUserSetting || isKeyCodeUnknown()) +} - if (!key.consumeEvent) { - flags = flags.withFlag(KeyCodeTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT) - } +fun KeyCodeTriggerKey.isKeyCodeUnknown(): Boolean { + return KeyEventUtils.isKeyCodeUnknown(keyCode) +} - if (key.detectionSource == KeyEventDetectionSource.INPUT_METHOD) { - flags = flags.withFlag(KeyCodeTriggerKeyEntity.FLAG_DETECTION_SOURCE_INPUT_METHOD) - } +fun KeyCodeTriggerKey.isScanCodeDetectionUserConfigurable(): Boolean { + return scanCode != null && !isKeyCodeUnknown() +} - return KeyCodeTriggerKeyEntity( - keyCode = key.keyCode, - deviceId = deviceId, - deviceName = deviceName, - clickType = clickType, - flags = flags, - uid = key.uid, - ) - } +/** + * Get the label for the key code or scan code, depending on whether to detect it with a scan code. + */ +fun KeyCodeTriggerKey.getCodeLabel(resourceProvider: ResourceProvider): String { + if (detectWithScancode() && scanCode != null) { + val codeLabel = ScancodeStrings.getScancodeLabel(scanCode!!) + ?: resourceProvider.getString(R.string.trigger_key_unknown_scan_code, scanCode!!) + + return "$codeLabel (${resourceProvider.getString( + R.string.trigger_key_scan_code_detection_flag, + )})" + } else { + return KeyCodeStrings.keyCodeToString(keyCode) + ?: resourceProvider.getString(R.string.trigger_key_unknown_key_code, keyCode) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyEventDetectionSource.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyEventDetectionSource.kt deleted file mode 100644 index c231beff8a..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyEventDetectionSource.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.github.sds100.keymapper.base.trigger - -enum class KeyEventDetectionSource { - ACCESSIBILITY_SERVICE, - INPUT_METHOD, -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyEventTriggerDevice.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyEventTriggerDevice.kt new file mode 100644 index 0000000000..5f904e9782 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyEventTriggerDevice.kt @@ -0,0 +1,43 @@ +package io.github.sds100.keymapper.base.trigger + +import kotlinx.serialization.Serializable + +@Serializable +sealed class KeyEventTriggerDevice : Comparable { + override fun compareTo(other: KeyEventTriggerDevice) = + this.javaClass.name.compareTo(other.javaClass.name) + + @Serializable + data object Internal : KeyEventTriggerDevice() + + @Serializable + data object Any : KeyEventTriggerDevice() + + @Serializable + data class External(val descriptor: String, val name: String) : KeyEventTriggerDevice() { + override fun compareTo(other: KeyEventTriggerDevice): Int { + if (other !is External) { + return super.compareTo(other) + } + + return compareValuesBy( + this, + other, + { it.name }, + { it.descriptor }, + ) + } + } + + fun isSameDevice(other: KeyEventTriggerDevice): Boolean { + if (this is Any || other is Any) { + return true + } + + if (this is External && other is External) { + return this.descriptor == other.descriptor + } else { + return this is Internal && other is Internal + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyEventTriggerKey.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyEventTriggerKey.kt new file mode 100644 index 0000000000..ebd8104ea8 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyEventTriggerKey.kt @@ -0,0 +1,136 @@ +package io.github.sds100.keymapper.base.trigger + +import io.github.sds100.keymapper.base.keymaps.ClickType +import io.github.sds100.keymapper.common.utils.hasFlag +import io.github.sds100.keymapper.common.utils.withFlag +import io.github.sds100.keymapper.data.entities.KeyEventTriggerKeyEntity +import io.github.sds100.keymapper.data.entities.TriggerKeyEntity +import java.util.UUID +import kotlinx.serialization.Serializable + +@Serializable +data class KeyEventTriggerKey( + override val uid: String = UUID.randomUUID().toString(), + override val keyCode: Int, + val device: KeyEventTriggerDevice, + override val clickType: ClickType, + override val consumeEvent: Boolean = true, + /** + * Whether this key can only be detected by an input method. Some keys, such as DPAD buttons, + * do not send key events to the accessibility service. + */ + val requiresIme: Boolean = false, + override val scanCode: Int? = null, + override val detectWithScanCodeUserSetting: Boolean = false, +) : TriggerKey(), + KeyCodeTriggerKey { + + override val allowedLongPress: Boolean = true + override val allowedDoublePress: Boolean = true + + override fun isSameDevice(otherKey: KeyCodeTriggerKey): Boolean { + if (otherKey !is KeyEventTriggerKey) { + return false + } + return device.isSameDevice(otherKey.device) + } + + // key code -> click type -> device -> consume key event + override fun compareTo(other: TriggerKey) = when (other) { + is KeyEventTriggerKey -> compareValuesBy( + this, + other, + { it.keyCode }, + { it.clickType }, + { it.device }, + { it.consumeEvent }, + ) + + else -> super.compareTo(other) + } + + companion object { + fun fromEntity(entity: KeyEventTriggerKeyEntity): TriggerKey { + val device = when (entity.deviceId) { + KeyEventTriggerKeyEntity.DEVICE_ID_THIS_DEVICE -> KeyEventTriggerDevice.Internal + KeyEventTriggerKeyEntity.DEVICE_ID_ANY_DEVICE -> KeyEventTriggerDevice.Any + else -> KeyEventTriggerDevice.External( + entity.deviceId, + entity.deviceName ?: "", + ) + } + + val clickType = when (entity.clickType) { + TriggerKeyEntity.SHORT_PRESS -> ClickType.SHORT_PRESS + TriggerKeyEntity.LONG_PRESS -> ClickType.LONG_PRESS + TriggerKeyEntity.DOUBLE_PRESS -> ClickType.DOUBLE_PRESS + else -> ClickType.SHORT_PRESS + } + + val consumeEvent = + !entity.flags.hasFlag(KeyEventTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT) + + val requiresIme = + entity.flags.hasFlag(KeyEventTriggerKeyEntity.FLAG_DETECTION_SOURCE_INPUT_METHOD) + + val detectWithScancode = + entity.flags.hasFlag(KeyEventTriggerKeyEntity.FLAG_DETECT_WITH_SCAN_CODE) + + return KeyEventTriggerKey( + uid = entity.uid, + keyCode = entity.keyCode, + device = device, + clickType = clickType, + consumeEvent = consumeEvent, + requiresIme = requiresIme, + scanCode = entity.scanCode, + detectWithScanCodeUserSetting = detectWithScancode, + ) + } + + fun toEntity(key: KeyEventTriggerKey): KeyEventTriggerKeyEntity { + val deviceId = when (key.device) { + KeyEventTriggerDevice.Any -> KeyEventTriggerKeyEntity.DEVICE_ID_ANY_DEVICE + is KeyEventTriggerDevice.External -> key.device.descriptor + KeyEventTriggerDevice.Internal -> KeyEventTriggerKeyEntity.DEVICE_ID_THIS_DEVICE + } + + val deviceName = + if (key.device is KeyEventTriggerDevice.External) { + key.device.name + } else { + null + } + + val clickType = when (key.clickType) { + ClickType.SHORT_PRESS -> TriggerKeyEntity.SHORT_PRESS + ClickType.LONG_PRESS -> TriggerKeyEntity.LONG_PRESS + ClickType.DOUBLE_PRESS -> TriggerKeyEntity.DOUBLE_PRESS + } + + var flags = 0 + + if (!key.consumeEvent) { + flags = flags.withFlag(KeyEventTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT) + } + + if (key.requiresIme) { + flags = flags.withFlag(KeyEventTriggerKeyEntity.FLAG_DETECTION_SOURCE_INPUT_METHOD) + } + + if (key.detectWithScanCodeUserSetting) { + flags = flags.withFlag(KeyEventTriggerKeyEntity.FLAG_DETECT_WITH_SCAN_CODE) + } + + return KeyEventTriggerKeyEntity( + keyCode = key.keyCode, + deviceId = deviceId, + deviceName = deviceName, + clickType = clickType, + flags = flags, + uid = key.uid, + scanCode = key.scanCode, + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt index 9e698a97bd..8d2dd8ea67 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt @@ -4,10 +4,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel -data class KeyMapListItemModel( - val isSelected: Boolean, - val content: Content, -) { +data class KeyMapListItemModel(val isSelected: Boolean, val content: Content) { val uid = content.uid data class Content( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerButtonRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerButtonRow.kt index 09aabd1396..fa34019f0e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerButtonRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerButtonRow.kt @@ -1,31 +1,45 @@ package io.github.sds100.keymapper.base.trigger +import android.content.res.Configuration +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ShoppingCart import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.canopas.lib.showcase.IntroShowcase 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.onboarding.OnboardingTapTarget -import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperTapTarget -import io.github.sds100.keymapper.base.utils.ui.compose.keyMapperShowcaseStyle @Composable fun RecordTriggerButtonRow( @@ -33,61 +47,24 @@ fun RecordTriggerButtonRow( onRecordTriggerClick: () -> Unit = {}, recordTriggerState: RecordTriggerState, onAdvancedTriggersClick: () -> Unit = {}, - showRecordTriggerTapTarget: Boolean = false, - onRecordTriggerTapTargetCompleted: () -> Unit = {}, - onSkipTapTarget: () -> Unit = {}, - showAdvancedTriggerTapTarget: Boolean = false, - onAdvancedTriggerTapTargetCompleted: () -> Unit = {}, ) { - Row(modifier, verticalAlignment = Alignment.CenterVertically) { - IntroShowcase( - showIntroShowCase = showRecordTriggerTapTarget, - onShowCaseCompleted = onRecordTriggerTapTargetCompleted, - dismissOnClickOutside = true, - ) { + Column(modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { RecordTriggerButton( - modifier = Modifier - .weight(1f) - .introShowCaseTarget(0, style = keyMapperShowcaseStyle()) { - KeyMapperTapTarget( - OnboardingTapTarget.RECORD_TRIGGER, - onSkipClick = onSkipTapTarget, - ) - }, + modifier = Modifier.weight(1f), recordTriggerState, onClick = onRecordTriggerClick, ) - } - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(8.dp)) - IntroShowcase( - showIntroShowCase = showAdvancedTriggerTapTarget, - onShowCaseCompleted = onAdvancedTriggerTapTargetCompleted, - dismissOnClickOutside = true, - ) { - AdvancedTriggersButton( - modifier = Modifier - .weight(1f) - .introShowCaseTarget(0, style = keyMapperShowcaseStyle()) { - KeyMapperTapTarget( - OnboardingTapTarget.ADVANCED_TRIGGERS, - showSkipButton = false, - ) - }, - isEnabled = recordTriggerState !is RecordTriggerState.CountingDown, - onClick = onAdvancedTriggersClick, - ) + AdvancedTriggersButton(onClick = onAdvancedTriggersClick) } } } @Composable -private fun RecordTriggerButton( - modifier: Modifier, - state: RecordTriggerState, - onClick: () -> Unit, -) { +fun RecordTriggerButton(modifier: Modifier, state: RecordTriggerState, onClick: () -> Unit) { val colors = ButtonDefaults.filledTonalButtonColors().copy( containerColor = LocalCustomColorsPalette.current.red, contentColor = LocalCustomColorsPalette.current.onRed, @@ -101,48 +78,66 @@ private fun RecordTriggerButton( stringResource(R.string.button_record_trigger) } + // Create pulsing animation for the recording dot + val infiniteTransition = rememberInfiniteTransition(label = "recording_dot_pulse") + val alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(1000), + repeatMode = RepeatMode.Reverse, + ), + label = "recording_dot_alpha", + ) + FilledTonalButton( modifier = modifier, onClick = onClick, colors = colors, ) { - BasicText( - text = text, - maxLines = 1, - autoSize = TextAutoSize.StepBased( - minFontSize = 5.sp, - maxFontSize = MaterialTheme.typography.labelLarge.fontSize, - ), - style = MaterialTheme.typography.labelLarge, - color = { colors.contentColor }, - overflow = TextOverflow.Ellipsis, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + // White recording dot + if (state is RecordTriggerState.CountingDown) { + Box( + modifier = Modifier + .size(8.dp) + .alpha(alpha) + .background( + color = Color.White, + shape = CircleShape, + ), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + BasicText( + text = text, + maxLines = 1, + autoSize = TextAutoSize.StepBased( + minFontSize = 5.sp, + maxFontSize = MaterialTheme.typography.labelLarge.fontSize, + ), + style = MaterialTheme.typography.labelLarge, + color = { colors.contentColor }, + overflow = TextOverflow.Ellipsis, + ) + } } } @Composable -private fun AdvancedTriggersButton( - modifier: Modifier, - isEnabled: Boolean, - onClick: () -> Unit, -) { - OutlinedButton( +private fun AdvancedTriggersButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + IconButton( modifier = modifier, - enabled = isEnabled, onClick = onClick, + colors = IconButtonDefaults.iconButtonColors( + containerColor = LocalCustomColorsPalette.current.amber, + contentColor = LocalCustomColorsPalette.current.onAmber, + ), ) { - val color = ButtonDefaults.textButtonColors().contentColor - BasicText( - text = stringResource(R.string.button_advanced_triggers), - maxLines = 1, - autoSize = TextAutoSize.StepBased( - minFontSize = 5.sp, - maxFontSize = MaterialTheme.typography.labelLarge.fontSize, - ), - style = MaterialTheme.typography.labelLarge, - color = { color }, - overflow = TextOverflow.Ellipsis, - ) + Icon(Icons.Outlined.ShoppingCart, contentDescription = null) } } @@ -172,6 +167,19 @@ private fun PreviewStopped() { } } +@Preview(widthDp = 400, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun PreviewStoppedDark() { + KeyMapperTheme { + Surface { + RecordTriggerButtonRow( + modifier = Modifier.fillMaxWidth(), + recordTriggerState = RecordTriggerState.Idle, + ) + } + } +} + @Preview(widthDp = 300) @Composable private fun PreviewStoppedCompact() { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerController.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerController.kt new file mode 100644 index 0000000000..5a1cceaeae --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerController.kt @@ -0,0 +1,283 @@ +package io.github.sds100.keymapper.base.trigger + +import android.view.KeyEvent +import io.github.sds100.keymapper.base.detection.DpadMotionEventTracker +import io.github.sds100.keymapper.base.input.InputEventDetectionSource +import io.github.sds100.keymapper.base.input.InputEventHub +import io.github.sds100.keymapper.base.input.InputEventHubCallback +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.isError +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceEvent +import io.github.sds100.keymapper.system.inputevents.KMEvdevEvent +import io.github.sds100.keymapper.system.inputevents.KMGamePadEvent +import io.github.sds100.keymapper.system.inputevents.KMInputEvent +import io.github.sds100.keymapper.system.inputevents.KMKeyEvent +import io.github.sds100.keymapper.system.inputevents.Scancode +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import timber.log.Timber + +@Singleton +class RecordTriggerControllerImpl @Inject constructor( + private val coroutineScope: CoroutineScope, + private val inputEventHub: InputEventHub, + private val accessibilityServiceAdapter: AccessibilityServiceAdapter, +) : RecordTriggerController, + InputEventHubCallback { + companion object { + /** + * How long should the accessibility service record a trigger in seconds. + */ + private const val RECORD_TRIGGER_TIMER_LENGTH = 5 + private const val INPUT_EVENT_HUB_ID = "record_trigger" + + private val SCAN_CODES_BLACKLIST = setOf( + Scancode.BTN_TOUCH, + Scancode.BTN_TOOL_FINGER, + ) + } + + override val state = MutableStateFlow(RecordTriggerState.Idle) + + private var recordingTriggerJob: Job? = null + + private val recordingTrigger: Boolean + get() = recordingTriggerJob != null && recordingTriggerJob?.isActive == true + + private val recordedKeys: MutableList = mutableListOf() + override val onRecordKey: MutableSharedFlow = MutableSharedFlow() + + /** + * The keys that are currently being held down. They are removed from the set when the up event + * is received and it is cleared when recording starts. + */ + private val downKeyEvents: MutableSet = mutableSetOf() + private val downEvdevEvents: MutableSet = mutableSetOf() + private val dpadMotionEventTracker: DpadMotionEventTracker = DpadMotionEventTracker() + + private var isEvdevRecordingEnabled: Boolean = false + + override fun setEvdevRecordingEnabled(enabled: Boolean) { + if (state.value is RecordTriggerState.CountingDown) { + return + } + + isEvdevRecordingEnabled = enabled + } + + override fun onInputEvent( + event: KMInputEvent, + detectionSource: InputEventDetectionSource, + ): Boolean { + if (!recordingTrigger) { + return false + } + + when (event) { + is KMEvdevEvent -> { + if (!isEvdevRecordingEnabled) { + return false + } + + // Do not record evdev events that are not key events. + if (event.type != KMEvdevEvent.TYPE_KEY_EVENT) { + return false + } + + if (SCAN_CODES_BLACKLIST.contains(event.code)) { + return false + } + + // Must also remove old down events if a new down even is received. + val matchingDownEvent: KMEvdevEvent? = downEvdevEvents.find { + it == event.copy(value = KMEvdevEvent.VALUE_DOWN) + } + + if (matchingDownEvent != null) { + downEvdevEvents.remove(matchingDownEvent) + } + + if (event.isDownEvent) { + downEvdevEvents.add(event) + } else if (event.isUpEvent) { + onRecordKey(createEvdevRecordedKey(event)) + Timber.d( + "Recorded evdev event ${event.code} ${KeyEvent.keyCodeToString( + event.androidCode, + )}", + ) + } + + return true + } + + is KMGamePadEvent -> { + val dpadKeyEvents = dpadMotionEventTracker.convertMotionEvent(event) + + for (keyEvent in dpadKeyEvents) { + if (keyEvent.action == KeyEvent.ACTION_DOWN) { + val recordedKey = createKeyEventRecordedKey( + keyEvent, + detectionSource, + ) + onRecordKey(recordedKey) + Timber.d( + "Recorded motion event ${KeyEvent.keyCodeToString(keyEvent.keyCode)}", + ) + } + } + return true + } + + is KMKeyEvent -> { + val matchingDownEvent: KMKeyEvent? = downKeyEvents.find { + it.keyCode == event.keyCode && + it.scanCode == event.scanCode && + it.deviceId == event.deviceId && + it.source == event.source + } + + // Must also remove old down events if a new down even is received. + if (matchingDownEvent != null) { + downKeyEvents.remove(matchingDownEvent) + } + + if (event.action == KeyEvent.ACTION_DOWN) { + downKeyEvents.add(event) + } else if (event.action == KeyEvent.ACTION_UP) { + // Only record the key if there is a matching down event. + // Do not do this when recording motion events from the input method + // or Activity because they intentionally only input a down event. + if (matchingDownEvent != null) { + val recordedKey = createKeyEventRecordedKey(event, detectionSource) + onRecordKey(recordedKey) + Timber.d("Recorded key event ${KeyEvent.keyCodeToString(event.keyCode)}") + } + } + return true + } + } + } + + override suspend fun startRecording(enableEvdevRecording: Boolean): KMResult<*> { + val serviceResult = + accessibilityServiceAdapter.send(AccessibilityServiceEvent.Ping("record_trigger")) + + if (serviceResult.isError) { + return serviceResult + } + + if (recordingTrigger) { + return Success(Unit) + } + + this.isEvdevRecordingEnabled = enableEvdevRecording + recordingTriggerJob = recordTriggerJob() + + return Success(Unit) + } + + override fun stopRecording(): KMResult<*> { + recordingTriggerJob?.cancel() + recordingTriggerJob = null + + dpadMotionEventTracker.reset() + inputEventHub.unregisterClient(INPUT_EVENT_HUB_ID) + state.update { RecordTriggerState.Completed(recordedKeys) } + + return Success(Unit) + } + + private fun onRecordKey(recordedKey: RecordedKey) { + recordedKeys.add(recordedKey) + runBlocking { onRecordKey.emit(recordedKey) } + } + + private fun createKeyEventRecordedKey( + keyEvent: KMKeyEvent, + detectionSource: InputEventDetectionSource, + ): RecordedKey.KeyEvent { + return RecordedKey.KeyEvent( + keyCode = keyEvent.keyCode, + scanCode = keyEvent.scanCode, + deviceDescriptor = keyEvent.device.descriptor, + deviceName = keyEvent.device.name, + isExternalDevice = keyEvent.device.isExternal, + detectionSource = detectionSource, + ) + } + + private fun createEvdevRecordedKey(evdevEvent: KMEvdevEvent): RecordedKey.EvdevEvent { + return RecordedKey.EvdevEvent( + keyCode = evdevEvent.androidCode, + scanCode = evdevEvent.code, + device = evdevEvent.device, + ) + } + + // Run on a different thread in case the main thread is locked up while recording and + // the evdev devices aren't ungrabbed. + private fun recordTriggerJob(): Job = coroutineScope.launch(Dispatchers.Default) { + Timber.i("Starting trigger recording. Evdev recording: $isEvdevRecordingEnabled") + + recordedKeys.clear() + dpadMotionEventTracker.reset() + downKeyEvents.clear() + + val evdevEventTypes = if (isEvdevRecordingEnabled) { + listOf(KMEvdevEvent.TYPE_KEY_EVENT) + } else { + emptyList() + } + + inputEventHub.registerClient( + INPUT_EVENT_HUB_ID, + this@RecordTriggerControllerImpl, + evdevEventTypes, + ) + + if (isEvdevRecordingEnabled) { + // Grab all evdev devices + inputEventHub.grabAllEvdevDevices(INPUT_EVENT_HUB_ID) + } + + repeat(RECORD_TRIGGER_TIMER_LENGTH) { iteration -> + val timeLeft = RECORD_TRIGGER_TIMER_LENGTH - iteration + + state.update { RecordTriggerState.CountingDown(timeLeft) } + + delay(1000) + } + + downKeyEvents.clear() + dpadMotionEventTracker.reset() + inputEventHub.unregisterClient(INPUT_EVENT_HUB_ID) + state.update { RecordTriggerState.Completed(recordedKeys) } + } +} + +interface RecordTriggerController { + val state: StateFlow + val onRecordKey: Flow + + fun setEvdevRecordingEnabled(enabled: Boolean) + + /** + * @return Success if started and an Error if failed to start. + */ + suspend fun startRecording(enableEvdevRecording: Boolean): KMResult<*> + fun stopRecording(): KMResult<*> +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerEvent.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerEvent.kt deleted file mode 100644 index 3c52caf495..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerEvent.kt +++ /dev/null @@ -1,30 +0,0 @@ -package io.github.sds100.keymapper.base.trigger - -import android.os.Parcelable -import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceEvent -import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import kotlinx.parcelize.Parcelize -import kotlinx.serialization.Serializable - -sealed class RecordTriggerEvent : AccessibilityServiceEvent() { - @Parcelize - @Serializable - data class RecordedTriggerKey( - val keyCode: Int, - val device: InputDeviceInfo?, - val detectionSource: KeyEventDetectionSource, - ) : RecordTriggerEvent(), - Parcelable - - @Serializable - data object StartRecordingTrigger : RecordTriggerEvent() - - @Serializable - data object StopRecordingTrigger : RecordTriggerEvent() - - @Serializable - data class OnIncrementRecordTriggerTimer(val timeLeft: Int) : RecordTriggerEvent() - - @Serializable - data object OnStoppedRecordingTrigger : RecordTriggerEvent() -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerUseCase.kt deleted file mode 100644 index 0564e382c5..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordTriggerUseCase.kt +++ /dev/null @@ -1,132 +0,0 @@ -package io.github.sds100.keymapper.base.trigger - -import android.view.KeyEvent -import io.github.sds100.keymapper.base.keymaps.detection.DpadMotionEventTracker -import io.github.sds100.keymapper.common.utils.KMResult -import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter -import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import io.github.sds100.keymapper.system.inputevents.InputEventUtils -import io.github.sds100.keymapper.system.inputevents.MyMotionEvent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class RecordTriggerController @Inject constructor( - private val coroutineScope: CoroutineScope, - private val serviceAdapter: AccessibilityServiceAdapter, -) : RecordTriggerUseCase { - override val state = MutableStateFlow(RecordTriggerState.Idle) - - private val recordedKeys: MutableList = mutableListOf() - override val onRecordKey: MutableSharedFlow = MutableSharedFlow() - private val dpadMotionEventTracker: DpadMotionEventTracker = DpadMotionEventTracker() - - init { - serviceAdapter.eventReceiver.onEach { event -> - when (event) { - is RecordTriggerEvent.OnStoppedRecordingTrigger -> - state.value = - RecordTriggerState.Completed(recordedKeys) - - is RecordTriggerEvent.OnIncrementRecordTriggerTimer -> - state.value = - RecordTriggerState.CountingDown(event.timeLeft) - - else -> Unit - } - }.launchIn(coroutineScope) - - serviceAdapter.eventReceiver - .mapNotNull { - if (it is RecordTriggerEvent.RecordedTriggerKey) { - it - } else { - null - } - } - .map { createRecordedKeyEvent(it.keyCode, it.device, it.detectionSource) } - .onEach { key -> - recordedKeys.add(key) - onRecordKey.emit(key) - } - .launchIn(coroutineScope) - } - - override suspend fun startRecording(): KMResult<*> { - recordedKeys.clear() - dpadMotionEventTracker.reset() - return serviceAdapter.send(RecordTriggerEvent.StartRecordingTrigger) - } - - override suspend fun stopRecording(): KMResult<*> { - return serviceAdapter.send(RecordTriggerEvent.StopRecordingTrigger) - } - - /** - * Process motion events from the activity so that DPAD buttons can be recorded - * even when the Key Mapper IME is not being used. DO NOT record the key events because - * these are sent from the joy sticks. - * @return Whether the motion event is consumed. - */ - fun onActivityMotionEvent(event: MyMotionEvent): Boolean { - if (state.value !is RecordTriggerState.CountingDown) { - return false - } - - val keyEvent = - dpadMotionEventTracker.convertMotionEvent(event).firstOrNull() ?: return false - - if (!InputEventUtils.isDpadKeyCode(keyEvent.keyCode)) { - return false - } - - if (keyEvent.action == KeyEvent.ACTION_UP) { - val recordedKey = createRecordedKeyEvent( - keyEvent.keyCode, - keyEvent.device, - KeyEventDetectionSource.INPUT_METHOD, - ) - - recordedKeys.add(recordedKey) - coroutineScope.launch { - onRecordKey.emit(recordedKey) - } - } - - return true - } - - private fun createRecordedKeyEvent( - keyCode: Int, - device: InputDeviceInfo?, - detectionSource: KeyEventDetectionSource, - ): RecordedKey { - val triggerKeyDevice = if (device != null && device.isExternal) { - TriggerKeyDevice.External(device.descriptor, device.name) - } else { - TriggerKeyDevice.Internal - } - - return RecordedKey(keyCode, triggerKeyDevice, detectionSource) - } -} - -interface RecordTriggerUseCase { - val state: Flow - val onRecordKey: Flow - - /** - * @return Success if started and an Error if failed to start. - */ - suspend fun startRecording(): KMResult<*> - suspend fun stopRecording(): KMResult<*> -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordedKey.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordedKey.kt index f4db2f265b..468b64a329 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordedKey.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RecordedKey.kt @@ -1,7 +1,18 @@ package io.github.sds100.keymapper.base.trigger -data class RecordedKey( - val keyCode: Int, - val device: TriggerKeyDevice, - val detectionSource: KeyEventDetectionSource, -) +import io.github.sds100.keymapper.base.input.InputEventDetectionSource +import io.github.sds100.keymapper.common.models.EvdevDeviceHandle + +sealed class RecordedKey { + data class KeyEvent( + val keyCode: Int, + val scanCode: Int, + val deviceDescriptor: String, + val deviceName: String, + val isExternalDevice: Boolean, + val detectionSource: InputEventDetectionSource, + ) : RecordedKey() + + data class EvdevEvent(val keyCode: Int, val scanCode: Int, val device: EvdevDeviceHandle) : + RecordedKey() +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/RemapStatus.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RemapStatus.kt new file mode 100644 index 0000000000..e187f83ed5 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/RemapStatus.kt @@ -0,0 +1,12 @@ +package io.github.sds100.keymapper.base.trigger + +enum class RemapStatus { + /** The button cannot be remapped. */ + UNSUPPORTED, + + /** The button might be remappable, but it requires special conditions or is uncertain. */ + UNCERTAIN, + + /** The button can be remapped. */ + SUPPORTED, +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/SetupGuiKeyboardBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/SetupGuiKeyboardBottomSheet.kt deleted file mode 100644 index e08ea1e899..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/SetupGuiKeyboardBottomSheet.kt +++ /dev/null @@ -1,344 +0,0 @@ -package io.github.sds100.keymapper.base.trigger - -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.height -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.FilledTonalButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -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.utils.ui.compose.openUriSafe -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DpadTriggerSetupBottomSheet( - modifier: Modifier = Modifier, - onDismissRequest: () -> Unit, - guiKeyboardState: SetupGuiKeyboardState, - onEnableKeyboardClick: () -> Unit = {}, - onChooseKeyboardClick: () -> Unit = {}, - onNeverShowAgainClick: () -> Unit = {}, - sheetState: SheetState, -) { - SetupGuiKeyboardBottomSheet( - modifier, - guiKeyboardState = guiKeyboardState, - sheetState = sheetState, - onDismissRequest = onDismissRequest, - onEnableKeyboardClick = onEnableKeyboardClick, - onChooseKeyboardClick = onChooseKeyboardClick, - onNeverShowAgainClick = onNeverShowAgainClick, - title = stringResource(R.string.dpad_trigger_setup_bottom_sheet_title), - text = stringResource(R.string.dpad_trigger_setup_bottom_sheet_text), - setupCompleteText = stringResource(R.string.dpad_trigger_setup_setup_complete_text), - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NoKeysRecordedBottomSheet( - modifier: Modifier = Modifier, - onDismissRequest: () -> Unit, - viewModel: BaseConfigTriggerViewModel, - sheetState: SheetState, -) { - val state by viewModel.setupGuiKeyboardState.collectAsStateWithLifecycle() - - SetupGuiKeyboardBottomSheet( - modifier = modifier, - guiKeyboardState = state, - sheetState = sheetState, - onDismissRequest = onDismissRequest, - onEnableKeyboardClick = viewModel::onEnableGuiKeyboardClick, - onChooseKeyboardClick = viewModel::onChooseGuiKeyboardClick, - onNeverShowAgainClick = viewModel::onNeverShowNoKeysRecordedClick, - title = stringResource(R.string.no_keys_recorded_bottom_sheet_title), - text = stringResource(R.string.no_keys_recorded_bottom_sheet_text), - setupCompleteText = stringResource(R.string.no_keys_recorded_setup_complete_text), - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun SetupGuiKeyboardBottomSheet( - modifier: Modifier = Modifier, - guiKeyboardState: SetupGuiKeyboardState, - sheetState: SheetState, - onDismissRequest: () -> Unit, - onEnableKeyboardClick: () -> Unit = {}, - onChooseKeyboardClick: () -> Unit = {}, - onNeverShowAgainClick: () -> Unit = {}, - title: String, - text: String, - setupCompleteText: String, -) { - val scope = rememberCoroutineScope() - val uriHandler = LocalUriHandler.current - val ctx = LocalContext.current - val scrollState = rememberScrollState() - - ModalBottomSheet( - modifier = modifier, - onDismissRequest = onDismissRequest, - sheetState = sheetState, - // Hide drag handle because other bottom sheets don't have it - dragHandle = {}, - ) { - Column( - modifier = Modifier.verticalScroll(scrollState), - ) { - Spacer(modifier = Modifier.height(16.dp)) - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 32.dp), - textAlign = TextAlign.Center, - text = title, - style = MaterialTheme.typography.headlineMedium, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth(), - text = text, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - val guiKeyboardUrl = stringResource(R.string.url_play_store_keymapper_gui_keyboard) - StepRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - isEnabled = !guiKeyboardState.isKeyboardInstalled, - rowText = stringResource(R.string.setup_gui_keyboard_install_keyboard_text), - buttonTextEnabled = stringResource(R.string.setup_gui_keyboard_install_keyboard_button), - buttonTextDisabled = stringResource(R.string.setup_gui_keyboard_install_keyboard_button_disabled), - onButtonClick = { - uriHandler.openUriSafe(ctx, guiKeyboardUrl) - }, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - StepRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - isEnabled = !guiKeyboardState.isKeyboardEnabled, - rowText = stringResource(R.string.setup_gui_keyboard_enable_keyboard_text), - buttonTextEnabled = stringResource(R.string.setup_gui_keyboard_enable_keyboard_button), - buttonTextDisabled = stringResource(R.string.setup_gui_keyboard_enable_keyboard_button_disabled), - onButtonClick = onEnableKeyboardClick, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - StepRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - isEnabled = !guiKeyboardState.isKeyboardChosen, - rowText = stringResource(R.string.setup_gui_keyboard_choose_keyboard_text), - buttonTextEnabled = stringResource(R.string.setup_gui_keyboard_choose_keyboard_button), - buttonTextDisabled = stringResource(R.string.setup_gui_keyboard_choose_keyboard_button_disabled), - onButtonClick = onChooseKeyboardClick, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - if (guiKeyboardState.isKeyboardInstalled && guiKeyboardState.isKeyboardEnabled && guiKeyboardState.isKeyboardChosen) { - Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = setupCompleteText, - style = MaterialTheme.typography.bodyLarge, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = { - onNeverShowAgainClick() - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.pos_never_show_again)) - } - - Spacer(modifier = Modifier.width(16.dp)) - - Button( - modifier = Modifier.weight(1f), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Text(stringResource(R.string.pos_done)) - } - } - - Spacer(Modifier.height(16.dp)) - } - } -} - -@Composable -private fun StepRow( - modifier: Modifier = Modifier, - isEnabled: Boolean, - rowText: String, - buttonTextEnabled: String, - buttonTextDisabled: String, - onButtonClick: () -> Unit, -) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.weight(1f), - text = rowText, - fontWeight = FontWeight.Medium, - ) - - FilledTonalButton( - onClick = onButtonClick, - enabled = isEnabled, - ) { - val text = if (isEnabled) { - buttonTextEnabled - } else { - buttonTextDisabled - } - - Text(text) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun PreviewDpad() { - KeyMapperTheme { - val sheetState = SheetState( - skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, - ) - - SetupGuiKeyboardBottomSheet( - onDismissRequest = {}, - guiKeyboardState = SetupGuiKeyboardState( - isKeyboardInstalled = true, - isKeyboardEnabled = false, - isKeyboardChosen = false, - ), - sheetState = sheetState, - title = stringResource(R.string.dpad_trigger_setup_bottom_sheet_title), - text = stringResource(R.string.dpad_trigger_setup_bottom_sheet_text), - setupCompleteText = stringResource(R.string.dpad_trigger_setup_setup_complete_text), - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun PreviewDpadComplete() { - KeyMapperTheme { - val sheetState = SheetState( - skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, - ) - - SetupGuiKeyboardBottomSheet( - onDismissRequest = {}, - guiKeyboardState = SetupGuiKeyboardState( - isKeyboardInstalled = true, - isKeyboardEnabled = true, - isKeyboardChosen = true, - ), - sheetState = sheetState, - title = stringResource(R.string.dpad_trigger_setup_bottom_sheet_title), - text = stringResource(R.string.dpad_trigger_setup_bottom_sheet_text), - setupCompleteText = stringResource(R.string.dpad_trigger_setup_setup_complete_text), - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun PreviewNoKeyRecordedComplete() { - KeyMapperTheme { - val sheetState = SheetState( - skipPartiallyExpanded = true, - density = LocalDensity.current, - initialValue = SheetValue.Expanded, - ) - - SetupGuiKeyboardBottomSheet( - onDismissRequest = {}, - guiKeyboardState = SetupGuiKeyboardState( - isKeyboardInstalled = true, - isKeyboardEnabled = true, - isKeyboardChosen = true, - ), - sheetState = sheetState, - title = stringResource(R.string.no_keys_recorded_bottom_sheet_title), - text = stringResource(R.string.no_keys_recorded_bottom_sheet_text), - setupCompleteText = stringResource(R.string.no_keys_recorded_setup_complete_text), - ) - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/SetupGuiKeyboardState.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/SetupGuiKeyboardState.kt deleted file mode 100644 index 4870c06627..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/SetupGuiKeyboardState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.github.sds100.keymapper.base.trigger - -data class SetupGuiKeyboardState( - val isKeyboardInstalled: Boolean, - val isKeyboardEnabled: Boolean, - val isKeyboardChosen: Boolean, -) { - companion object { - val DEFAULT = SetupGuiKeyboardState( - isKeyboardInstalled = false, - isKeyboardEnabled = false, - isKeyboardChosen = false, - ) - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/SetupGuiKeyboardUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/SetupGuiKeyboardUseCase.kt deleted file mode 100644 index bbed434572..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/SetupGuiKeyboardUseCase.kt +++ /dev/null @@ -1,64 +0,0 @@ -package io.github.sds100.keymapper.base.trigger - -import io.github.sds100.keymapper.base.system.inputmethod.KeyMapperImeHelper -import io.github.sds100.keymapper.common.utils.onSuccess -import io.github.sds100.keymapper.system.apps.PackageInfo -import io.github.sds100.keymapper.system.apps.PackageManagerAdapter -import io.github.sds100.keymapper.system.apps.getPackageInfoFlow -import io.github.sds100.keymapper.system.inputmethod.ImeInfo -import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import javax.inject.Inject - -class SetupGuiKeyboardUseCaseImpl @Inject constructor( - private val inputMethodAdapter: InputMethodAdapter, - private val packageManager: PackageManagerAdapter, -) : SetupGuiKeyboardUseCase { - private val guiKeyboardPackage: Flow = - packageManager.getPackageInfoFlow(KeyMapperImeHelper.KEY_MAPPER_GUI_IME_PACKAGE) - - override val isInstalled: Flow = guiKeyboardPackage.map { it != null } - - override val isEnabled: Flow = - getGuiKeyboardImeInfoFlow().map { it?.isEnabled ?: false } - - override val isCompatibleVersion: Flow = - guiKeyboardPackage.map { packageInfo -> - if (packageInfo == null) { - false - } else { - packageInfo.versionCode >= KeyMapperImeHelper.MIN_SUPPORTED_GUI_KEYBOARD_VERSION_CODE - } - } - - override suspend fun enableInputMethod() { - inputMethodAdapter.getInfoByPackageName(KeyMapperImeHelper.KEY_MAPPER_GUI_IME_PACKAGE) - .onSuccess { - inputMethodAdapter.enableIme(it.id) - } - } - - override val isChosen: Flow = - getGuiKeyboardImeInfoFlow().map { it?.isChosen ?: false } - - override fun chooseInputMethod() { - inputMethodAdapter.showImePicker(fromForeground = true) - } - - private fun getGuiKeyboardImeInfoFlow(): Flow { - return inputMethodAdapter.inputMethods.map { list -> list.find { it.packageName == KeyMapperImeHelper.KEY_MAPPER_GUI_IME_PACKAGE } } - } -} - -interface SetupGuiKeyboardUseCase { - val isInstalled: Flow - - val isEnabled: Flow - suspend fun enableInputMethod() - - val isChosen: Flow - fun chooseInputMethod() - - val isCompatibleVersion: Flow -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/SetupInputMethodUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/SetupInputMethodUseCase.kt new file mode 100644 index 0000000000..c5e3fbfd90 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/SetupInputMethodUseCase.kt @@ -0,0 +1,68 @@ +package io.github.sds100.keymapper.base.trigger + +import android.os.Build +import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.base.system.inputmethod.KeyMapperImeHelper +import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface +import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.onFailure +import io.github.sds100.keymapper.common.utils.suspendThen +import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter +import javax.inject.Inject +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout +import timber.log.Timber + +@ViewModelScoped +class SetupInputMethodUseCaseImpl @Inject constructor( + private val switchImeInterface: SwitchImeInterface, + private val inputMethodAdapter: InputMethodAdapter, + private val buildConfigProvider: BuildConfigProvider, +) : SetupInputMethodUseCase { + private val keyMapperImeHelper = + KeyMapperImeHelper(switchImeInterface, inputMethodAdapter, buildConfigProvider.packageName) + + override val isEnabled: Flow = keyMapperImeHelper.isCompatibleImeEnabledFlow + + override suspend fun enableInputMethod(): KMResult { + return keyMapperImeHelper.enableCompatibleInputMethods() + } + + override val isChosen: Flow = keyMapperImeHelper.isCompatibleImeChosenFlow + + override suspend fun chooseInputMethod(): KMResult { + // On Android 13+, the accessibility service can enable the input method without + // any user input + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return keyMapperImeHelper.enableCompatibleInputMethods() + .onFailure { + Timber.e("Failed to enable compatible input method: $it") + } + .suspendThen { + // Wait for the ime to be enabled asynchronously. + try { + withTimeout(3000L) { + isEnabled.first { it } + keyMapperImeHelper.chooseCompatibleInputMethod() + } + } catch (e: TimeoutCancellationException) { + KMError.Exception(e) + } + } + } else { + return keyMapperImeHelper.chooseCompatibleInputMethod() + } + } +} + +interface SetupInputMethodUseCase { + val isEnabled: Flow + suspend fun enableInputMethod(): KMResult + + val isChosen: Flow + suspend fun chooseInputMethod(): KMResult +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt index 2bba80a7ae..303993d860 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt @@ -7,13 +7,13 @@ import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.common.utils.withFlag import io.github.sds100.keymapper.data.entities.AssistantTriggerKeyEntity import io.github.sds100.keymapper.data.entities.EntityExtra +import io.github.sds100.keymapper.data.entities.EvdevTriggerKeyEntity import io.github.sds100.keymapper.data.entities.FingerprintTriggerKeyEntity import io.github.sds100.keymapper.data.entities.FloatingButtonEntityWithLayout import io.github.sds100.keymapper.data.entities.FloatingButtonKeyEntity -import io.github.sds100.keymapper.data.entities.KeyCodeTriggerKeyEntity +import io.github.sds100.keymapper.data.entities.KeyEventTriggerKeyEntity import io.github.sds100.keymapper.data.entities.TriggerEntity import io.github.sds100.keymapper.data.entities.getData -import io.github.sds100.keymapper.system.inputevents.InputEventUtils import kotlinx.serialization.Serializable @Serializable @@ -22,7 +22,6 @@ data class Trigger( val mode: TriggerMode = TriggerMode.Undefined, val vibrate: Boolean = false, val longPressDoubleVibration: Boolean = false, - val screenOffTrigger: Boolean = false, val longPressDelay: Int? = null, val doublePressDelay: Int? = null, val vibrateDuration: Int? = null, @@ -34,29 +33,22 @@ data class Trigger( fun isChangingVibrationDurationAllowed(): Boolean = vibrate || longPressDoubleVibration - fun isChangingLongPressDelayAllowed(): Boolean = keys.any { key -> key.clickType == ClickType.LONG_PRESS } - - fun isChangingDoublePressDelayAllowed(): Boolean = keys.any { key -> key.clickType == ClickType.DOUBLE_PRESS } - - fun isLongPressDoubleVibrationAllowed(): Boolean = (keys.size == 1 || (mode is TriggerMode.Parallel)) && - keys.getOrNull(0)?.clickType == ClickType.LONG_PRESS + fun isChangingLongPressDelayAllowed(): Boolean = keys.any { key -> + key.clickType == + ClickType.LONG_PRESS + } - /** - * Must check that it is not empty otherwise it would be true from the "all" check. - * It is not allowed if the key is an assistant button because it is assumed to be true - * anyway. - */ - fun isDetectingWhenScreenOffAllowed(): Boolean { - return keys.isNotEmpty() && - keys.all { - it is KeyCodeTriggerKey && - InputEventUtils.canDetectKeyWhenScreenOff( - it.keyCode, - ) - } + fun isChangingDoublePressDelayAllowed(): Boolean = keys.any { key -> + key.clickType == + ClickType.DOUBLE_PRESS } - fun isChangingSequenceTriggerTimeoutAllowed(): Boolean = keys.isNotEmpty() && keys.size > 1 && mode is TriggerMode.Sequence + fun isLongPressDoubleVibrationAllowed(): Boolean = + (keys.size == 1 || (mode is TriggerMode.Parallel)) && + keys.getOrNull(0)?.clickType == ClickType.LONG_PRESS + + fun isChangingSequenceTriggerTimeoutAllowed(): Boolean = + keys.isNotEmpty() && keys.size > 1 && mode is TriggerMode.Sequence fun updateFloatingButtonData(buttons: List): Trigger { val newTriggerKeys = keys.map { key -> @@ -89,7 +81,7 @@ object TriggerEntityMapper { val keys = entity.keys.map { key -> when (key) { is AssistantTriggerKeyEntity -> AssistantTriggerKey.fromEntity(key) - is KeyCodeTriggerKeyEntity -> KeyCodeTriggerKey.fromEntity( + is KeyEventTriggerKeyEntity -> KeyEventTriggerKey.fromEntity( key, ) is FloatingButtonKeyEntity -> { @@ -98,12 +90,15 @@ object TriggerEntityMapper { } is FingerprintTriggerKeyEntity -> FingerprintTriggerKey.fromEntity(key) + is EvdevTriggerKeyEntity -> EvdevTriggerKey.fromEntity(key) } } val mode = when { entity.mode == TriggerEntity.SEQUENCE && keys.size > 1 -> TriggerMode.Sequence - entity.mode == TriggerEntity.PARALLEL && keys.size > 1 -> TriggerMode.Parallel(keys[0].clickType) + entity.mode == TriggerEntity.PARALLEL && keys.size > 1 -> TriggerMode.Parallel( + keys[0].clickType, + ) else -> TriggerMode.Undefined } @@ -125,19 +120,22 @@ object TriggerEntityMapper { vibrateDuration = entity.extras.getData(TriggerEntity.EXTRA_VIBRATION_DURATION) .valueOrNull()?.toIntOrNull(), - sequenceTriggerTimeout = entity.extras.getData(TriggerEntity.EXTRA_SEQUENCE_TRIGGER_TIMEOUT) + sequenceTriggerTimeout = entity.extras.getData( + TriggerEntity.EXTRA_SEQUENCE_TRIGGER_TIMEOUT, + ) .valueOrNull()?.toIntOrNull(), triggerFromOtherApps = entity.flags.hasFlag(TriggerEntity.TRIGGER_FLAG_FROM_OTHER_APPS), showToast = entity.flags.hasFlag(TriggerEntity.TRIGGER_FLAG_SHOW_TOAST), - screenOffTrigger = entity.flags.hasFlag(TriggerEntity.TRIGGER_FLAG_SCREEN_OFF_TRIGGERS), ) } fun toEntity(trigger: Trigger): TriggerEntity { val extras = mutableListOf() - if (trigger.isChangingSequenceTriggerTimeoutAllowed() && trigger.sequenceTriggerTimeout != null) { + if (trigger.isChangingSequenceTriggerTimeoutAllowed() && + trigger.sequenceTriggerTimeout != null + ) { extras.add( EntityExtra( TriggerEntity.EXTRA_SEQUENCE_TRIGGER_TIMEOUT, @@ -189,10 +187,6 @@ object TriggerEntityMapper { flags = flags.withFlag(TriggerEntity.TRIGGER_FLAG_LONG_PRESS_DOUBLE_VIBRATION) } - if (trigger.isDetectingWhenScreenOffAllowed() && trigger.screenOffTrigger) { - flags = flags.withFlag(TriggerEntity.TRIGGER_FLAG_SCREEN_OFF_TRIGGERS) - } - if (trigger.triggerFromOtherApps) { flags = flags.withFlag(TriggerEntity.TRIGGER_FLAG_FROM_OTHER_APPS) } @@ -204,11 +198,12 @@ object TriggerEntityMapper { val keys = trigger.keys.map { key -> when (key) { is AssistantTriggerKey -> AssistantTriggerKey.toEntity(key) - is KeyCodeTriggerKey -> KeyCodeTriggerKey.toEntity( + is KeyEventTriggerKey -> KeyEventTriggerKey.toEntity( key, ) is FloatingButtonKey -> FloatingButtonKey.toEntity(key) is FingerprintTriggerKey -> FingerprintTriggerKey.toEntity(key) + is EvdevTriggerKey -> EvdevTriggerKey.toEntity(key) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverBottomSheet.kt new file mode 100644 index 0000000000..a358f590ad --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverBottomSheet.kt @@ -0,0 +1,80 @@ +package io.github.sds100.keymapper.base.trigger + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +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 kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TriggerDiscoverBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState, + onDismissRequest: () -> Unit = {}, + content: @Composable () -> Unit, +) { + val scope = rememberCoroutineScope() + + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismissRequest, + sheetState = sheetState, + // Hide drag handle because other bottom sheets don't have it + dragHandle = {}, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + content() + + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.neg_cancel)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewNoKeyRecordedComplete() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + TriggerDiscoverBottomSheet( + sheetState = sheetState, + content = { TriggerDiscoverScreen(showFloatingButtons = true) }, + ) + } +} 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 new file mode 100644 index 0000000000..9f83da30e9 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerDiscoverScreen.kt @@ -0,0 +1,278 @@ +package io.github.sds100.keymapper.base.trigger + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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 +import androidx.compose.material.icons.automirrored.outlined.VolumeUp +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.outlined.BubbleChart +import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.Mouse +import androidx.compose.material.icons.rounded.Fingerprint +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +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.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.base.utils.ui.compose.icons.IndeterminateQuestionBox +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.utils.ui.compose.icons.ModeOffOn +import io.github.sds100.keymapper.base.utils.ui.compose.icons.SportsEsports +import io.github.sds100.keymapper.base.utils.ui.compose.icons.VoiceSelection + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TriggerDiscoverScreen( + modifier: Modifier = Modifier, + onShortcutClick: (TriggerSetupShortcut) -> Unit = {}, + showFloatingButtons: Boolean = false, + showFingerprintGestures: Boolean = false, +) { + val customColors = LocalCustomColorsPalette.current + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column { + Text( + text = stringResource(R.string.trigger_discover_screen_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResource(R.string.trigger_discover_screen_subtitle), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + + TriggerSection( + title = stringResource(R.string.trigger_discover_section_on_device_buttons), + shortcuts = buildList { + add( + ShortcutData( + TriggerSetupShortcut.VOLUME, + stringResource(R.string.trigger_discover_shortcut_volume), + Icons.AutoMirrored.Outlined.VolumeUp, + ), + ) + + add( + ShortcutData( + TriggerSetupShortcut.ASSISTANT, + stringResource(R.string.trigger_discover_shortcut_assistant), + KeyMapperIcons.VoiceSelection, + ), + ) + + add( + ShortcutData( + TriggerSetupShortcut.POWER, + stringResource(R.string.trigger_discover_shortcut_power), + KeyMapperIcons.ModeOffOn, + ), + ) + + if (showFingerprintGestures) { + add( + ShortcutData( + TriggerSetupShortcut.FINGERPRINT_GESTURE, + stringResource(R.string.trigger_discover_shortcut_fingerprint_gesture), + Icons.Rounded.Fingerprint, + ), + ) + } + }, + onShortcutClick = onShortcutClick, + ) + + TriggerSection( + title = stringResource(R.string.trigger_discover_section_peripherals_gaming), + shortcuts = listOf( + ShortcutData( + TriggerSetupShortcut.KEYBOARD, + stringResource(R.string.trigger_discover_shortcut_keyboard), + Icons.Outlined.Keyboard, + ), + ShortcutData( + TriggerSetupShortcut.MOUSE, + stringResource(R.string.trigger_discover_shortcut_mouse), + Icons.Outlined.Mouse, + ), + ShortcutData( + TriggerSetupShortcut.GAMEPAD, + stringResource(R.string.trigger_discover_shortcut_gamepad), + KeyMapperIcons.SportsEsports, + ), + ShortcutData( + TriggerSetupShortcut.OTHER, + stringResource(R.string.trigger_discover_shortcut_other), + KeyMapperIcons.IndeterminateQuestionBox, + ), + ), + onShortcutClick = onShortcutClick, + ) + + AnimatedVisibility(visible = showFloatingButtons) { + TriggerSection( + title = stringResource(R.string.trigger_discover_section_floating_buttons), + shortcuts = listOf( + ShortcutData( + TriggerSetupShortcut.FLOATING_BUTTON_CUSTOM, + stringResource(R.string.trigger_discover_shortcut_custom), + Icons.Outlined.BubbleChart, + ), +// ShortcutData( +// TriggerSetupShortcut.NOTCH, +// stringResource(R.string.trigger_discover_shortcut_notch), +// Icons.Default.TouchApp +// ), + ShortcutData( + TriggerSetupShortcut.FLOATING_BUTTON_LOCK_SCREEN, + stringResource(R.string.trigger_discover_shortcut_lock_screen), + Icons.Default.PhoneAndroid, + ), + ), + onShortcutClick = onShortcutClick, + backgroundColor = customColors.amberContainer, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TriggerSection( + title: String, + shortcuts: List, + onShortcutClick: (TriggerSetupShortcut) -> Unit, + backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + for (shortcut in shortcuts) { + ShortcutButton( + shortcut = shortcut, + backgroundColor = backgroundColor, + onClick = { onShortcutClick(shortcut.type) }, + ) + } + } + } +} + +@Composable +private fun ShortcutButton( + modifier: Modifier = Modifier, + shortcut: ShortcutData, + backgroundColor: Color, + onClick: () -> Unit, +) { + Column( + modifier = modifier.widthIn(max = 56.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Surface( + modifier = Modifier.size(48.dp), + onClick = onClick, + shape = MaterialTheme.shapes.small, + color = backgroundColor, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = shortcut.icon, + contentDescription = shortcut.label, + tint = MaterialTheme.colorScheme.contentColorFor(backgroundColor).takeOrElse { + LocalCustomColorsPalette.current.contentColorFor(backgroundColor) + }, + ) + } + } + + Text( + text = shortcut.label, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private data class ShortcutData( + val type: TriggerSetupShortcut, + val label: String, + val icon: ImageVector, +) + +@Preview(name = "Normal Phone") +@Composable +private fun TriggerDiscoverScreenPreview() { + KeyMapperTheme { + Surface { + TriggerDiscoverScreen() + } + } +} + +@Preview(name = "Small Phone", widthDp = 320, heightDp = 568) +@Composable +private fun TriggerDiscoverScreenSmallPhonePreview() { + KeyMapperTheme { + Surface { + TriggerDiscoverScreen() + } + } +} + +@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun TriggerDiscoverScreenDarkModePreview() { + KeyMapperTheme { + Surface { + TriggerDiscoverScreen() + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerError.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerError.kt index b50cc06efe..eea5a79fc6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerError.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerError.kt @@ -2,7 +2,6 @@ package io.github.sds100.keymapper.base.trigger enum class TriggerError(val isFixable: Boolean) { DND_ACCESS_DENIED(isFixable = true), - SCREEN_OFF_ROOT_DENIED(isFixable = true), CANT_DETECT_IN_PHONE_CALL(isFixable = true), // This error appears when a key map has an assistant trigger but the user hasn't purchased @@ -19,4 +18,10 @@ enum class TriggerError(val isFixable: Boolean) { FLOATING_BUTTONS_NOT_PURCHASED(isFixable = true), PURCHASE_VERIFICATION_FAILED(isFixable = true), + + SYSTEM_BRIDGE_UNSUPPORTED(isFixable = false), + + SYSTEM_BRIDGE_DISCONNECTED(isFixable = true), + + EVDEV_DEVICE_NOT_FOUND(isFixable = false), } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerErrorSnapshot.kt index 70184806ee..86335276b2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerErrorSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerErrorSnapshot.kt @@ -1,15 +1,15 @@ package io.github.sds100.keymapper.base.trigger -import android.os.Build import android.view.KeyEvent import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.keymaps.requiresImeKeyEventForwardingInPhoneCall import io.github.sds100.keymapper.base.purchasing.ProductId import io.github.sds100.keymapper.base.purchasing.PurchasingError +import io.github.sds100.keymapper.common.models.EvdevDeviceInfo import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.onSuccess -import io.github.sds100.keymapper.system.inputevents.InputEventUtils +import io.github.sds100.keymapper.system.inputevents.KeyEventUtils /** * Store the data required for determining trigger errors to reduce the number of calls with @@ -21,6 +21,14 @@ data class TriggerErrorSnapshot( val isRootGranted: Boolean, val purchases: KMResult>, val showDpadImeSetupError: Boolean, + /** + * Can be null if the sdk version is not high enough. + */ + val isSystemBridgeConnected: Boolean?, + /** + * Can be null if the sdk version is not high enough. + */ + val evdevDevices: List?, ) { companion object { private val keysThatRequireDndAccess = arrayOf( @@ -39,7 +47,9 @@ data class TriggerErrorSnapshot( return TriggerError.FLOATING_BUTTONS_NOT_PURCHASED } }.onFailure { error -> - if ((key is AssistantTriggerKey || key is FloatingButtonKey) && error == PurchasingError.PurchasingProcessError.NetworkError) { + if ((key is AssistantTriggerKey || key is FloatingButtonKey) && + error == PurchasingError.PurchasingProcessError.NetworkError + ) { return TriggerError.PURCHASE_VERIFICATION_FAILED } } @@ -54,32 +64,37 @@ data class TriggerErrorSnapshot( } val requiresDndAccess = - key is KeyCodeTriggerKey && key.keyCode in keysThatRequireDndAccess + key is KeyEventTriggerKey && key.keyCode in keysThatRequireDndAccess if (requiresDndAccess) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !isDndAccessGranted) { + if (!isDndAccessGranted) { return TriggerError.DND_ACCESS_DENIED } } - if (keyMap.trigger.screenOffTrigger && - !isRootGranted && - keyMap.trigger.isDetectingWhenScreenOffAllowed() - ) { - return TriggerError.SCREEN_OFF_ROOT_DENIED - } - val containsDpadKey = - key is KeyCodeTriggerKey && - InputEventUtils.isDpadKeyCode( - key.keyCode, - ) && - key.detectionSource == KeyEventDetectionSource.INPUT_METHOD + key is KeyEventTriggerKey && + KeyEventUtils.isDpadKeyCode(key.keyCode) && + key.requiresIme if (showDpadImeSetupError && !isKeyMapperImeChosen && containsDpadKey) { return TriggerError.DPAD_IME_NOT_SELECTED } + if (key is EvdevTriggerKey) { + if (isSystemBridgeConnected == null) { + return TriggerError.SYSTEM_BRIDGE_UNSUPPORTED + } + + if (!isSystemBridgeConnected) { + return TriggerError.SYSTEM_BRIDGE_DISCONNECTED + } + + if (evdevDevices != null && !evdevDevices.contains(key.device)) { + return TriggerError.EVDEV_DEVICE_NOT_FOUND + } + } + return null } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKey.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKey.kt index 1239059181..89221f71a3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKey.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKey.kt @@ -7,22 +7,45 @@ import kotlinx.serialization.Serializable sealed class TriggerKey : Comparable { abstract val clickType: ClickType - /** - * Whether the event that triggers this key will be consumed and not passed - * onto subsequent apps. E.g consuming the volume down key event will mean the volume - * doesn't change. - */ - abstract val consumeEvent: Boolean abstract val uid: String abstract val allowedLongPress: Boolean abstract val allowedDoublePress: Boolean fun setClickType(clickType: ClickType): TriggerKey = when (this) { is AssistantTriggerKey -> copy(clickType = clickType) - is KeyCodeTriggerKey -> copy(clickType = clickType) + is KeyEventTriggerKey -> copy(clickType = clickType) is FloatingButtonKey -> copy(clickType = clickType) is FingerprintTriggerKey -> copy(clickType = clickType) + is EvdevTriggerKey -> copy(clickType = clickType) } override fun compareTo(other: TriggerKey) = this.javaClass.name.compareTo(other.javaClass.name) + + fun isLogicallyEqual(other: TriggerKey): Boolean { + when { + this is KeyCodeTriggerKey && other is KeyCodeTriggerKey -> { + if (this.detectWithScancode()) { + return this.scanCode == other.scanCode && this.isSameDevice(other) + } else { + return this.keyCode == other.keyCode && this.isSameDevice(other) + } + } + + // Assistant triggers can not be triggered at the same time. + this is AssistantTriggerKey && other is AssistantTriggerKey -> { + return true + } + + // Fingerprint gestures can not be triggered at the same time. + this is FingerprintTriggerKey && other is FingerprintTriggerKey -> { + return true + } + + this is FloatingButtonKey && other is FloatingButtonKey -> { + return this.buttonUid == other.buttonUid + } + } + + return false + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyDevice.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyDevice.kt deleted file mode 100644 index 08d7942721..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyDevice.kt +++ /dev/null @@ -1,47 +0,0 @@ -package io.github.sds100.keymapper.base.trigger - -import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import kotlinx.serialization.Serializable - -@Serializable -sealed class TriggerKeyDevice : Comparable { - override fun compareTo(other: TriggerKeyDevice) = this.javaClass.name.compareTo(other.javaClass.name) - - @Serializable - data object Internal : TriggerKeyDevice() - - @Serializable - data object Any : TriggerKeyDevice() - - @Serializable - data class External(val descriptor: String, val name: String) : TriggerKeyDevice() { - override fun compareTo(other: TriggerKeyDevice): Int { - if (other !is External) { - return super.compareTo(other) - } - - return compareValuesBy( - this, - other, - { it.name }, - { it.descriptor }, - ) - } - } - - fun isSameDevice(other: TriggerKeyDevice): Boolean { - if (other is External && this is External) { - return other.descriptor == this.descriptor - } else { - return true - } - } - - fun isSameDevice(device: InputDeviceInfo): Boolean { - if (this is External && device.isExternal) { - return device.descriptor == this.descriptor - } else { - return true - } - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt index 4e38181428..d04962ecb5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt @@ -46,6 +46,8 @@ import io.github.sds100.keymapper.base.keymaps.ClickType import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType import io.github.sds100.keymapper.base.utils.ui.LinkType import io.github.sds100.keymapper.base.utils.ui.compose.DragDropState +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.utils.ui.compose.icons.ProModeIcon @Composable fun TriggerKeyListItem( @@ -112,6 +114,7 @@ fun TriggerKeyListItem( is TriggerKeyListItemModel.Assistant -> Icons.Outlined.Assistant is TriggerKeyListItemModel.FloatingButton -> Icons.Outlined.BubbleChart is TriggerKeyListItemModel.FingerprintGesture -> Icons.Outlined.Fingerprint + is TriggerKeyListItemModel.EvdevEvent -> KeyMapperIcons.ProModeIcon else -> null } @@ -127,9 +130,15 @@ fun TriggerKeyListItem( val primaryText = when (model) { is TriggerKeyListItemModel.Assistant -> when (model.assistantType) { - AssistantTriggerType.ANY -> stringResource(R.string.assistant_any_trigger_name) - AssistantTriggerType.VOICE -> stringResource(R.string.assistant_voice_trigger_name) - AssistantTriggerType.DEVICE -> stringResource(R.string.assistant_device_trigger_name) + AssistantTriggerType.ANY -> stringResource( + R.string.assistant_any_trigger_name, + ) + AssistantTriggerType.VOICE -> stringResource( + R.string.assistant_voice_trigger_name, + ) + AssistantTriggerType.DEVICE -> stringResource( + R.string.assistant_device_trigger_name, + ) } is TriggerKeyListItemModel.FloatingButton -> stringResource( @@ -137,15 +146,26 @@ fun TriggerKeyListItem( model.buttonName, ) - is TriggerKeyListItemModel.KeyCode -> model.keyName + is TriggerKeyListItemModel.KeyEvent -> model.keyName + is TriggerKeyListItemModel.EvdevEvent -> model.keyName - is TriggerKeyListItemModel.FloatingButtonDeleted -> stringResource(R.string.trigger_error_floating_button_deleted_title) + is TriggerKeyListItemModel.FloatingButtonDeleted -> stringResource( + R.string.trigger_error_floating_button_deleted_title, + ) is TriggerKeyListItemModel.FingerprintGesture -> when (model.gestureType) { - FingerprintGestureType.SWIPE_UP -> stringResource(R.string.trigger_key_fingerprint_gesture_up) - FingerprintGestureType.SWIPE_DOWN -> stringResource(R.string.trigger_key_fingerprint_gesture_down) - FingerprintGestureType.SWIPE_LEFT -> stringResource(R.string.trigger_key_fingerprint_gesture_left) - FingerprintGestureType.SWIPE_RIGHT -> stringResource(R.string.trigger_key_fingerprint_gesture_right) + FingerprintGestureType.SWIPE_UP -> stringResource( + R.string.trigger_key_fingerprint_gesture_up, + ) + FingerprintGestureType.SWIPE_DOWN -> stringResource( + R.string.trigger_key_fingerprint_gesture_down, + ) + FingerprintGestureType.SWIPE_LEFT -> stringResource( + R.string.trigger_key_fingerprint_gesture_left, + ) + FingerprintGestureType.SWIPE_RIGHT -> stringResource( + R.string.trigger_key_fingerprint_gesture_right, + ) } } @@ -159,7 +179,8 @@ fun TriggerKeyListItem( } val tertiaryText = when (model) { - is TriggerKeyListItemModel.KeyCode -> model.extraInfo + is TriggerKeyListItemModel.KeyEvent -> model.extraInfo + is TriggerKeyListItemModel.EvdevEvent -> model.extraInfo is TriggerKeyListItemModel.FloatingButton -> model.layoutName else -> null @@ -205,7 +226,9 @@ fun TriggerKeyListItem( IconButton(onClick = onEditClick) { Icon( imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(R.string.trigger_key_list_item_edit), + contentDescription = stringResource( + R.string.trigger_key_list_item_edit, + ), tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(24.dp), ) @@ -215,7 +238,9 @@ fun TriggerKeyListItem( IconButton(onClick = onRemoveClick) { Icon( imageVector = Icons.Rounded.Clear, - contentDescription = stringResource(R.string.trigger_key_list_item_remove), + contentDescription = stringResource( + R.string.trigger_key_list_item_remove, + ), tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(24.dp), ) @@ -253,13 +278,33 @@ fun TriggerKeyListItem( private fun getErrorMessage(error: TriggerError): String { return when (error) { TriggerError.DND_ACCESS_DENIED -> stringResource(R.string.trigger_error_dnd_access_denied) - TriggerError.SCREEN_OFF_ROOT_DENIED -> stringResource(R.string.trigger_error_screen_off_root_permission_denied) - TriggerError.CANT_DETECT_IN_PHONE_CALL -> stringResource(R.string.trigger_error_cant_detect_in_phone_call) - TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> stringResource(R.string.trigger_error_assistant_not_purchased) - TriggerError.DPAD_IME_NOT_SELECTED -> stringResource(R.string.trigger_error_dpad_ime_not_selected) - TriggerError.FLOATING_BUTTON_DELETED -> stringResource(R.string.trigger_error_floating_button_deleted) - TriggerError.FLOATING_BUTTONS_NOT_PURCHASED -> stringResource(R.string.trigger_error_floating_buttons_not_purchased) - TriggerError.PURCHASE_VERIFICATION_FAILED -> stringResource(R.string.trigger_error_product_verification_failed) + TriggerError.CANT_DETECT_IN_PHONE_CALL -> stringResource( + R.string.trigger_error_cant_detect_in_phone_call, + ) + TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> stringResource( + R.string.trigger_error_assistant_not_purchased, + ) + TriggerError.DPAD_IME_NOT_SELECTED -> stringResource( + R.string.trigger_error_dpad_ime_not_selected, + ) + TriggerError.FLOATING_BUTTON_DELETED -> stringResource( + R.string.trigger_error_floating_button_deleted, + ) + TriggerError.FLOATING_BUTTONS_NOT_PURCHASED -> stringResource( + R.string.trigger_error_floating_buttons_not_purchased, + ) + TriggerError.PURCHASE_VERIFICATION_FAILED -> stringResource( + R.string.trigger_error_product_verification_failed, + ) + TriggerError.SYSTEM_BRIDGE_UNSUPPORTED -> stringResource( + R.string.trigger_error_system_bridge_unsupported, + ) + TriggerError.SYSTEM_BRIDGE_DISCONNECTED -> stringResource( + R.string.trigger_error_system_bridge_disconnected, + ) + TriggerError.EVDEV_DEVICE_NOT_FOUND -> stringResource( + R.string.trigger_error_evdev_device_not_found, + ) } } @@ -296,11 +341,7 @@ private fun TextColumn( } @Composable -private fun ErrorTextColumn( - modifier: Modifier = Modifier, - primaryText: String, - errorText: String, -) { +private fun ErrorTextColumn(modifier: Modifier = Modifier, primaryText: String, errorText: String) { Column( modifier = modifier, ) { @@ -322,9 +363,9 @@ private fun ErrorTextColumn( @Preview @Composable -private fun KeyCodePreview() { +private fun KeyEventPreview() { TriggerKeyListItem( - model = TriggerKeyListItemModel.KeyCode( + model = TriggerKeyListItemModel.KeyEvent( id = "id", keyName = "Volume Up", clickType = ClickType.SHORT_PRESS, @@ -338,11 +379,29 @@ private fun KeyCodePreview() { ) } +@Preview +@Composable +private fun EvdevEventPreview() { + TriggerKeyListItem( + model = TriggerKeyListItemModel.EvdevEvent( + id = "id", + keyName = "Volume Up", + clickType = ClickType.SHORT_PRESS, + extraInfo = "Gpio-keys", + linkType = LinkType.ARROW, + error = null, + ), + isDragging = false, + isReorderingEnabled = true, + index = 0, + ) +} + @Preview @Composable private fun NoDragPreview() { TriggerKeyListItem( - model = TriggerKeyListItemModel.KeyCode( + model = TriggerKeyListItemModel.KeyEvent( id = "id", keyName = "Volume Up", clickType = ClickType.LONG_PRESS, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt index e104ecf8c9..b56911fc7b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt @@ -1,8 +1,8 @@ package io.github.sds100.keymapper.base.trigger +import android.view.KeyEvent import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -23,6 +23,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue.Expanded import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -32,16 +33,21 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowWidthSizeClass import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.keymaps.ClickType import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType import io.github.sds100.keymapper.base.utils.ui.CheckBoxListItem import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText import io.github.sds100.keymapper.base.utils.ui.compose.openUriSafe +import io.github.sds100.keymapper.system.inputevents.Scancode import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -58,7 +64,10 @@ fun TriggerKeyOptionsBottomSheet( onSelectFingerprintGestureType: (FingerprintGestureType) -> Unit = {}, onEditFloatingButtonClick: () -> Unit = {}, onEditFloatingLayoutClick: () -> Unit = {}, + onScanCodeDetectionChanged: (Boolean) -> Unit = {}, ) { + val isCompact = isVerticalCompactLayout() + ModalBottomSheet( modifier = modifier, onDismissRequest = onDismissRequest, @@ -66,37 +75,42 @@ fun TriggerKeyOptionsBottomSheet( // Hide drag handle because other bottom sheets don't have it dragHandle = {}, ) { - val uriHandler = LocalUriHandler.current - val ctx = LocalContext.current - val helpUrl = stringResource(R.string.url_trigger_key_options_guide) val scope = rememberCoroutineScope() Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Spacer(modifier = Modifier.height(12.dp)) Box(modifier = Modifier.fillMaxWidth()) { Text( - modifier = Modifier.align(Alignment.Center), + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .padding(horizontal = 48.dp), textAlign = TextAlign.Center, text = stringResource(R.string.trigger_key_options_title), style = MaterialTheme.typography.headlineMedium, + overflow = TextOverflow.Ellipsis, ) - IconButton( + HelpIconButton( modifier = Modifier .align(Alignment.TopEnd) .padding(horizontal = 8.dp), - onClick = { uriHandler.openUriSafe(ctx, helpUrl) }, - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.HelpOutline, - contentDescription = null, - ) - } + ) } Spacer(modifier = Modifier.height(8.dp)) - if (state is TriggerKeyOptionsState.KeyCode) { + if (state is TriggerKeyOptionsState.KeyEvent) { + ScanCodeDetectionButtonRow( + modifier = Modifier.fillMaxWidth(), + isEnabled = state.isScanCodeSettingEnabled, + isScanCodeSelected = state.isScanCodeDetectionSelected, + keyCode = state.keyCode, + scanCode = state.scanCode, + onSelectedChange = onScanCodeDetectionChanged, + isCompact = isCompact, + ) + CheckBoxText( modifier = Modifier.padding(8.dp), text = stringResource(R.string.flag_dont_override_default_action), @@ -105,54 +119,47 @@ fun TriggerKeyOptionsBottomSheet( ) } - if (state.showClickTypes) { - Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = stringResource(R.string.trigger_key_click_types_header), - style = MaterialTheme.typography.titleSmall, + if (state is TriggerKeyOptionsState.EvdevEvent) { + ScanCodeDetectionButtonRow( + modifier = Modifier.fillMaxWidth(), + isEnabled = state.isScanCodeSettingEnabled, + isScanCodeSelected = state.isScanCodeDetectionSelected, + keyCode = state.keyCode, + scanCode = state.scanCode, + onSelectedChange = onScanCodeDetectionChanged, + isCompact = isCompact, ) - FlowRow( + CheckBoxText( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.flag_dont_override_default_action), + isChecked = state.doNotRemapChecked, + onCheckedChange = onCheckDoNotRemap, + ) + } + + if (state.showClickTypes) { + ClickTypeSection( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp), - ) { - Spacer(Modifier.width(8.dp)) - - RadioButtonText( - modifier = Modifier.weight(1f), - isSelected = state.clickType == ClickType.SHORT_PRESS, - text = stringResource(R.string.radio_button_short_press), - onSelected = { onSelectClickType(ClickType.SHORT_PRESS) }, - ) - - if (state.showLongPressClickType) { - RadioButtonText( - modifier = Modifier.weight(1f), - isSelected = state.clickType == ClickType.LONG_PRESS, - text = stringResource(R.string.radio_button_long_press), - onSelected = { onSelectClickType(ClickType.LONG_PRESS) }, - ) - } - - RadioButtonText( - modifier = Modifier.weight(1f), - isSelected = state.clickType == ClickType.DOUBLE_PRESS, - text = stringResource(R.string.radio_button_double_press), - onSelected = { onSelectClickType(ClickType.DOUBLE_PRESS) }, - ) + .padding(horizontal = 16.dp), + state, + onSelectClickType, + isCompact = isCompact, + ) - Spacer(Modifier.width(8.dp)) - } + Spacer(Modifier.height(8.dp)) } - if (state is TriggerKeyOptionsState.KeyCode) { + if (state is TriggerKeyOptionsState.KeyEvent) { Text( modifier = Modifier.padding(horizontal = 16.dp), text = stringResource(R.string.trigger_key_device_header), style = MaterialTheme.typography.titleSmall, ) + Spacer(Modifier.height(8.dp)) + for (device in state.devices) { RadioButtonText( modifier = Modifier.padding(horizontal = 8.dp), @@ -199,28 +206,36 @@ fun TriggerKeyOptionsBottomSheet( modifier = Modifier.padding(horizontal = 8.dp), text = stringResource(R.string.fingerprint_gesture_down), isSelected = state.gestureType == FingerprintGestureType.SWIPE_DOWN, - onSelected = { onSelectFingerprintGestureType(FingerprintGestureType.SWIPE_DOWN) }, + onSelected = { + onSelectFingerprintGestureType(FingerprintGestureType.SWIPE_DOWN) + }, ) RadioButtonText( modifier = Modifier.padding(horizontal = 8.dp), text = stringResource(R.string.fingerprint_gesture_up), isSelected = state.gestureType == FingerprintGestureType.SWIPE_UP, - onSelected = { onSelectFingerprintGestureType(FingerprintGestureType.SWIPE_UP) }, + onSelected = { + onSelectFingerprintGestureType(FingerprintGestureType.SWIPE_UP) + }, ) RadioButtonText( modifier = Modifier.padding(horizontal = 8.dp), text = stringResource(R.string.fingerprint_gesture_left), isSelected = state.gestureType == FingerprintGestureType.SWIPE_LEFT, - onSelected = { onSelectFingerprintGestureType(FingerprintGestureType.SWIPE_LEFT) }, + onSelected = { + onSelectFingerprintGestureType(FingerprintGestureType.SWIPE_LEFT) + }, ) RadioButtonText( modifier = Modifier.padding(horizontal = 8.dp), text = stringResource(R.string.fingerprint_gesture_right), isSelected = state.gestureType == FingerprintGestureType.SWIPE_RIGHT, - onSelected = { onSelectFingerprintGestureType(FingerprintGestureType.SWIPE_RIGHT) }, + onSelected = { + onSelectFingerprintGestureType(FingerprintGestureType.SWIPE_RIGHT) + }, ) } @@ -236,7 +251,11 @@ fun TriggerKeyOptionsBottomSheet( modifier = Modifier.weight(1f), onClick = onEditFloatingButtonClick, ) { - Text(stringResource(R.string.floating_button_trigger_option_configure_button)) + Text( + stringResource( + R.string.floating_button_trigger_option_configure_button, + ), + ) } Spacer(Modifier.width(16.dp)) @@ -283,10 +302,112 @@ fun TriggerKeyOptionsBottomSheet( } } +@Composable +private fun HelpIconButton(modifier: Modifier) { + val uriHandler = LocalUriHandler.current + val helpUrl = stringResource(R.string.url_trigger_key_options_guide) + val ctx = LocalContext.current + + IconButton( + modifier = modifier, + onClick = { uriHandler.openUriSafe(ctx, helpUrl) }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.HelpOutline, + contentDescription = null, + ) + } +} + +@Composable +private fun ClickTypeSection( + modifier: Modifier, + state: TriggerKeyOptionsState, + onSelectClickType: (ClickType) -> Unit, + isCompact: Boolean, +) { + Column(modifier) { + Text( + text = stringResource(R.string.trigger_key_click_types_header), + style = MaterialTheme.typography.titleSmall, + ) + + Spacer(Modifier.height(8.dp)) + + val clickTypeButtonContent: List> = buildList { + add(ClickType.SHORT_PRESS to stringResource(R.string.radio_button_short_press)) + if (state.showLongPressClickType) { + add(ClickType.LONG_PRESS to stringResource(R.string.radio_button_long_press)) + } + add(ClickType.DOUBLE_PRESS to stringResource(R.string.radio_button_double_press)) + } + + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + buttonStates = clickTypeButtonContent, + selectedState = state.clickType, + onStateSelected = onSelectClickType, + isCompact = isCompact, + ) + } +} + +@Composable +private fun ScanCodeDetectionButtonRow( + modifier: Modifier = Modifier, + keyCode: Int, + scanCode: Int?, + isEnabled: Boolean, + isScanCodeSelected: Boolean, + onSelectedChange: (Boolean) -> Unit, + isCompact: Boolean, +) { + Column(modifier) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(R.string.trigger_scan_code_detection_explanation), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(8.dp)) + + val buttonStates = listOf( + false to if (keyCode == KeyEvent.KEYCODE_UNKNOWN) { + stringResource(R.string.trigger_use_key_code_button_disabled) + } else { + stringResource(R.string.trigger_use_key_code_button_enabled, keyCode) + }, + true to if (scanCode == null) { + stringResource(R.string.trigger_use_scan_code_button_disabled) + } else { + stringResource(R.string.trigger_use_scan_code_button_enabled, scanCode) + }, + ) + + KeyMapperSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + buttonStates = buttonStates, + selectedState = isScanCodeSelected, + onStateSelected = onSelectedChange, + isCompact = isCompact, + isEnabled = isEnabled, + ) + } +} + +@Composable +private fun isVerticalCompactLayout(): Boolean { + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + + return windowSizeClass.windowHeightSizeClass == WindowHeightSizeClass.COMPACT && + windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT +} + @OptIn(ExperimentalMaterial3Api::class) @Preview @Composable -private fun Preview() { +private fun PreviewKeyEvent() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, @@ -296,7 +417,7 @@ private fun Preview() { TriggerKeyOptionsBottomSheet( sheetState = sheetState, - state = TriggerKeyOptionsState.KeyCode( + state = TriggerKeyOptionsState.KeyEvent( doNotRemapChecked = true, clickType = ClickType.DOUBLE_PRESS, showClickTypes = true, @@ -312,6 +433,74 @@ private fun Preview() { isChecked = false, ), ), + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + isScanCodeDetectionSelected = true, + isScanCodeSettingEnabled = true, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(heightDp = 400, widthDp = 300) +@Composable +private fun PreviewKeyEventTiny() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = Expanded, + ) + + TriggerKeyOptionsBottomSheet( + sheetState = sheetState, + state = TriggerKeyOptionsState.KeyEvent( + doNotRemapChecked = true, + clickType = ClickType.DOUBLE_PRESS, + showClickTypes = true, + devices = listOf( + CheckBoxListItem( + id = "id1", + label = "Device 1", + isChecked = true, + ), + CheckBoxListItem( + id = "id2", + label = "Device 2", + isChecked = false, + ), + ), + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + isScanCodeDetectionSelected = true, + isScanCodeSettingEnabled = true, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewEvdev() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = Expanded, + ) + + TriggerKeyOptionsBottomSheet( + sheetState = sheetState, + state = TriggerKeyOptionsState.EvdevEvent( + doNotRemapChecked = true, + clickType = ClickType.DOUBLE_PRESS, + showClickTypes = true, + keyCode = KeyEvent.KEYCODE_UNKNOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + isScanCodeDetectionSelected = true, + isScanCodeSettingEnabled = false, ), ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyShortcut.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyShortcut.kt deleted file mode 100644 index c94755cbeb..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyShortcut.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.sds100.keymapper.base.trigger - -enum class TriggerKeyShortcut { - ASSISTANT, - FLOATING_BUTTON, - FINGERPRINT_GESTURE, -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt new file mode 100644 index 0000000000..18a9d505ce --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupBottomSheet.kt @@ -0,0 +1,1288 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.github.sds100.keymapper.base.trigger + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.VolumeUp +import androidx.compose.material.icons.outlined.Mouse +import androidx.compose.material.icons.rounded.Fingerprint +import androidx.compose.material.icons.rounded.Keyboard +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.base.utils.ProModeStatus +import io.github.sds100.keymapper.base.utils.ui.compose.AccessibilityServiceRequirementRow +import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.HeaderText +import io.github.sds100.keymapper.base.utils.ui.compose.InputMethodRequirementRow +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow +import io.github.sds100.keymapper.base.utils.ui.compose.ProModeRequirementRow +import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText +import io.github.sds100.keymapper.base.utils.ui.compose.icons.IndeterminateQuestionBox +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.utils.ui.compose.icons.ModeOffOn +import io.github.sds100.keymapper.base.utils.ui.compose.icons.SportsEsports +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HandleTriggerSetupBottomSheet(delegate: TriggerSetupDelegate) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val triggerSetupState: TriggerSetupState? by + delegate.triggerSetupState.collectAsStateWithLifecycle() + + when (triggerSetupState) { + is TriggerSetupState.Volume -> VolumeTriggerSetupBottomSheet( + sheetState = sheetState, + state = triggerSetupState as TriggerSetupState.Volume, + onDismissRequest = delegate::onDismissTriggerSetup, + onEnableAccessibilityServiceClick = delegate::onEnableAccessibilityServiceClick, + onEnableProModeClick = delegate::onEnableProModeClick, + onRecordTriggerClick = delegate::onTriggerSetupRecordClick, + onUseProModeCheckedChange = delegate::onUseProModeCheckedChange, + ) + + is TriggerSetupState.Power -> PowerTriggerSetupBottomSheet( + sheetState = sheetState, + state = triggerSetupState as TriggerSetupState.Power, + onDismissRequest = delegate::onDismissTriggerSetup, + onEnableAccessibilityServiceClick = delegate::onEnableAccessibilityServiceClick, + onEnableProModeClick = delegate::onEnableProModeClick, + onRecordTriggerClick = delegate::onTriggerSetupRecordClick, + ) + + is TriggerSetupState.FingerprintGesture -> FingerprintGestureSetupBottomSheet( + sheetState = sheetState, + state = triggerSetupState as TriggerSetupState.FingerprintGesture, + onDismissRequest = delegate::onDismissTriggerSetup, + onEnableAccessibilityServiceClick = delegate::onEnableAccessibilityServiceClick, + onGestureTypeSelected = delegate::onFingerprintGestureTypeSelected, + onAddTriggerClick = delegate::onAddFingerprintGestureClick, + ) + + is TriggerSetupState.Keyboard -> KeyboardTriggerSetupBottomSheet( + sheetState = sheetState, + state = triggerSetupState as TriggerSetupState.Keyboard, + onDismissRequest = delegate::onDismissTriggerSetup, + onEnableAccessibilityServiceClick = delegate::onEnableAccessibilityServiceClick, + onEnableProModeClick = delegate::onEnableProModeClick, + onRecordTriggerClick = delegate::onTriggerSetupRecordClick, + onUseProModeCheckedChange = delegate::onUseProModeCheckedChange, + ) + + is TriggerSetupState.Mouse -> MouseTriggerSetupBottomSheet( + sheetState = sheetState, + state = triggerSetupState as TriggerSetupState.Mouse, + onDismissRequest = delegate::onDismissTriggerSetup, + onEnableAccessibilityServiceClick = delegate::onEnableAccessibilityServiceClick, + onEnableProModeClick = delegate::onEnableProModeClick, + onRecordTriggerClick = delegate::onTriggerSetupRecordClick, + ) + + is TriggerSetupState.Other -> OtherTriggerSetupBottomSheet( + sheetState = sheetState, + state = triggerSetupState as TriggerSetupState.Other, + onDismissRequest = delegate::onDismissTriggerSetup, + onEnableAccessibilityServiceClick = delegate::onEnableAccessibilityServiceClick, + onEnableProModeClick = delegate::onEnableProModeClick, + onRecordTriggerClick = delegate::onTriggerSetupRecordClick, + onUseProModeCheckedChange = delegate::onUseProModeCheckedChange, + ) + + is TriggerSetupState.NotDetected -> NotDetectedSetupBottomSheet( + sheetState = sheetState, + state = triggerSetupState as TriggerSetupState.NotDetected, + onDismissRequest = delegate::onDismissTriggerSetup, + onEnableAccessibilityServiceClick = delegate::onEnableAccessibilityServiceClick, + onEnableProModeClick = delegate::onEnableProModeClick, + onRecordTriggerClick = delegate::onTriggerSetupRecordClick, + ) + + is TriggerSetupState.Gamepad -> GamepadTriggerSetupBottomSheet( + sheetState = sheetState, + state = triggerSetupState as TriggerSetupState.Gamepad, + onDismissRequest = delegate::onDismissTriggerSetup, + onEnableAccessibilityServiceClick = delegate::onEnableAccessibilityServiceClick, + onSelectButtonType = delegate::onGamepadButtonTypeSelected, + onRecordTriggerClick = delegate::onTriggerSetupRecordClick, + onEnableInputMethodClick = delegate::onEnableImeClick, + onChooseInputMethodClick = delegate::onChooseImeClick, + onUseProModeCheckedChange = delegate::onUseProModeCheckedChange, + onEnableProModeClick = delegate::onEnableProModeClick, + ) + + null -> {} + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GamepadTriggerSetupBottomSheet( + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit = {}, + sheetState: SheetState, + state: TriggerSetupState.Gamepad, + onRecordTriggerClick: () -> Unit = {}, + onEnableAccessibilityServiceClick: () -> Unit = {}, + onSelectButtonType: (TriggerSetupState.Gamepad.Type) -> Unit = { }, + onEnableProModeClick: () -> Unit = {}, + onEnableInputMethodClick: () -> Unit = { }, + onChooseInputMethodClick: () -> Unit = { }, + onUseProModeCheckedChange: (Boolean) -> Unit = {}, +) { + TriggerSetupBottomSheet( + modifier = modifier, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.trigger_setup_gamepad_title), + icon = KeyMapperIcons.SportsEsports, + positiveButtonContent = { + if (state.areRequirementsMet) { + RecordTriggerButton( + modifier = Modifier.weight(1f), + state = state.recordTriggerState, + onClick = onRecordTriggerClick, + ) + } else { + TriggerRequirementsNotMetButton(modifier = Modifier.weight(1f)) + } + }, + ) { + // There is no guarantee that a gamepad can be remapped + RemapStatusRow( + modifier = Modifier.fillMaxWidth(), + color = LocalCustomColorsPalette.current.orange, + text = stringResource(R.string.trigger_setup_status_might_remap_device), + ) + + HeaderText(text = stringResource(R.string.trigger_setup_options_title)) + + val buttonStates = listOf( + TriggerSetupState.Gamepad.Type.DPAD to + stringResource(R.string.trigger_setup_gamepad_type_dpad), + TriggerSetupState.Gamepad.Type.SIMPLE_BUTTONS to + stringResource(R.string.trigger_setup_gamepad_type_simple_buttons), + ) + + val selectedState = when (state) { + is TriggerSetupState.Gamepad.Dpad -> TriggerSetupState.Gamepad.Type.DPAD + is TriggerSetupState.Gamepad.SimpleButtons -> + TriggerSetupState.Gamepad.Type.SIMPLE_BUTTONS + } + + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + buttonStates = buttonStates, + selectedState = selectedState, + onStateSelected = onSelectButtonType, + ) + + val isUseProModeChecked = when (state) { + is TriggerSetupState.Gamepad.Dpad -> false + is TriggerSetupState.Gamepad.SimpleButtons -> state.isUseProModeChecked + } + + val isUseProModeEnabled = when (state) { + is TriggerSetupState.Gamepad.Dpad -> false + is TriggerSetupState.Gamepad.SimpleButtons -> true + } + + CheckBoxText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.trigger_setup_screen_off_option), + isChecked = isUseProModeChecked, + isEnabled = isUseProModeEnabled, + onCheckedChange = onUseProModeCheckedChange, + ) + + HeaderText(text = stringResource(R.string.trigger_setup_requirements_title)) + + AccessibilityServiceRequirementRow( + isServiceEnabled = state.isAccessibilityServiceEnabled, + onClick = onEnableAccessibilityServiceClick, + ) + + when (state) { + is TriggerSetupState.Gamepad.Dpad -> { + InputMethodRequirementRow( + isEnabled = state.isImeEnabled, + isChosen = state.isImeChosen, + onEnableClick = onEnableInputMethodClick, + onChooseClick = onChooseInputMethodClick, + enablingRequiresUserInput = state.enablingRequiresUserInput, + ) + } + + is TriggerSetupState.Gamepad.SimpleButtons -> { + ProModeRequirementRow( + modifier = Modifier.fillMaxWidth(), + isVisible = state.isUseProModeChecked, + proModeStatus = state.proModeStatus, + onClick = onEnableProModeClick, + ) + } + } + + if (state is TriggerSetupState.Gamepad.Dpad) { + HeaderText(text = stringResource(R.string.trigger_setup_information_title)) + + Text( + stringResource(R.string.trigger_setup_gamepad_information_dpad), + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun MouseTriggerSetupBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState, + state: TriggerSetupState.Mouse, + onDismissRequest: () -> Unit = {}, + onEnableAccessibilityServiceClick: () -> Unit = {}, + onEnableProModeClick: () -> Unit = {}, + onRecordTriggerClick: () -> Unit = {}, +) { + TriggerSetupBottomSheet( + modifier = modifier, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.trigger_setup_mouse_title), + icon = Icons.Outlined.Mouse, + positiveButtonContent = { + if (state.areRequirementsMet) { + RecordTriggerButton( + modifier = Modifier.weight(1f), + state = state.recordTriggerState, + onClick = onRecordTriggerClick, + ) + } else { + TriggerRequirementsNotMetButton(modifier = Modifier.weight(1f)) + } + }, + ) { + RemapStatusButton(modifier = Modifier.fillMaxWidth(), remapStatus = state.remapStatus) + + HeaderText(text = stringResource(R.string.trigger_setup_options_title)) + + CheckBoxText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.trigger_setup_screen_off_option), + isChecked = true, + isEnabled = false, + onCheckedChange = {}, + ) + + HeaderText(text = stringResource(R.string.trigger_setup_requirements_title)) + + AccessibilityServiceRequirementRow( + isServiceEnabled = state.isAccessibilityServiceEnabled, + onClick = onEnableAccessibilityServiceClick, + ) + + ProModeRequirementRow( + isVisible = true, + proModeStatus = state.proModeStatus, + onClick = onEnableProModeClick, + ) + } +} + +@Composable +private fun PowerTriggerSetupBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState, + state: TriggerSetupState.Power, + onDismissRequest: () -> Unit = {}, + onEnableAccessibilityServiceClick: () -> Unit = {}, + onEnableProModeClick: () -> Unit = {}, + onRecordTriggerClick: () -> Unit = {}, +) { + TriggerSetupBottomSheet( + modifier = modifier, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.trigger_setup_power_title), + icon = KeyMapperIcons.ModeOffOn, + positiveButtonContent = { + if (state.areRequirementsMet) { + RecordTriggerButton( + modifier = Modifier.weight(1f), + state = state.recordTriggerState, + onClick = onRecordTriggerClick, + ) + } else { + TriggerRequirementsNotMetButton(modifier = Modifier.weight(1f)) + } + }, + ) { + RemapStatusButton(modifier = Modifier.fillMaxWidth(), remapStatus = state.remapStatus) + + HeaderText(text = stringResource(R.string.trigger_setup_options_title)) + + CheckBoxText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.trigger_setup_screen_off_option), + isChecked = true, + isEnabled = false, + onCheckedChange = {}, + ) + + HeaderText(text = stringResource(R.string.trigger_setup_requirements_title)) + + AccessibilityServiceRequirementRow( + isServiceEnabled = state.isAccessibilityServiceEnabled, + onClick = onEnableAccessibilityServiceClick, + ) + + ProModeRequirementRow( + isVisible = true, + proModeStatus = state.proModeStatus, + onClick = onEnableProModeClick, + ) + + HeaderText(text = stringResource(R.string.trigger_setup_information_title)) + + Text( + stringResource(R.string.trigger_setup_power_information), + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun RemapStatusButton(modifier: Modifier = Modifier, remapStatus: RemapStatus) { + when (remapStatus) { + RemapStatus.UNSUPPORTED -> RemapStatusRow( + modifier = modifier, + color = MaterialTheme.colorScheme.error, + text = stringResource(R.string.trigger_setup_status_can_not_remap), + ) + + RemapStatus.UNCERTAIN -> RemapStatusRow( + modifier = modifier, + color = LocalCustomColorsPalette.current.amber, + text = stringResource(R.string.trigger_setup_status_might_remap_button), + ) + + RemapStatus.SUPPORTED -> RemapStatusRow( + modifier = modifier, + color = LocalCustomColorsPalette.current.green, + text = stringResource(R.string.trigger_setup_status_remap_button_possible), + ) + } +} + +@Composable +private fun VolumeTriggerSetupBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState, + state: TriggerSetupState.Volume, + onDismissRequest: () -> Unit = {}, + onEnableAccessibilityServiceClick: () -> Unit = {}, + onEnableProModeClick: () -> Unit = {}, + onRecordTriggerClick: () -> Unit = {}, + onUseProModeCheckedChange: (Boolean) -> Unit = {}, +) { + TriggerSetupBottomSheet( + modifier = modifier, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.trigger_setup_volume_title), + icon = Icons.AutoMirrored.Outlined.VolumeUp, + + positiveButtonContent = { + if (state.areRequirementsMet) { + RecordTriggerButton( + modifier = Modifier.weight(1f), + state = state.recordTriggerState, + onClick = onRecordTriggerClick, + ) + } else { + TriggerRequirementsNotMetButton(modifier = Modifier.weight(1f)) + } + }, + ) { + RemapStatusRow( + modifier = Modifier.fillMaxWidth(), + color = LocalCustomColorsPalette.current.green, + text = stringResource(R.string.trigger_setup_status_remap_button_possible), + ) + + HeaderText(text = stringResource(R.string.trigger_setup_options_title)) + + CheckBoxText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.trigger_setup_screen_off_option), + isChecked = state.isUseProModeChecked, + isEnabled = !state.forceProMode, + onCheckedChange = onUseProModeCheckedChange, + ) + + HeaderText(text = stringResource(R.string.trigger_setup_requirements_title)) + + AccessibilityServiceRequirementRow( + isServiceEnabled = state.isAccessibilityServiceEnabled, + onClick = onEnableAccessibilityServiceClick, + ) + + ProModeRequirementRow( + isVisible = state.isUseProModeChecked, + proModeStatus = state.proModeStatus, + onClick = onEnableProModeClick, + ) + } +} + +@Composable +private fun NotDetectedSetupBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState, + state: TriggerSetupState.NotDetected, + onDismissRequest: () -> Unit = {}, + onEnableAccessibilityServiceClick: () -> Unit = {}, + onEnableProModeClick: () -> Unit = {}, + onRecordTriggerClick: () -> Unit = {}, +) { + TriggerSetupBottomSheet( + modifier = modifier, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.trigger_setup_no_trigger_detected_title), + icon = KeyMapperIcons.IndeterminateQuestionBox, + + positiveButtonContent = { + if (state.areRequirementsMet) { + RecordTriggerButton( + modifier = Modifier.weight(1f), + state = state.recordTriggerState, + onClick = onRecordTriggerClick, + ) + } else { + TriggerRequirementsNotMetButton(modifier = Modifier.weight(1f)) + } + }, + ) { + RemapStatusRow( + modifier = Modifier.fillMaxWidth(), + color = LocalCustomColorsPalette.current.amber, + text = stringResource(R.string.trigger_setup_status_might_remap_button), + ) + + HeaderText(text = stringResource(R.string.trigger_setup_options_title)) + + // Must always be checked because PRO mode is always used to increase the chances + // of detecting the button + CheckBoxText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.trigger_setup_screen_off_option), + isChecked = true, + isEnabled = false, + onCheckedChange = {}, + ) + + HeaderText(text = stringResource(R.string.trigger_setup_requirements_title)) + + AccessibilityServiceRequirementRow( + isServiceEnabled = state.isAccessibilityServiceEnabled, + onClick = onEnableAccessibilityServiceClick, + ) + + ProModeRequirementRow( + isVisible = true, + proModeStatus = state.proModeStatus, + onClick = onEnableProModeClick, + ) + + HeaderText(text = stringResource(R.string.trigger_setup_information_title)) + + Text( + stringResource(R.string.trigger_setup_not_detected_information), + style = MaterialTheme.typography.bodyMedium, + ) + + val uriHandler = LocalUriHandler.current + val helpUrl = stringResource(R.string.url_discord_server_invite) + + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = { + uriHandler.openUri(helpUrl) + }, + colors = ButtonDefaults.buttonColors( + containerColor = LocalCustomColorsPalette.current.discord, + contentColor = LocalCustomColorsPalette.current.onDiscord, + ), + ) { + Text(stringResource(R.string.trigger_setup_get_help_button)) + } + } +} + +@Composable +private fun OtherTriggerSetupBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState, + state: TriggerSetupState.Other, + onDismissRequest: () -> Unit = {}, + onEnableAccessibilityServiceClick: () -> Unit = {}, + onEnableProModeClick: () -> Unit = {}, + onRecordTriggerClick: () -> Unit = {}, + onUseProModeCheckedChange: (Boolean) -> Unit = {}, +) { + TriggerSetupBottomSheet( + modifier = modifier, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.trigger_setup_other_title), + icon = KeyMapperIcons.IndeterminateQuestionBox, + + positiveButtonContent = { + if (state.areRequirementsMet) { + RecordTriggerButton( + modifier = Modifier.weight(1f), + state = state.recordTriggerState, + onClick = onRecordTriggerClick, + ) + } else { + TriggerRequirementsNotMetButton(modifier = Modifier.weight(1f)) + } + }, + ) { + RemapStatusRow( + modifier = Modifier.fillMaxWidth(), + color = LocalCustomColorsPalette.current.amber, + text = stringResource(R.string.trigger_setup_status_might_remap_button), + ) + + HeaderText(text = stringResource(R.string.trigger_setup_options_title)) + + CheckBoxText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.trigger_setup_screen_off_option), + isChecked = state.isUseProModeChecked, + isEnabled = !state.forceProMode, + onCheckedChange = onUseProModeCheckedChange, + ) + + HeaderText(text = stringResource(R.string.trigger_setup_requirements_title)) + + AccessibilityServiceRequirementRow( + isServiceEnabled = state.isAccessibilityServiceEnabled, + onClick = onEnableAccessibilityServiceClick, + ) + + ProModeRequirementRow( + isVisible = state.isUseProModeChecked, + proModeStatus = state.proModeStatus, + onClick = onEnableProModeClick, + ) + + HeaderText(text = stringResource(R.string.trigger_setup_information_title)) + + Text( + stringResource(R.string.trigger_setup_get_help_information), + style = MaterialTheme.typography.bodyMedium, + ) + + val uriHandler = LocalUriHandler.current + val helpUrl = stringResource(R.string.url_discord_server_invite) + + Button( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = { + uriHandler.openUri(helpUrl) + }, + colors = ButtonDefaults.buttonColors( + containerColor = LocalCustomColorsPalette.current.discord, + contentColor = LocalCustomColorsPalette.current.onDiscord, + ), + ) { + Text(stringResource(R.string.trigger_setup_get_help_button)) + } + } +} + +@Composable +private fun KeyboardTriggerSetupBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState, + state: TriggerSetupState.Keyboard, + onDismissRequest: () -> Unit = {}, + onEnableAccessibilityServiceClick: () -> Unit = {}, + onEnableProModeClick: () -> Unit = {}, + onRecordTriggerClick: () -> Unit = {}, + onUseProModeCheckedChange: (Boolean) -> Unit = {}, +) { + TriggerSetupBottomSheet( + modifier = modifier, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.trigger_setup_keyboard_title), + icon = Icons.Rounded.Keyboard, + + positiveButtonContent = { + if (state.areRequirementsMet) { + RecordTriggerButton( + modifier = Modifier.weight(1f), + state = state.recordTriggerState, + onClick = onRecordTriggerClick, + ) + } else { + TriggerRequirementsNotMetButton(modifier = Modifier.weight(1f)) + } + }, + ) { + RemapStatusRow( + modifier = Modifier.fillMaxWidth(), + color = LocalCustomColorsPalette.current.green, + text = stringResource(R.string.trigger_setup_status_remap_device_possible), + ) + + HeaderText(text = stringResource(R.string.trigger_setup_options_title)) + + CheckBoxText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.trigger_setup_screen_off_option), + isChecked = state.isUseProModeChecked, + isEnabled = !state.forceProMode, + onCheckedChange = onUseProModeCheckedChange, + ) + + HeaderText(text = stringResource(R.string.trigger_setup_requirements_title)) + + AccessibilityServiceRequirementRow( + isServiceEnabled = state.isAccessibilityServiceEnabled, + onClick = onEnableAccessibilityServiceClick, + ) + + ProModeRequirementRow( + isVisible = state.isUseProModeChecked, + proModeStatus = state.proModeStatus, + onClick = onEnableProModeClick, + ) + } +} + +@Composable +private fun FingerprintGestureSetupBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState, + state: TriggerSetupState.FingerprintGesture, + onDismissRequest: () -> Unit = {}, + onEnableAccessibilityServiceClick: () -> Unit = {}, + onGestureTypeSelected: (FingerprintGestureType) -> Unit = {}, + onAddTriggerClick: () -> Unit = {}, +) { + TriggerSetupBottomSheet( + modifier = modifier, + sheetState = sheetState, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.trigger_setup_fingerprint_reader_title), + icon = Icons.Rounded.Fingerprint, + + positiveButtonContent = { + if (state.areRequirementsMet) { + AddTriggerButton(modifier = Modifier.weight(1f), onClick = onAddTriggerClick) + } else { + TriggerRequirementsNotMetButton(modifier = Modifier.weight(1f)) + } + }, + ) { + RemapStatusRow( + modifier = Modifier.fillMaxWidth(), + color = LocalCustomColorsPalette.current.amber, + text = stringResource(R.string.trigger_setup_status_might_remap_button), + ) + + HeaderText(text = stringResource(R.string.trigger_setup_requirements_title)) + + AccessibilityServiceRequirementRow( + isServiceEnabled = state.isAccessibilityServiceEnabled, + onClick = onEnableAccessibilityServiceClick, + ) + + HeaderText(text = stringResource(R.string.trigger_key_fingerprint_gesture_type_header)) + + RadioButtonText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.fingerprint_gesture_down), + isSelected = state.selectedType == FingerprintGestureType.SWIPE_DOWN, + onSelected = { onGestureTypeSelected(FingerprintGestureType.SWIPE_DOWN) }, + ) + + RadioButtonText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.fingerprint_gesture_up), + isSelected = state.selectedType == FingerprintGestureType.SWIPE_UP, + onSelected = { onGestureTypeSelected(FingerprintGestureType.SWIPE_UP) }, + ) + + RadioButtonText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.fingerprint_gesture_left), + isSelected = state.selectedType == FingerprintGestureType.SWIPE_LEFT, + onSelected = { onGestureTypeSelected(FingerprintGestureType.SWIPE_LEFT) }, + ) + + RadioButtonText( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.fingerprint_gesture_right), + isSelected = state.selectedType == FingerprintGestureType.SWIPE_RIGHT, + onSelected = { onGestureTypeSelected(FingerprintGestureType.SWIPE_RIGHT) }, + ) + } +} + +@Composable +fun RemapStatusRow(modifier: Modifier = Modifier, color: Color, text: String) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier + .size(16.dp) + .background( + color = color, + shape = CircleShape, + ), + ) + + Spacer(Modifier.width(16.dp)) + + Text(text = text, style = MaterialTheme.typography.titleMedium) + } +} + +@Composable +fun TriggerRequirementsNotMetButton(modifier: Modifier = Modifier) { + FilledTonalButton(modifier = modifier, onClick = {}, enabled = false) { + Text(stringResource(R.string.trigger_setup_requirements_not_met_button)) + } +} + +@Composable +fun AddTriggerButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + Button(modifier = modifier, onClick = onClick) { + Text(stringResource(R.string.trigger_setup_add_trigger_button)) + } +} + +@Composable +fun TriggerSetupBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState, + onDismissRequest: () -> Unit, + title: String, + icon: ImageVector, + positiveButtonContent: @Composable RowScope.() -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + val scope = rememberCoroutineScope() + + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismissRequest, + sheetState = sheetState, + // Hide drag handle because other bottom sheets don't have it + dragHandle = {}, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Surface( + modifier = Modifier.size(48.dp), + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + modifier = Modifier.size(32.dp), + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + Column( + modifier = Modifier + .animateContentSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + content() + } + + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + positiveButtonContent() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PowerButtonPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + PowerTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Power( + isAccessibilityServiceEnabled = true, + proModeStatus = ProModeStatus.ENABLED, + areRequirementsMet = true, + recordTriggerState = RecordTriggerState.Idle, + remapStatus = RemapStatus.SUPPORTED, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PowerButtonDisabledPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + PowerTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Power( + isAccessibilityServiceEnabled = false, + proModeStatus = ProModeStatus.UNSUPPORTED, + areRequirementsMet = false, + recordTriggerState = RecordTriggerState.Idle, + remapStatus = RemapStatus.UNSUPPORTED, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun VolumeButtonPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + VolumeTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Volume( + isAccessibilityServiceEnabled = true, + isUseProModeChecked = true, + proModeStatus = ProModeStatus.ENABLED, + areRequirementsMet = true, + recordTriggerState = RecordTriggerState.Idle, + forceProMode = false, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun VolumeButtonDisabledPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + VolumeTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Volume( + isAccessibilityServiceEnabled = false, + isUseProModeChecked = true, + proModeStatus = ProModeStatus.DISABLED, + areRequirementsMet = false, + recordTriggerState = RecordTriggerState.Idle, + forceProMode = false, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun FingerprintGestureRequirementsMetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + FingerprintGestureSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.FingerprintGesture( + isAccessibilityServiceEnabled = true, + areRequirementsMet = true, + selectedType = FingerprintGestureType.SWIPE_DOWN, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun FingerprintGestureRequirementsNotMetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + FingerprintGestureSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.FingerprintGesture( + isAccessibilityServiceEnabled = false, + areRequirementsMet = false, + selectedType = FingerprintGestureType.SWIPE_UP, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun KeyboardButtonEnabledPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + KeyboardTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Keyboard( + isAccessibilityServiceEnabled = true, + isUseProModeChecked = false, + proModeStatus = ProModeStatus.DISABLED, + areRequirementsMet = true, + recordTriggerState = RecordTriggerState.Idle, + forceProMode = false, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun KeyboardButtonDisabledPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + KeyboardTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Keyboard( + isAccessibilityServiceEnabled = false, + isUseProModeChecked = true, + proModeStatus = ProModeStatus.DISABLED, + areRequirementsMet = false, + recordTriggerState = RecordTriggerState.Idle, + forceProMode = false, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun MouseButtonPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + MouseTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Mouse( + isAccessibilityServiceEnabled = true, + proModeStatus = ProModeStatus.ENABLED, + areRequirementsMet = true, + recordTriggerState = RecordTriggerState.Idle, + remapStatus = RemapStatus.SUPPORTED, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun MouseButtonDisabledPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + MouseTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Mouse( + isAccessibilityServiceEnabled = false, + proModeStatus = ProModeStatus.UNSUPPORTED, + areRequirementsMet = false, + recordTriggerState = RecordTriggerState.Idle, + remapStatus = RemapStatus.UNSUPPORTED, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun OtherButtonPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + OtherTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Other( + isAccessibilityServiceEnabled = true, + isUseProModeChecked = true, + proModeStatus = ProModeStatus.ENABLED, + areRequirementsMet = true, + recordTriggerState = RecordTriggerState.Idle, + forceProMode = false, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun OtherButtonDisabledPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + OtherTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Other( + isAccessibilityServiceEnabled = false, + isUseProModeChecked = true, + proModeStatus = ProModeStatus.DISABLED, + areRequirementsMet = false, + recordTriggerState = RecordTriggerState.Idle, + forceProMode = false, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun GamepadDpadPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + GamepadTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Gamepad.Dpad( + isAccessibilityServiceEnabled = true, + isImeEnabled = true, + isImeChosen = true, + areRequirementsMet = true, + recordTriggerState = RecordTriggerState.Idle, + enablingRequiresUserInput = true, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun GamepadDpadDisabledPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + GamepadTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Gamepad.Dpad( + isAccessibilityServiceEnabled = false, + isImeEnabled = false, + isImeChosen = false, + areRequirementsMet = false, + recordTriggerState = RecordTriggerState.Idle, + enablingRequiresUserInput = true, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun GamepadSimpleButtonsPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + GamepadTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Gamepad.SimpleButtons( + isAccessibilityServiceEnabled = true, + isUseProModeChecked = true, + proModeStatus = ProModeStatus.ENABLED, + areRequirementsMet = true, + recordTriggerState = RecordTriggerState.Idle, + forceProMode = false, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun GamepadSimpleButtonsDisabledPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + GamepadTriggerSetupBottomSheet( + sheetState = sheetState, + state = TriggerSetupState.Gamepad.SimpleButtons( + isAccessibilityServiceEnabled = false, + isUseProModeChecked = false, + proModeStatus = ProModeStatus.DISABLED, + areRequirementsMet = false, + recordTriggerState = RecordTriggerState.Idle, + forceProMode = false, + ), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupDelegate.kt new file mode 100644 index 0000000000..f18b6d4fcf --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupDelegate.kt @@ -0,0 +1,457 @@ +package io.github.sds100.keymapper.base.trigger + +import android.os.Build +import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegate +import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.base.utils.ProModeStatus +import io.github.sds100.keymapper.base.utils.navigation.NavDestination +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate +import io.github.sds100.keymapper.base.utils.ui.DialogProvider +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.utils.AccessibilityServiceError +import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.onFailure +import io.github.sds100.keymapper.common.utils.onSuccess +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState +import javax.inject.Inject +import javax.inject.Named +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import timber.log.Timber + +@OptIn(ExperimentalCoroutinesApi::class) +@ViewModelScoped +class TriggerSetupDelegateImpl @Inject constructor( + @Named("viewmodel") + val viewModelScope: CoroutineScope, + val setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, + val recordTriggerController: RecordTriggerController, + val systemBridgeConnectionManager: SystemBridgeConnectionManager, + val configTriggerUseCase: ConfigTriggerUseCase, + val setupInputMethodUseCase: SetupInputMethodUseCase, + resourceProvider: ResourceProvider, + dialogProvider: DialogProvider, + navigationProvider: NavigationProvider, +) : TriggerSetupDelegate, + ResourceProvider by resourceProvider, + DialogProvider by dialogProvider, + NavigationProvider by navigationProvider, + SetupAccessibilityServiceDelegate by setupAccessibilityServiceDelegate { + + private val currentSetupShortcut: MutableStateFlow = + MutableStateFlow(null) + + private val isScreenOffChecked: MutableStateFlow = MutableStateFlow(false) + private val isProModeLocked: MutableStateFlow = MutableStateFlow(false) + private val forceProMode: MutableStateFlow = MutableStateFlow(false) + + private val selectedFingerprintGestureType: MutableStateFlow = + MutableStateFlow(FingerprintGestureType.SWIPE_DOWN) + + private val selectedGamePadType: MutableStateFlow = + MutableStateFlow(TriggerSetupState.Gamepad.Type.DPAD) + + private val proModeStatus: Flow = + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + systemBridgeConnectionManager.connectionState.map { state -> + when (state) { + is SystemBridgeConnectionState.Connected -> ProModeStatus.ENABLED + is SystemBridgeConnectionState.Disconnected -> ProModeStatus.DISABLED + } + } + } else { + flowOf(ProModeStatus.UNSUPPORTED) + } + + override val triggerSetupState: StateFlow = + currentSetupShortcut.flatMapLatest { shortcut -> + if (shortcut == null) { + flowOf(null) + } else { + when (shortcut) { + TriggerSetupShortcut.VOLUME -> buildSetupVolumeTriggerFlow() + TriggerSetupShortcut.POWER -> buildSetupPowerTriggerFlow() + TriggerSetupShortcut.FINGERPRINT_GESTURE -> buildSetupFingerprintGestureFlow() + TriggerSetupShortcut.KEYBOARD -> buildSetupKeyboardTriggerFlow() + TriggerSetupShortcut.MOUSE -> buildSetupMouseTriggerFlow() + TriggerSetupShortcut.GAMEPAD -> buildSetupGamepadTriggerFlow() + TriggerSetupShortcut.OTHER -> buildSetupOtherTriggerFlow() + TriggerSetupShortcut.NOT_DETECTED -> buildSetupNotDetectedFlow() + + else -> throw UnsupportedOperationException("Unhandled shortcut: $shortcut") + } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, null) + + override fun showTriggerSetup(shortcut: TriggerSetupShortcut, forceProMode: Boolean) { + isProModeLocked.value = true + this.forceProMode.value = forceProMode + // If force pro mode is enabled, automatically check the screen off option + if (forceProMode) { + isScreenOffChecked.value = true + } + currentSetupShortcut.value = shortcut + } + + private fun buildSetupVolumeTriggerFlow(): Flow { + return combine( + accessibilityServiceState, + isScreenOffChecked, + recordTriggerController.state, + proModeStatus, + forceProMode, + ) { serviceState, isScreenOffChecked, recordTriggerState, proModeStatus, forceProMode -> + val areRequirementsMet = if (isScreenOffChecked) { + serviceState == AccessibilityServiceState.ENABLED && + proModeStatus == ProModeStatus.ENABLED + } else { + serviceState == AccessibilityServiceState.ENABLED + } + + TriggerSetupState.Volume( + isAccessibilityServiceEnabled = serviceState == AccessibilityServiceState.ENABLED, + isUseProModeChecked = isScreenOffChecked, + proModeStatus = proModeStatus, + areRequirementsMet = areRequirementsMet, + recordTriggerState = recordTriggerState, + forceProMode = forceProMode, + ) + } + } + + private fun buildSetupGamepadTriggerFlow(): Flow { + return selectedGamePadType.flatMapLatest { selectedGamepadType -> + when (selectedGamepadType) { + TriggerSetupState.Gamepad.Type.DPAD -> { + combine( + accessibilityServiceState, + setupInputMethodUseCase.isEnabled, + setupInputMethodUseCase.isChosen, + recordTriggerController.state, + ) { serviceState, isImeEnabled, isImeChosen, recordTriggerState -> + val areRequirementsMet = + serviceState == AccessibilityServiceState.ENABLED && + isImeEnabled && + isImeChosen + + TriggerSetupState.Gamepad.Dpad( + isAccessibilityServiceEnabled = + serviceState == AccessibilityServiceState.ENABLED, + isImeEnabled = isImeEnabled, + isImeChosen = isImeChosen, + areRequirementsMet = areRequirementsMet, + recordTriggerState = recordTriggerState, + enablingRequiresUserInput = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU, + ) + } + } + + TriggerSetupState.Gamepad.Type.SIMPLE_BUTTONS -> { + combine( + accessibilityServiceState, + isScreenOffChecked, + recordTriggerController.state, + proModeStatus, + forceProMode, + ) { + serviceState, + isScreenOffChecked, + recordTriggerState, + proModeStatus, + forceProMode, + -> + val areRequirementsMet = if (isScreenOffChecked) { + serviceState == AccessibilityServiceState.ENABLED && + proModeStatus == ProModeStatus.ENABLED + } else { + serviceState == AccessibilityServiceState.ENABLED + } + + TriggerSetupState.Gamepad.SimpleButtons( + isAccessibilityServiceEnabled = + serviceState == AccessibilityServiceState.ENABLED, + isUseProModeChecked = isScreenOffChecked, + proModeStatus = proModeStatus, + areRequirementsMet = areRequirementsMet, + recordTriggerState = recordTriggerState, + forceProMode = forceProMode, + ) + } + } + } + } + } + + private fun buildSetupOtherTriggerFlow(): Flow { + return combine( + accessibilityServiceState, + isScreenOffChecked, + recordTriggerController.state, + proModeStatus, + forceProMode, + ) { serviceState, isScreenOffChecked, recordTriggerState, proModeStatus, forceProMode -> + val areRequirementsMet = if (isScreenOffChecked) { + serviceState == AccessibilityServiceState.ENABLED && + proModeStatus == ProModeStatus.ENABLED + } else { + serviceState == AccessibilityServiceState.ENABLED + } + + TriggerSetupState.Other( + isAccessibilityServiceEnabled = serviceState == AccessibilityServiceState.ENABLED, + isUseProModeChecked = isScreenOffChecked, + proModeStatus = proModeStatus, + areRequirementsMet = areRequirementsMet, + recordTriggerState = recordTriggerState, + forceProMode = forceProMode, + ) + } + } + + private fun buildSetupNotDetectedFlow(): Flow { + return combine( + accessibilityServiceState, + recordTriggerController.state, + proModeStatus, + ) { serviceState, recordTriggerState, proModeStatus -> + val areRequirementsMet = + serviceState == AccessibilityServiceState.ENABLED && + proModeStatus == ProModeStatus.ENABLED + + TriggerSetupState.NotDetected( + isAccessibilityServiceEnabled = serviceState == AccessibilityServiceState.ENABLED, + proModeStatus = proModeStatus, + areRequirementsMet = areRequirementsMet, + recordTriggerState = recordTriggerState, + ) + } + } + + private fun buildSetupKeyboardTriggerFlow(): Flow { + return combine( + accessibilityServiceState, + isScreenOffChecked, + recordTriggerController.state, + proModeStatus, + forceProMode, + ) { serviceState, isScreenOffChecked, recordTriggerState, proModeStatus, forceProMode -> + val areRequirementsMet = if (isScreenOffChecked) { + serviceState == AccessibilityServiceState.ENABLED && + proModeStatus == ProModeStatus.ENABLED + } else { + serviceState == AccessibilityServiceState.ENABLED + } + + TriggerSetupState.Keyboard( + isAccessibilityServiceEnabled = serviceState == AccessibilityServiceState.ENABLED, + isUseProModeChecked = isScreenOffChecked, + proModeStatus = proModeStatus, + areRequirementsMet = areRequirementsMet, + recordTriggerState = recordTriggerState, + forceProMode = forceProMode, + ) + } + } + + private fun buildSetupFingerprintGestureFlow(): Flow { + return combine( + accessibilityServiceState, + selectedFingerprintGestureType, + ) { serviceState, gestureType -> + val areRequirementsMet = serviceState == AccessibilityServiceState.ENABLED + + TriggerSetupState.FingerprintGesture( + isAccessibilityServiceEnabled = serviceState == AccessibilityServiceState.ENABLED, + areRequirementsMet = areRequirementsMet, + selectedType = gestureType, + ) + } + } + + private fun buildSetupPowerTriggerFlow(): Flow { + return combine( + accessibilityServiceState, + recordTriggerController.state, + proModeStatus, + ) { serviceState, recordTriggerState, proModeStatus -> + val areRequirementsMet = + serviceState == AccessibilityServiceState.ENABLED && + proModeStatus == ProModeStatus.ENABLED + + val remapStatus = if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + if (areRequirementsMet) { + RemapStatus.SUPPORTED + } else { + RemapStatus.UNCERTAIN + } + } else { + RemapStatus.UNSUPPORTED + } + + TriggerSetupState.Power( + isAccessibilityServiceEnabled = serviceState == AccessibilityServiceState.ENABLED, + proModeStatus = proModeStatus, + areRequirementsMet = areRequirementsMet, + recordTriggerState = recordTriggerState, + remapStatus = remapStatus, + ) + } + } + + private fun buildSetupMouseTriggerFlow(): Flow { + return combine( + accessibilityServiceState, + recordTriggerController.state, + proModeStatus, + ) { serviceState, recordTriggerState, proModeStatus -> + val areRequirementsMet = + serviceState == AccessibilityServiceState.ENABLED && + proModeStatus == ProModeStatus.ENABLED + + val remapStatus = if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + if (areRequirementsMet) { + RemapStatus.SUPPORTED + } else { + RemapStatus.UNCERTAIN + } + } else { + RemapStatus.UNSUPPORTED + } + + TriggerSetupState.Mouse( + isAccessibilityServiceEnabled = serviceState == AccessibilityServiceState.ENABLED, + proModeStatus = proModeStatus, + areRequirementsMet = areRequirementsMet, + recordTriggerState = recordTriggerState, + remapStatus = remapStatus, + ) + } + } + + override fun onEnableAccessibilityServiceClick() { + viewModelScope.launch { + showEnableAccessibilityServiceDialog() + } + } + + override fun onEnableProModeClick() { + viewModelScope.launch { + navigate("trigger_setup_enable_pro_mode", NavDestination.ProMode) + } + } + + override fun onScreenOffTriggerSetupCheckedChange(isChecked: Boolean) { + isScreenOffChecked.value = isChecked + } + + override fun onUseProModeCheckedChange(isChecked: Boolean) { + isScreenOffChecked.value = isChecked + } + + override fun onDismissTriggerSetup() { + currentSetupShortcut.value = null + } + + override fun onTriggerSetupRecordClick() { + val setupState = triggerSetupState.value ?: return + + val enableEvdevRecording = when (setupState) { + is TriggerSetupState.Volume -> setupState.isUseProModeChecked + is TriggerSetupState.Keyboard -> setupState.isUseProModeChecked + is TriggerSetupState.Power -> true + is TriggerSetupState.FingerprintGesture -> false + is TriggerSetupState.Mouse -> true + is TriggerSetupState.Other -> setupState.isUseProModeChecked + is TriggerSetupState.Gamepad.Dpad -> false + is TriggerSetupState.Gamepad.SimpleButtons -> setupState.isUseProModeChecked + // Always enable pro mode recording to increase the chances of detecting + // the key + is TriggerSetupState.NotDetected -> true + } + + viewModelScope.launch { + val recordTriggerState = recordTriggerController.state.firstOrNull() ?: return@launch + + val result: KMResult<*> = when (recordTriggerState) { + is RecordTriggerState.CountingDown -> { + recordTriggerController.stopRecording() + } + + is RecordTriggerState.Completed, + RecordTriggerState.Idle, + -> recordTriggerController.startRecording( + enableEvdevRecording, + ) + } + + result.onSuccess { + currentSetupShortcut.value = null + } + + // Show dialog if the accessibility service is disabled or crashed + if (result is AccessibilityServiceError) { + showFixAccessibilityServiceDialog(result) + } + } + } + + override fun onFingerprintGestureTypeSelected(type: FingerprintGestureType) { + selectedFingerprintGestureType.value = type + } + + override fun onAddFingerprintGestureClick() { + configTriggerUseCase.addFingerprintGesture(selectedFingerprintGestureType.value) + currentSetupShortcut.value = null + } + + override fun onGamepadButtonTypeSelected(type: TriggerSetupState.Gamepad.Type) { + selectedGamePadType.value = type + } + + override fun onEnableImeClick() { + viewModelScope.launch { + setupInputMethodUseCase.enableInputMethod() + } + } + + override fun onChooseImeClick() { + viewModelScope.launch { + setupInputMethodUseCase.chooseInputMethod().onFailure { + Timber.e("Failed to choose input method when setting up trigger. Error: $it") + } + } + } +} + +interface TriggerSetupDelegate { + val triggerSetupState: StateFlow + fun showTriggerSetup(shortcut: TriggerSetupShortcut, forceProMode: Boolean = false) + fun onDismissTriggerSetup() + fun onEnableAccessibilityServiceClick() + fun onEnableProModeClick() + fun onScreenOffTriggerSetupCheckedChange(isChecked: Boolean) + fun onUseProModeCheckedChange(isChecked: Boolean) + fun onTriggerSetupRecordClick() + fun onFingerprintGestureTypeSelected(type: FingerprintGestureType) + fun onAddFingerprintGestureClick() + fun onGamepadButtonTypeSelected(type: TriggerSetupState.Gamepad.Type) + fun onEnableImeClick() + fun onChooseImeClick() +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupShortcut.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupShortcut.kt new file mode 100644 index 0000000000..6d5d7316dc --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupShortcut.kt @@ -0,0 +1,18 @@ +package io.github.sds100.keymapper.base.trigger + +import androidx.annotation.Keep + +@Keep +enum class TriggerSetupShortcut { + VOLUME, + ASSISTANT, + POWER, + FINGERPRINT_GESTURE, + KEYBOARD, + MOUSE, + GAMEPAD, + OTHER, + NOT_DETECTED, + FLOATING_BUTTON_CUSTOM, + FLOATING_BUTTON_LOCK_SCREEN, +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupState.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupState.kt new file mode 100644 index 0000000000..e94c8749de --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerSetupState.kt @@ -0,0 +1,91 @@ +package io.github.sds100.keymapper.base.trigger + +import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.base.utils.ProModeStatus + +sealed class TriggerSetupState { + data class Volume( + val isAccessibilityServiceEnabled: Boolean, + val isUseProModeChecked: Boolean, + val proModeStatus: ProModeStatus, + val areRequirementsMet: Boolean, + val recordTriggerState: RecordTriggerState, + val forceProMode: Boolean = false, + ) : TriggerSetupState() + + data class Keyboard( + val isAccessibilityServiceEnabled: Boolean, + val isUseProModeChecked: Boolean, + val proModeStatus: ProModeStatus, + val areRequirementsMet: Boolean, + val recordTriggerState: RecordTriggerState, + val forceProMode: Boolean = false, + ) : TriggerSetupState() + + data class Power( + val isAccessibilityServiceEnabled: Boolean, + val proModeStatus: ProModeStatus, + val areRequirementsMet: Boolean, + val recordTriggerState: RecordTriggerState, + val remapStatus: RemapStatus, + ) : TriggerSetupState() + + data class Mouse( + val isAccessibilityServiceEnabled: Boolean, + val proModeStatus: ProModeStatus, + val areRequirementsMet: Boolean, + val recordTriggerState: RecordTriggerState, + val remapStatus: RemapStatus, + ) : TriggerSetupState() + + data class Other( + val isAccessibilityServiceEnabled: Boolean, + val isUseProModeChecked: Boolean, + val proModeStatus: ProModeStatus, + val areRequirementsMet: Boolean, + val recordTriggerState: RecordTriggerState, + val forceProMode: Boolean = false, + ) : TriggerSetupState() + + data class NotDetected( + val isAccessibilityServiceEnabled: Boolean, + val proModeStatus: ProModeStatus, + val areRequirementsMet: Boolean, + val recordTriggerState: RecordTriggerState, + ) : TriggerSetupState() + + sealed class Gamepad : TriggerSetupState() { + abstract val isAccessibilityServiceEnabled: Boolean + abstract val areRequirementsMet: Boolean + abstract val recordTriggerState: RecordTriggerState + + enum class Type { + DPAD, + SIMPLE_BUTTONS, + } + + data class Dpad( + override val isAccessibilityServiceEnabled: Boolean, + val isImeEnabled: Boolean, + val enablingRequiresUserInput: Boolean, + val isImeChosen: Boolean, + override val areRequirementsMet: Boolean, + override val recordTriggerState: RecordTriggerState, + ) : Gamepad() + + data class SimpleButtons( + override val isAccessibilityServiceEnabled: Boolean, + val isUseProModeChecked: Boolean, + val proModeStatus: ProModeStatus, + override val areRequirementsMet: Boolean, + override val recordTriggerState: RecordTriggerState, + val forceProMode: Boolean = false, + ) : Gamepad() + } + + data class FingerprintGesture( + val isAccessibilityServiceEnabled: Boolean, + val areRequirementsMet: Boolean, + val selectedType: FingerprintGestureType, + ) : TriggerSetupState() +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerValidator.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerValidator.kt new file mode 100644 index 0000000000..26bdd87986 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerValidator.kt @@ -0,0 +1,100 @@ +package io.github.sds100.keymapper.base.trigger + +import io.github.sds100.keymapper.base.keymaps.ClickType + +/** + * Checks that the trigger is still valid. If it is a parallel trigger it removes + * conflicting keys that can't both be pressed down at the same time. And if it is a single + * key trigger then it will check it has one key. If not then it will convert it into a sequence + * trigger and not a parallel trigger so no keys are removed. + */ +fun Trigger.validate(): Trigger { + when (this.mode) { + is TriggerMode.Parallel -> { + return validateParallelTrigger(this) + } + + TriggerMode.Undefined -> { + return validateSingleKeyTrigger(this) + } + + TriggerMode.Sequence -> { + // No validation needed for sequence triggers. Any keys can be pressed in any order + if (keys.size <= 1) { + return copy(mode = TriggerMode.Undefined) + } + + return this + } + } +} + +private fun validateSingleKeyTrigger(trigger: Trigger): Trigger { + if (trigger.keys.size > 1) { + // If there are multiple keys then we convert it to a sequence trigger + return Trigger(mode = TriggerMode.Sequence, keys = trigger.keys.take(1)) + } else { + return trigger + } +} + +private fun validateParallelTrigger(trigger: Trigger): Trigger { + if (trigger.keys.size <= 1) { + return trigger.copy(mode = TriggerMode.Undefined) + } + + var newMode = trigger.mode + + outerLoop@ for (key in trigger.keys) { + // If there are conflicting keys then set the mode to sequence trigger + for (otherKey in trigger.keys) { + if (key != otherKey && key.isLogicallyEqual(otherKey)) { + newMode = TriggerMode.Sequence + break@outerLoop + } + } + + // Set the trigger mode to a short press if any keys are not compatible with the selected + // trigger mode + if (newMode is TriggerMode.Parallel) { + if ( + (newMode.clickType == ClickType.LONG_PRESS && !key.allowedLongPress) || + (newMode.clickType == ClickType.DOUBLE_PRESS && !key.allowedDoublePress) + ) { + newMode = TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS) + } + } + } + + var newKeys = trigger.keys + + // If the trigger is still a parallel trigger then check that all the keys can be + // pressed at the same time. + if (newMode is TriggerMode.Parallel) { + newKeys = trigger.keys.distinctBy { key -> + when (key) { + // You can't mix assistant trigger types in a parallel trigger because there is no notion of a "down" key event, which means they can't be pressed at the same time + is AssistantTriggerKey, is FingerprintTriggerKey -> 0 + is FloatingButtonKey -> key.buttonUid + + is KeyEventTriggerKey -> { + if (key.detectWithScancode()) { + Pair(key.scanCode, key.device) + } else { + Pair(key.keyCode, key.device) + } + } + + is EvdevTriggerKey -> { + if (key.detectWithScancode()) { + Pair(key.scanCode, key.device) + } else { + Pair(key.keyCode, key.device) + } + } + } + }.toMutableList() + } + + return trigger.copy(mode = newMode, keys = newKeys) +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt index d419568ebf..5a835bc1e2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt @@ -1,214 +1,480 @@ package io.github.sds100.keymapper.base.utils +import android.content.Context import android.content.pm.PackageManager +import android.telephony.SmsManager import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.purchasing.ProductId import io.github.sds100.keymapper.base.purchasing.PurchasingError import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.base.utils.ui.ResourceProviderImpl +import io.github.sds100.keymapper.common.utils.AccessibilityServiceError import io.github.sds100.keymapper.common.utils.BuildUtils import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.data.DataError +import io.github.sds100.keymapper.sysbridge.utils.SystemBridgeError import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.permissions.Permission +fun KMError.getFullMessage(ctx: Context): String { + return getFullMessage(ResourceProviderImpl(ctx)) +} + fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { return when (this) { - is SystemError.PermissionDenied -> { - val resId = when (permission) { - Permission.WRITE_SETTINGS -> R.string.error_action_requires_write_settings_permission - Permission.CAMERA -> R.string.error_action_requires_camera_permission - Permission.DEVICE_ADMIN -> R.string.error_need_to_enable_device_admin - Permission.READ_PHONE_STATE -> R.string.error_action_requires_read_phone_state_permission - Permission.ACCESS_NOTIFICATION_POLICY -> R.string.error_action_notification_policy_permission - Permission.WRITE_SECURE_SETTINGS -> R.string.error_need_write_secure_settings_permission - Permission.NOTIFICATION_LISTENER -> R.string.error_denied_notification_listener_service_permission - Permission.CALL_PHONE -> R.string.error_denied_call_phone_permission - Permission.ROOT -> R.string.error_requires_root - Permission.IGNORE_BATTERY_OPTIMISATION -> R.string.error_battery_optimisation_enabled - Permission.SHIZUKU -> R.string.error_shizuku_permission_denied - Permission.ACCESS_FINE_LOCATION -> R.string.error_access_fine_location_permission_denied - Permission.ANSWER_PHONE_CALL -> R.string.error_answer_end_phone_call_permission_denied - Permission.FIND_NEARBY_DEVICES -> R.string.error_find_nearby_devices_permission_denied - Permission.POST_NOTIFICATIONS -> R.string.error_notifications_permission_denied + is SystemError.PermissionDenied -> + { + val resId = when (permission) { + Permission.WRITE_SETTINGS -> + R.string.error_action_requires_write_settings_permission + Permission.CAMERA -> + R.string.error_action_requires_camera_permission + Permission.DEVICE_ADMIN -> + R.string.error_need_to_enable_device_admin + Permission.READ_PHONE_STATE -> + R.string.error_action_requires_read_phone_state_permission + Permission.ACCESS_NOTIFICATION_POLICY -> + R.string.error_action_notification_policy_permission + Permission.WRITE_SECURE_SETTINGS -> + R.string.error_need_write_secure_settings_permission + Permission.NOTIFICATION_LISTENER -> + R.string.error_denied_notification_listener_service_permission + Permission.CALL_PHONE -> + R.string.error_denied_call_phone_permission + Permission.SEND_SMS -> + R.string.error_denied_send_sms_permission + Permission.ROOT -> + R.string.error_requires_root + Permission.IGNORE_BATTERY_OPTIMISATION -> + R.string.error_battery_optimisation_enabled + Permission.SHIZUKU -> + R.string.error_shizuku_permission_denied + Permission.ACCESS_FINE_LOCATION -> + R.string.error_access_fine_location_permission_denied + Permission.ANSWER_PHONE_CALL -> + R.string.error_answer_end_phone_call_permission_denied + Permission.FIND_NEARBY_DEVICES -> + R.string.error_find_nearby_devices_permission_denied + Permission.POST_NOTIFICATIONS -> + R.string.error_notifications_permission_denied + } + + resourceProvider.getString(resId) } - resourceProvider.getString(resId) - } - - is KMError.AppNotFound -> resourceProvider.getString( - R.string.error_app_isnt_installed, - packageName, - ) - - is KMError.AppDisabled -> resourceProvider.getString( - R.string.error_app_is_disabled_package_name, - this.packageName, - ) - - is KMError.NoCompatibleImeEnabled -> resourceProvider.getString(R.string.error_key_mapper_ime_service_disabled) - is KMError.NoCompatibleImeChosen -> resourceProvider.getString(R.string.error_ime_must_be_chosen) - is KMError.SystemFeatureNotSupported -> when (this.feature) { - PackageManager.FEATURE_NFC -> resourceProvider.getString(R.string.error_system_feature_nfc_unsupported) - PackageManager.FEATURE_CAMERA -> resourceProvider.getString(R.string.error_system_feature_camera_unsupported) - PackageManager.FEATURE_FINGERPRINT -> resourceProvider.getString(R.string.error_system_feature_fingerprint_unsupported) - PackageManager.FEATURE_WIFI -> resourceProvider.getString(R.string.error_system_feature_wifi_unsupported) - PackageManager.FEATURE_BLUETOOTH -> resourceProvider.getString(R.string.error_system_feature_bluetooth_unsupported) - PackageManager.FEATURE_DEVICE_ADMIN -> resourceProvider.getString(R.string.error_system_feature_device_admin_unsupported) - PackageManager.FEATURE_CAMERA_FLASH -> resourceProvider.getString(R.string.error_system_feature_camera_flash_unsupported) - PackageManager.FEATURE_TELEPHONY -> resourceProvider.getString(R.string.error_system_feature_telephony_unsupported) - else -> throw Exception("Don't know how to get error message for this system feature ${this.feature}") - } - - is DataError.ExtraNotFound -> resourceProvider.getString( - R.string.error_extra_not_found, - extraId, - ) - - is KMError.SdkVersionTooLow -> resourceProvider.getString( - R.string.error_sdk_version_too_low, - BuildUtils.getSdkVersionName(minSdk), - ) - - is KMError.SdkVersionTooHigh -> resourceProvider.getString( - R.string.error_sdk_version_too_high, - BuildUtils.getSdkVersionName(maxSdk), - ) - - is KMError.InputMethodNotFound -> resourceProvider.getString( - R.string.error_ime_not_found, - imeLabel, - ) - - is KMError.FrontFlashNotFound -> resourceProvider.getString(R.string.error_front_flash_not_found) - is KMError.BackFlashNotFound -> resourceProvider.getString(R.string.error_back_flash_not_found) - is KMError.DeviceNotFound -> resourceProvider.getString(R.string.error_device_not_found) - is KMError.Exception -> exception.toString() - is KMError.EmptyJson -> resourceProvider.getString(R.string.error_empty_json) - is KMError.InvalidNumber -> resourceProvider.getString(R.string.error_invalid_number) - is KMError.NumberTooSmall -> resourceProvider.getString(R.string.error_number_too_small, min) - is KMError.NumberTooBig -> resourceProvider.getString(R.string.error_number_too_big, max) - is KMError.EmptyText -> resourceProvider.getString(R.string.error_cant_be_empty) - KMError.BackupVersionTooNew -> resourceProvider.getString(R.string.error_backup_version_too_new) - KMError.NoIncompatibleKeyboardsInstalled -> resourceProvider.getString(R.string.error_no_incompatible_input_methods_installed) - KMError.NoMediaSessions -> resourceProvider.getString(R.string.error_no_media_sessions) - KMError.NoVoiceAssistant -> resourceProvider.getString(R.string.error_voice_assistant_not_found) - KMError.AccessibilityServiceDisabled -> resourceProvider.getString(R.string.error_accessibility_service_disabled) - KMError.LauncherShortcutsNotSupported -> resourceProvider.getString(R.string.error_launcher_shortcuts_not_supported) - KMError.AccessibilityServiceCrashed -> resourceProvider.getString(R.string.error_accessibility_service_crashed) - KMError.CantFindImeSettings -> resourceProvider.getString(R.string.error_cant_find_ime_settings) - KMError.CantShowImePickerInBackground -> resourceProvider.getString(R.string.error_cant_show_ime_picker_in_background) - KMError.FailedToFindAccessibilityNode -> resourceProvider.getString(R.string.error_failed_to_find_accessibility_node) - is KMError.FailedToPerformAccessibilityGlobalAction -> resourceProvider.getString( - R.string.error_failed_to_perform_accessibility_global_action, - action, - ) - - KMError.FailedToDispatchGesture -> resourceProvider.getString(R.string.error_failed_to_dispatch_gesture) - KMError.AppShortcutCantBeOpened -> resourceProvider.getString(R.string.error_opening_app_shortcut) - KMError.InsufficientPermissionsToOpenAppShortcut -> resourceProvider.getString(R.string.error_keymapper_doesnt_have_permission_app_shortcut) - KMError.NoAppToPhoneCall -> resourceProvider.getString(R.string.error_no_app_to_phone_call) - - KMError.CameraInUse -> resourceProvider.getString(R.string.error_camera_in_use) - KMError.CameraError -> resourceProvider.getString(R.string.error_camera_error) - KMError.CameraDisabled -> resourceProvider.getString(R.string.error_camera_disabled) - KMError.CameraDisconnected -> resourceProvider.getString(R.string.error_camera_disconnected) - KMError.MaxCamerasInUse -> resourceProvider.getString(R.string.error_max_cameras_in_use) - KMError.CameraVariableFlashlightStrengthUnsupported -> resourceProvider.getString(R.string.error_variable_flashlight_strength_unsupported) - - is KMError.FailedToModifySystemSetting -> resourceProvider.getString( - R.string.error_failed_to_modify_system_setting, - setting, - ) - - is SystemError.ImeDisabled -> resourceProvider.getString( - R.string.error_ime_disabled, - this.ime.label, - ) - - KMError.FailedToChangeIme -> resourceProvider.getString(R.string.error_failed_to_change_ime) - KMError.NoCameraApp -> resourceProvider.getString(R.string.error_no_camera_app) - KMError.NoDeviceAssistant -> resourceProvider.getString(R.string.error_no_device_assistant) - KMError.NoSettingsApp -> resourceProvider.getString(R.string.error_no_settings_app) - KMError.NoAppToOpenUrl -> resourceProvider.getString(R.string.error_no_app_to_open_url) - - KMError.CantFindSoundFile -> resourceProvider.getString(R.string.error_cant_find_sound_file) - is KMError.CorruptJsonFile -> reason - - is KMError.CannotCreateFileInTarget -> resourceProvider.getString( - R.string.error_file_access_denied, - uri, - ) - - KMError.FileOperationCancelled -> resourceProvider.getString(R.string.error_file_operation_cancelled) - is KMError.NoSpaceLeftOnTarget -> resourceProvider.getString( - R.string.error_no_space_left_at_target, - uri, - ) - - is KMError.NotADirectory -> resourceProvider.getString(R.string.error_not_a_directory, uri) - is KMError.NotAFile -> resourceProvider.getString(R.string.error_not_a_file, uri) - is KMError.SourceFileNotFound -> resourceProvider.getString( - R.string.error_source_file_not_found, - uri, - ) - - KMError.StoragePermissionDenied -> resourceProvider.getString(R.string.error_storage_permission_denied) - KMError.TargetDirectoryMatchesSourceDirectory -> resourceProvider.getString(R.string.error_matching_source_and_target_paths) - is KMError.TargetDirectoryNotFound -> resourceProvider.getString( - R.string.error_directory_not_found, - uri, - ) - - is KMError.TargetFileNotFound -> resourceProvider.getString( - R.string.error_target_file_not_found, - uri, - ) - - KMError.UnknownIOError -> resourceProvider.getString(R.string.error_io_error) - KMError.ShizukuNotStarted -> resourceProvider.getString(R.string.error_shizuku_not_started) - KMError.NoFileName -> resourceProvider.getString(R.string.error_no_file_name) - KMError.CantDetectKeyEventsInPhoneCall -> resourceProvider.getString(R.string.trigger_error_cant_detect_in_phone_call_explanation) - KMError.GestureStrokeCountTooHigh -> resourceProvider.getString(R.string.trigger_error_gesture_stroke_count_too_high) - KMError.GestureDurationTooHigh -> resourceProvider.getString(R.string.trigger_error_gesture_duration_too_high) - - KMError.DpadTriggerImeNotSelected -> resourceProvider.getString(R.string.trigger_error_dpad_ime_not_selected) - KMError.InvalidBackup -> resourceProvider.getString(R.string.error_invalid_backup) - KMError.MalformedUrl -> resourceProvider.getString(R.string.error_malformed_url) - KMError.UiElementNotFound -> resourceProvider.getString(R.string.error_ui_element_not_found) - - PurchasingError.PurchasingProcessError.Cancelled -> resourceProvider.getString( - R.string.purchasing_error_cancelled, - ) - - PurchasingError.PurchasingProcessError.NetworkError -> resourceProvider.getString( - R.string.purchasing_error_network, - ) - - PurchasingError.PurchasingProcessError.ProductNotFound -> resourceProvider.getString( - R.string.purchasing_error_product_not_found, - ) - - PurchasingError.PurchasingProcessError.StoreProblem -> resourceProvider.getString( - R.string.purchasing_error_store_problem, - ) - - PurchasingError.PurchasingProcessError.PaymentPending -> resourceProvider.getString( - R.string.purchasing_error_payment_pending, - ) - - PurchasingError.PurchasingProcessError.PurchaseInvalid -> resourceProvider.getString( - R.string.purchasing_error_purchase_invalid, - ) - - is PurchasingError.PurchasingProcessError.Unexpected -> message - - is PurchasingError.ProductNotPurchased -> when (product) { - ProductId.ASSISTANT_TRIGGER -> resourceProvider.getString(R.string.purchasing_error_assistant_not_purchased_home_screen) - ProductId.FLOATING_BUTTONS -> resourceProvider.getString(R.string.purchasing_error_floating_buttons_not_purchased_home_screen) - } - - PurchasingError.PurchasingNotImplemented -> resourceProvider.getString(R.string.purchasing_error_not_implemented) - else -> throw IllegalArgumentException("Unknown error $this") + is KMError.AppNotFound -> + resourceProvider.getString( + R.string.error_app_isnt_installed, + packageName, + ) + + is KMError.AppDisabled -> + resourceProvider.getString( + R.string.error_app_is_disabled_package_name, + this.packageName, + ) + + is KMError.NoCompatibleImeEnabled -> + resourceProvider.getString( + R.string.error_key_mapper_ime_service_disabled, + ) + is KMError.NoCompatibleImeChosen -> + resourceProvider.getString( + R.string.error_ime_must_be_chosen, + ) + is KMError.SystemFeatureNotSupported -> + when (this.feature) { + PackageManager.FEATURE_NFC -> + resourceProvider.getString( + R.string.error_system_feature_nfc_unsupported, + ) + PackageManager.FEATURE_CAMERA -> + resourceProvider.getString( + R.string.error_system_feature_camera_unsupported, + ) + PackageManager.FEATURE_FINGERPRINT -> + resourceProvider.getString( + R.string.error_system_feature_fingerprint_unsupported, + ) + PackageManager.FEATURE_WIFI -> + resourceProvider.getString( + R.string.error_system_feature_wifi_unsupported, + ) + PackageManager.FEATURE_BLUETOOTH -> + resourceProvider.getString( + R.string.error_system_feature_bluetooth_unsupported, + ) + PackageManager.FEATURE_DEVICE_ADMIN -> + resourceProvider.getString( + R.string.error_system_feature_device_admin_unsupported, + ) + PackageManager.FEATURE_CAMERA_FLASH -> + resourceProvider.getString( + R.string.error_system_feature_camera_flash_unsupported, + ) + PackageManager.FEATURE_TELEPHONY, + PackageManager.FEATURE_TELEPHONY_DATA, + PackageManager.FEATURE_TELEPHONY_MESSAGING, + -> + resourceProvider.getString( + R.string.error_system_feature_telephony_unsupported, + ) + + else -> + throw Exception( + "Don't know how to get error message for this system feature ${this.feature}", + ) + } + + is DataError.ExtraNotFound -> + resourceProvider.getString( + R.string.error_extra_not_found, + extraId, + ) + + is KMError.SdkVersionTooLow -> + resourceProvider.getString( + R.string.error_sdk_version_too_low, + BuildUtils.getSdkVersionName(minSdk), + ) + + is KMError.SdkVersionTooHigh -> + resourceProvider.getString( + R.string.error_sdk_version_too_high, + BuildUtils.getSdkVersionName(maxSdk), + ) + + is KMError.InputMethodNotFound -> + resourceProvider.getString( + R.string.error_ime_not_found, + imeLabel, + ) + + is KMError.FrontFlashNotFound -> + resourceProvider.getString( + R.string.error_front_flash_not_found, + ) + is KMError.BackFlashNotFound -> + resourceProvider.getString( + R.string.error_back_flash_not_found, + ) + is KMError.DeviceNotFound -> + resourceProvider.getString(R.string.error_device_not_found) + is KMError.Exception -> + exception.toString() + is KMError.EmptyJson -> + resourceProvider.getString(R.string.error_empty_json) + is KMError.InvalidNumber -> + resourceProvider.getString(R.string.error_invalid_number) + is KMError.NumberTooSmall -> + resourceProvider.getString( + R.string.error_number_too_small, + min, + ) + + is KMError.NumberTooBig -> + resourceProvider.getString(R.string.error_number_too_big, max) + is KMError.EmptyText -> + resourceProvider.getString(R.string.error_cant_be_empty) + KMError.BackupVersionTooNew -> + resourceProvider.getString( + R.string.error_backup_version_too_new, + ) + KMError.NoIncompatibleKeyboardsInstalled -> + resourceProvider.getString( + R.string.error_no_incompatible_input_methods_installed, + ) + KMError.NoMediaSessions -> + resourceProvider.getString(R.string.error_no_media_sessions) + KMError.NoVoiceAssistant -> + resourceProvider.getString( + R.string.error_voice_assistant_not_found, + ) + AccessibilityServiceError.Disabled -> + resourceProvider.getString( + R.string.error_accessibility_service_disabled, + ) + AccessibilityServiceError.Crashed -> + resourceProvider.getString( + R.string.error_accessibility_service_crashed, + ) + KMError.LauncherShortcutsNotSupported -> + resourceProvider.getString( + R.string.error_launcher_shortcuts_not_supported, + ) + KMError.CantFindImeSettings -> + resourceProvider.getString( + R.string.error_cant_find_ime_settings, + ) + KMError.CantShowImePickerInBackground -> + resourceProvider.getString( + R.string.error_cant_show_ime_picker_in_background, + ) + KMError.FailedToFindAccessibilityNode -> + resourceProvider.getString( + R.string.error_failed_to_find_accessibility_node, + ) + is KMError.FailedToPerformAccessibilityGlobalAction -> + resourceProvider.getString( + R.string.error_failed_to_perform_accessibility_global_action, + action, + ) + + KMError.FailedToDispatchGesture -> + resourceProvider.getString( + R.string.error_failed_to_dispatch_gesture, + ) + KMError.AppShortcutCantBeOpened -> + resourceProvider.getString( + R.string.error_opening_app_shortcut, + ) + KMError.InsufficientPermissionsToOpenAppShortcut -> + resourceProvider.getString( + R.string.error_keymapper_doesnt_have_permission_app_shortcut, + ) + KMError.NoAppToPhoneCall -> + resourceProvider.getString(R.string.error_no_app_to_phone_call) + KMError.NoAppToSendSms -> + resourceProvider.getString(R.string.error_no_app_to_send_sms) + is KMError.SendSmsError -> + { + when (resultCode) { + SmsManager.RESULT_ERROR_GENERIC_FAILURE -> + resourceProvider.getString( + R.string.error_sms_generic_failure, + ) + SmsManager.RESULT_ERROR_RADIO_OFF -> + resourceProvider.getString( + R.string.error_sms_radio_off, + ) + SmsManager.RESULT_ERROR_NO_SERVICE -> + resourceProvider.getString( + R.string.error_sms_no_service, + ) + SmsManager.RESULT_ERROR_LIMIT_EXCEEDED -> + resourceProvider.getString( + R.string.error_sms_limit_exceeded, + ) + SmsManager.RESULT_NETWORK_REJECT -> + resourceProvider.getString( + R.string.error_sms_network_reject, + ) + SmsManager.RESULT_NO_MEMORY -> + resourceProvider.getString( + R.string.error_sms_no_memory, + ) + SmsManager.RESULT_INVALID_SMS_FORMAT -> + resourceProvider.getString( + R.string.error_sms_invalid_format, + ) + SmsManager.RESULT_NETWORK_ERROR -> + resourceProvider.getString( + R.string.error_sms_network_error, + ) + SmsManager.RESULT_SMS_BLOCKED_DURING_EMERGENCY -> + resourceProvider.getString( + R.string.error_sms_blocked_during_emergency, + ) + SmsManager.RESULT_RIL_SIM_ABSENT -> + resourceProvider.getString( + R.string.error_sms_no_sim, + ) + else -> + resourceProvider.getString(R.string.error_sms_generic_failure) + } + } + + KMError.CameraInUse -> + resourceProvider.getString(R.string.error_camera_in_use) + KMError.CameraError -> + resourceProvider.getString(R.string.error_camera_error) + KMError.CameraDisabled -> + resourceProvider.getString(R.string.error_camera_disabled) + KMError.CameraDisconnected -> + resourceProvider.getString(R.string.error_camera_disconnected) + KMError.MaxCamerasInUse -> + resourceProvider.getString(R.string.error_max_cameras_in_use) + KMError.CameraVariableFlashlightStrengthUnsupported -> + resourceProvider.getString( + R.string.error_variable_flashlight_strength_unsupported, + ) + + is KMError.FailedToModifySystemSetting -> + resourceProvider.getString( + R.string.error_failed_to_modify_system_setting, + setting, + ) + + is SystemError.ImeDisabled -> + resourceProvider.getString( + R.string.error_ime_disabled, + this.ime.label, + ) + + KMError.SwitchImeFailed -> + resourceProvider.getString(R.string.error_failed_to_change_ime) + KMError.EnableImeFailed -> + resourceProvider.getString(R.string.error_failed_to_enable_ime) + KMError.NoCameraApp -> + resourceProvider.getString(R.string.error_no_camera_app) + KMError.NoDeviceAssistant -> + resourceProvider.getString(R.string.error_no_device_assistant) + KMError.NoSettingsApp -> + resourceProvider.getString(R.string.error_no_settings_app) + KMError.NoAppToOpenUrl -> + resourceProvider.getString(R.string.error_no_app_to_open_url) + + KMError.CantFindSoundFile -> + resourceProvider.getString(R.string.error_cant_find_sound_file) + is KMError.CorruptJsonFile -> + reason + + is KMError.CannotCreateFileInTarget -> + resourceProvider.getString( + R.string.error_file_access_denied, + uri, + ) + + KMError.FileOperationCancelled -> + resourceProvider.getString( + R.string.error_file_operation_cancelled, + ) + is KMError.NoSpaceLeftOnTarget -> + resourceProvider.getString( + R.string.error_no_space_left_at_target, + uri, + ) + + is KMError.NotADirectory -> + resourceProvider.getString(R.string.error_not_a_directory, uri) + is KMError.NotAFile -> + resourceProvider.getString(R.string.error_not_a_file, uri) + is KMError.SourceFileNotFound -> + resourceProvider.getString( + R.string.error_source_file_not_found, + uri, + ) + + KMError.StoragePermissionDenied -> + resourceProvider.getString( + R.string.error_storage_permission_denied, + ) + KMError.TargetDirectoryMatchesSourceDirectory -> + resourceProvider.getString( + R.string.error_matching_source_and_target_paths, + ) + is KMError.TargetDirectoryNotFound -> + resourceProvider.getString( + R.string.error_directory_not_found, + uri, + ) + + is KMError.TargetFileNotFound -> + resourceProvider.getString( + R.string.error_target_file_not_found, + uri, + ) + + KMError.UnknownIOError -> + resourceProvider.getString(R.string.error_io_error) + KMError.ShizukuNotStarted -> + resourceProvider.getString(R.string.error_shizuku_not_started) + KMError.NoFileName -> + resourceProvider.getString(R.string.error_no_file_name) + KMError.CantDetectKeyEventsInPhoneCall -> + resourceProvider.getString( + R.string.trigger_error_cant_detect_in_phone_call_explanation, + ) + KMError.GestureStrokeCountTooHigh -> + resourceProvider.getString( + R.string.trigger_error_gesture_stroke_count_too_high, + ) + KMError.GestureDurationTooHigh -> + resourceProvider.getString( + R.string.trigger_error_gesture_duration_too_high, + ) + + KMError.DpadTriggerImeNotSelected -> + resourceProvider.getString( + R.string.trigger_error_dpad_ime_not_selected, + ) + KMError.InvalidBackup -> + resourceProvider.getString(R.string.error_invalid_backup) + KMError.MalformedUrl -> + resourceProvider.getString(R.string.error_malformed_url) + KMError.UiElementNotFound -> + resourceProvider.getString(R.string.error_ui_element_not_found) + is KMError.ShellCommandTimeout -> + resourceProvider.getString( + R.string.error_shell_command_timeout, + timeoutMillis / 1000, + ) + is SystemBridgeError.Disconnected -> + resourceProvider.getString( + R.string.error_system_bridge_disconnected, + ) + + PurchasingError.PurchasingProcessError.Cancelled -> + resourceProvider.getString( + R.string.purchasing_error_cancelled, + ) + + PurchasingError.PurchasingProcessError.NetworkError -> + resourceProvider.getString( + R.string.purchasing_error_network, + ) + + PurchasingError.PurchasingProcessError.ProductNotFound -> + resourceProvider.getString( + R.string.purchasing_error_product_not_found, + ) + + PurchasingError.PurchasingProcessError.StoreProblem -> + resourceProvider.getString( + R.string.purchasing_error_store_problem, + ) + + PurchasingError.PurchasingProcessError.PaymentPending -> + resourceProvider.getString( + R.string.purchasing_error_payment_pending, + ) + + PurchasingError.PurchasingProcessError.PurchaseInvalid -> + resourceProvider.getString( + R.string.purchasing_error_purchase_invalid, + ) + + is PurchasingError.PurchasingProcessError.Unexpected -> + message + + is PurchasingError.ProductNotPurchased -> + when (product) { + ProductId.ASSISTANT_TRIGGER -> + resourceProvider.getString( + R.string.purchasing_error_assistant_not_purchased_home_screen, + ) + ProductId.FLOATING_BUTTONS -> + resourceProvider.getString( + R.string.purchasing_error_floating_buttons_not_purchased_home_screen, + ) + } + + PurchasingError.PurchasingNotImplemented -> + resourceProvider.getString( + R.string.purchasing_error_not_implemented, + ) + + is KMError.KeyEventActionError -> + resourceProvider.getString( + R.string.error_fix_key_event_action, + ) + is KMError.KeyMapperSmsRateLimit -> + resourceProvider.getString( + R.string.error_sms_rate_limit, + ) + + else -> + this.toString() } } @@ -219,13 +485,15 @@ val KMError.isFixable: Boolean KMError.NoCompatibleImeEnabled, KMError.NoCompatibleImeChosen, is SystemError.ImeDisabled, - KMError.AccessibilityServiceDisabled, - KMError.AccessibilityServiceCrashed, + is AccessibilityServiceError, is SystemError.PermissionDenied, is KMError.ShizukuNotStarted, is KMError.CantDetectKeyEventsInPhoneCall, + is SystemBridgeError.Disconnected, + is KMError.KeyEventActionError, + -> + true - -> true - - else -> false + else -> + false } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/FilterUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/FilterUtils.kt index 5ac7a28816..b90224f9b5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/FilterUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/FilterUtils.kt @@ -2,11 +2,11 @@ package io.github.sds100.keymapper.base.utils import io.github.sds100.keymapper.base.utils.ui.ISearchable import io.github.sds100.keymapper.common.utils.State +import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withContext -import java.util.Locale fun List.filterByQuery(query: String?): Flow>> = flow { if (query.isNullOrBlank()) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/InputEventStrings.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/KeyCodeStrings.kt similarity index 97% rename from base/src/main/java/io/github/sds100/keymapper/base/utils/InputEventStrings.kt rename to base/src/main/java/io/github/sds100/keymapper/base/utils/KeyCodeStrings.kt index ea2bf1c8f0..b74936b4e9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/InputEventStrings.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/KeyCodeStrings.kt @@ -2,9 +2,8 @@ package io.github.sds100.keymapper.base.utils import android.view.KeyEvent import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.system.inputevents.InputEventUtils.KEYCODE_TO_SCANCODE_OFFSET -object InputEventStrings { +object KeyCodeStrings { val MODIFIER_LABELS = mapOf( KeyEvent.META_CTRL_ON to R.string.meta_state_ctrl, @@ -34,8 +33,8 @@ object InputEventStrings { * Maps keys which aren't single characters like the Control keys to a string representation */ private val NON_CHARACTER_KEY_LABELS: Map = mapOf( - KeyEvent.KEYCODE_VOLUME_DOWN to "Volume down", - KeyEvent.KEYCODE_VOLUME_UP to "Volume up", + KeyEvent.KEYCODE_VOLUME_DOWN to "Volume Down", + KeyEvent.KEYCODE_VOLUME_UP to "Volume Up", KeyEvent.KEYCODE_CTRL_LEFT to "Ctrl Left", KeyEvent.KEYCODE_CTRL_RIGHT to "Ctrl Right", @@ -369,11 +368,7 @@ object InputEventStrings { * Create a text representation of a key event. E.g if the control key was pressed, * "Ctrl" will be returned */ - fun keyCodeToString(keyCode: Int): String = NON_CHARACTER_KEY_LABELS[keyCode].let { - if (keyCode >= KEYCODE_TO_SCANCODE_OFFSET || keyCode < 0) { - "scancode $keyCode" - } else { - it ?: "unknown keycode $keyCode" - } + fun keyCodeToString(keyCode: Int): String? { + return NON_CHARACTER_KEY_LABELS[keyCode] } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ProModeStatus.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ProModeStatus.kt new file mode 100644 index 0000000000..4bc9c674ec --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ProModeStatus.kt @@ -0,0 +1,7 @@ +package io.github.sds100.keymapper.base.utils + +enum class ProModeStatus { + UNSUPPORTED, + DISABLED, + ENABLED, +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ScancodeStrings.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ScancodeStrings.kt new file mode 100644 index 0000000000..b1b9c512fc --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ScancodeStrings.kt @@ -0,0 +1,579 @@ +package io.github.sds100.keymapper.base.utils + +import io.github.sds100.keymapper.system.inputevents.Scancode + +object ScancodeStrings { + private val SCANCODE_LABELS = mapOf( + // Number keys + Scancode.KEY_1 to "1", + Scancode.KEY_2 to "2", + Scancode.KEY_3 to "3", + Scancode.KEY_4 to "4", + Scancode.KEY_5 to "5", + Scancode.KEY_6 to "6", + Scancode.KEY_7 to "7", + Scancode.KEY_8 to "8", + Scancode.KEY_9 to "9", + Scancode.KEY_0 to "0", + + // Special characters + Scancode.KEY_MINUS to "-", + Scancode.KEY_EQUAL to "=", + Scancode.KEY_LEFTBRACE to "[", + Scancode.KEY_RIGHTBRACE to "]", + Scancode.KEY_SEMICOLON to ";", + Scancode.KEY_APOSTROPHE to "'", + Scancode.KEY_GRAVE to "`", + Scancode.KEY_BACKSLASH to "\\", + Scancode.KEY_COMMA to ",", + Scancode.KEY_DOT to ".", + Scancode.KEY_SLASH to "/", + + // Letter keys + Scancode.KEY_Q to "Q", + Scancode.KEY_W to "W", + Scancode.KEY_E to "E", + Scancode.KEY_R to "R", + Scancode.KEY_T to "T", + Scancode.KEY_Y to "Y", + Scancode.KEY_U to "U", + Scancode.KEY_I to "I", + Scancode.KEY_O to "O", + Scancode.KEY_P to "P", + Scancode.KEY_A to "A", + Scancode.KEY_S to "S", + Scancode.KEY_D to "D", + Scancode.KEY_F to "F", + Scancode.KEY_G to "G", + Scancode.KEY_H to "H", + Scancode.KEY_J to "J", + Scancode.KEY_K to "K", + Scancode.KEY_L to "L", + Scancode.KEY_Z to "Z", + Scancode.KEY_X to "X", + Scancode.KEY_C to "C", + Scancode.KEY_V to "V", + Scancode.KEY_B to "B", + Scancode.KEY_N to "N", + Scancode.KEY_M to "M", + + // Function keys + Scancode.KEY_F1 to "F1", + Scancode.KEY_F2 to "F2", + Scancode.KEY_F3 to "F3", + Scancode.KEY_F4 to "F4", + Scancode.KEY_F5 to "F5", + Scancode.KEY_F6 to "F6", + Scancode.KEY_F7 to "F7", + Scancode.KEY_F8 to "F8", + Scancode.KEY_F9 to "F9", + Scancode.KEY_F10 to "F10", + Scancode.KEY_F11 to "F11", + Scancode.KEY_F12 to "F12", + Scancode.KEY_F13 to "F13", + Scancode.KEY_F14 to "F14", + Scancode.KEY_F15 to "F15", + Scancode.KEY_F16 to "F16", + Scancode.KEY_F17 to "F17", + Scancode.KEY_F18 to "F18", + Scancode.KEY_F19 to "F19", + Scancode.KEY_F20 to "F20", + Scancode.KEY_F21 to "F21", + Scancode.KEY_F22 to "F22", + Scancode.KEY_F23 to "F23", + Scancode.KEY_F24 to "F24", + + // Control keys + Scancode.KEY_BACKSPACE to "Backspace", + Scancode.KEY_TAB to "Tab", + Scancode.KEY_ENTER to "Enter", + Scancode.KEY_LEFTCTRL to "Left Ctrl", + Scancode.KEY_RIGHTCTRL to "Right Ctrl", + Scancode.KEY_LEFTSHIFT to "Left Shift", + Scancode.KEY_RIGHTSHIFT to "Right Shift", + Scancode.KEY_LEFTALT to "Left Alt", + Scancode.KEY_RIGHTALT to "Right Alt", + Scancode.KEY_SPACE to "Space", + Scancode.KEY_CAPSLOCK to "Caps Lock", + Scancode.KEY_NUMLOCK to "Num Lock", + Scancode.KEY_SCROLLLOCK to "Scroll Lock", + Scancode.KEY_LEFTMETA to "Left Meta", + Scancode.KEY_RIGHTMETA to "Right Meta", + Scancode.KEY_COMPOSE to "Compose", + + // Navigation keys + Scancode.KEY_HOME to "Home", + Scancode.KEY_END to "End", + Scancode.KEY_UP to "Up", + Scancode.KEY_DOWN to "Down", + Scancode.KEY_LEFT to "Left", + Scancode.KEY_RIGHT to "Right", + Scancode.KEY_PAGEUP to "Page Up", + Scancode.KEY_PAGEDOWN to "Page Down", + Scancode.KEY_INSERT to "Insert", + Scancode.KEY_DELETE to "Delete", + + // Keypad keys + Scancode.KEY_KP0 to "Keypad 0", + Scancode.KEY_KP1 to "Keypad 1", + Scancode.KEY_KP2 to "Keypad 2", + Scancode.KEY_KP3 to "Keypad 3", + Scancode.KEY_KP4 to "Keypad 4", + Scancode.KEY_KP5 to "Keypad 5", + Scancode.KEY_KP6 to "Keypad 6", + Scancode.KEY_KP7 to "Keypad 7", + Scancode.KEY_KP8 to "Keypad 8", + Scancode.KEY_KP9 to "Keypad 9", + Scancode.KEY_KPDOT to "Keypad .", + Scancode.KEY_KPPLUS to "Keypad +", + Scancode.KEY_KPMINUS to "Keypad -", + Scancode.KEY_KPASTERISK to "Keypad *", + Scancode.KEY_KPSLASH to "Keypad /", + Scancode.KEY_KPENTER to "Keypad Enter", + Scancode.KEY_KPEQUAL to "Keypad =", + Scancode.KEY_KPPLUSMINUS to "Keypad +/-", + Scancode.KEY_KPCOMMA to "Keypad ,", + Scancode.KEY_KPLEFTPAREN to "Keypad (", + Scancode.KEY_KPRIGHTPAREN to "Keypad )", + Scancode.KEY_KPJPCOMMA to "Keypad JP Comma", + + // Media keys + Scancode.KEY_MUTE to "Mute", + Scancode.KEY_VOLUMEDOWN to "Volume Down", + Scancode.KEY_VOLUMEUP to "Volume Up", + Scancode.KEY_NEXTSONG to "Next Song", + Scancode.KEY_PREVIOUSSONG to "Previous Song", + Scancode.KEY_PLAYPAUSE to "Play/Pause", + Scancode.KEY_STOPCD to "Stop", + Scancode.KEY_RECORD to "Record", + Scancode.KEY_REWIND to "Rewind", + Scancode.KEY_FASTFORWARD to "Fast Forward", + Scancode.KEY_EJECTCD to "Eject", + Scancode.KEY_CLOSECD to "Close CD", + Scancode.KEY_EJECTCLOSECD to "Eject/Close CD", + Scancode.KEY_PLAYCD to "Play CD", + Scancode.KEY_PAUSECD to "Pause CD", + Scancode.KEY_PLAY to "Play", + + // System keys + Scancode.KEY_POWER to "Power", + Scancode.KEY_SLEEP to "Sleep", + Scancode.KEY_WAKEUP to "Wake Up", + Scancode.KEY_SUSPEND to "Suspend", + Scancode.KEY_PAUSE to "Pause", + Scancode.KEY_SYSRQ to "SysRq", + Scancode.KEY_LINEFEED to "Line Feed", + Scancode.KEY_MACRO to "Macro", + Scancode.KEY_SCALE to "Scale", + + // Brightness keys + Scancode.KEY_BRIGHTNESSDOWN to "Brightness Down", + Scancode.KEY_BRIGHTNESSUP to "Brightness Up", + Scancode.KEY_BRIGHTNESS_CYCLE to "Brightness Cycle", + Scancode.KEY_BRIGHTNESS_AUTO to "Brightness Auto", + Scancode.KEY_BRIGHTNESS_MIN to "Brightness Min", + Scancode.KEY_BRIGHTNESS_MAX to "Brightness Max", + Scancode.KEY_DISPLAY_OFF to "Display Off", + Scancode.KEY_SWITCHVIDEOMODE to "Switch Video Mode", + Scancode.KEY_KBDILLUMTOGGLE to "Keyboard Illumination Toggle", + Scancode.KEY_KBDILLUMDOWN to "Keyboard Illumination Down", + Scancode.KEY_KBDILLUMUP to "Keyboard Illumination Up", + + // International keys + Scancode.KEY_ZENKAKUHANKAKU to "Zenkaku/Hankaku", + Scancode.KEY_102ND to "102nd Key", + Scancode.KEY_RO to "Ro", + Scancode.KEY_KATAKANA to "Katakana", + Scancode.KEY_HIRAGANA to "Hiragana", + Scancode.KEY_HENKAN to "Henkan", + Scancode.KEY_KATAKANAHIRAGANA to "Katakana/Hiragana", + Scancode.KEY_MUHENKAN to "Muhenkan", + Scancode.KEY_HANGEUL to "Hangeul", + Scancode.KEY_HANJA to "Hanja", + Scancode.KEY_YEN to "Yen", + + // Application keys + Scancode.KEY_STOP to "Stop", + Scancode.KEY_AGAIN to "Again", + Scancode.KEY_PROPS to "Properties", + Scancode.KEY_UNDO to "Undo", + Scancode.KEY_FRONT to "Front", + Scancode.KEY_COPY to "Copy", + Scancode.KEY_OPEN to "Open", + Scancode.KEY_PASTE to "Paste", + Scancode.KEY_FIND to "Find", + Scancode.KEY_CUT to "Cut", + Scancode.KEY_HELP to "Help", + Scancode.KEY_MENU to "Menu", + Scancode.KEY_CALC to "Calculator", + Scancode.KEY_SETUP to "Setup", + Scancode.KEY_FILE to "File", + Scancode.KEY_SENDFILE to "Send File", + Scancode.KEY_DELETEFILE to "Delete File", + Scancode.KEY_XFER to "Transfer", + Scancode.KEY_PROG1 to "Program 1", + Scancode.KEY_PROG2 to "Program 2", + Scancode.KEY_PROG3 to "Program 3", + Scancode.KEY_PROG4 to "Program 4", + + // Web/Internet keys + Scancode.KEY_WWW to "WWW", + Scancode.KEY_MSDOS to "MS-DOS", + Scancode.KEY_COFFEE to "Coffee", + Scancode.KEY_ROTATE_DISPLAY to "Rotate Display", + Scancode.KEY_CYCLEWINDOWS to "Cycle Windows", + Scancode.KEY_MAIL to "Mail", + Scancode.KEY_BOOKMARKS to "Bookmarks", + Scancode.KEY_COMPUTER to "Computer", + Scancode.KEY_BACK to "Back", + Scancode.KEY_FORWARD to "Forward", + Scancode.KEY_HOMEPAGE to "Homepage", + Scancode.KEY_REFRESH to "Refresh", + Scancode.KEY_EXIT to "Exit", + Scancode.KEY_MOVE to "Move", + Scancode.KEY_EDIT to "Edit", + Scancode.KEY_SCROLLUP to "Scroll Up", + Scancode.KEY_SCROLLDOWN to "Scroll Down", + Scancode.KEY_NEW to "New", + Scancode.KEY_REDO to "Redo", + + // Multimedia keys + Scancode.KEY_BASSBOOST to "Bass Boost", + Scancode.KEY_PRINT to "Print", + Scancode.KEY_HP to "HP", + Scancode.KEY_CAMERA to "Camera", + Scancode.KEY_SOUND to "Sound", + Scancode.KEY_QUESTION to "Question", + Scancode.KEY_EMAIL to "Email", + Scancode.KEY_CHAT to "Chat", + Scancode.KEY_SEARCH to "Search", + Scancode.KEY_CONNECT to "Connect", + Scancode.KEY_FINANCE to "Finance", + Scancode.KEY_SPORT to "Sport", + Scancode.KEY_SHOP to "Shop", + Scancode.KEY_ALTERASE to "Alt Erase", + Scancode.KEY_CANCEL to "Cancel", + Scancode.KEY_MEDIA to "Media", + + // Wireless keys + Scancode.KEY_BLUETOOTH to "Bluetooth", + Scancode.KEY_WLAN to "WLAN", + Scancode.KEY_UWB to "UWB", + Scancode.KEY_UNKNOWN to "Unknown", + Scancode.KEY_WWAN to "WWAN", + Scancode.KEY_RFKILL to "RF Kill", + Scancode.KEY_MICMUTE to "Mic Mute", + + // Video keys + Scancode.KEY_VIDEO_NEXT to "Video Next", + Scancode.KEY_VIDEO_PREV to "Video Previous", + + // Battery and document keys + Scancode.KEY_BATTERY to "Battery", + Scancode.KEY_DOCUMENTS to "Documents", + Scancode.KEY_SEND to "Send", + Scancode.KEY_REPLY to "Reply", + Scancode.KEY_FORWARDMAIL to "Forward Mail", + Scancode.KEY_SAVE to "Save", + + // Mouse buttons + Scancode.BTN_LEFT to "Left Mouse Button", + Scancode.BTN_RIGHT to "Right Mouse Button", + Scancode.BTN_MIDDLE to "Middle Mouse Button", + Scancode.BTN_SIDE to "Side Mouse Button", + Scancode.BTN_EXTRA to "Extra Mouse Button", + Scancode.BTN_FORWARD to "Forward Mouse Button", + Scancode.BTN_BACK to "Back Mouse Button", + Scancode.BTN_TASK to "Task Mouse Button", + + // Generic buttons + Scancode.BTN_0 to "Button 0", + Scancode.BTN_1 to "Button 1", + Scancode.BTN_2 to "Button 2", + Scancode.BTN_3 to "Button 3", + Scancode.BTN_4 to "Button 4", + Scancode.BTN_5 to "Button 5", + Scancode.BTN_6 to "Button 6", + Scancode.BTN_7 to "Button 7", + Scancode.BTN_8 to "Button 8", + Scancode.BTN_9 to "Button 9", + + // Joystick buttons + Scancode.BTN_TRIGGER to "Trigger", + Scancode.BTN_THUMB to "Thumb", + Scancode.BTN_THUMB2 to "Thumb 2", + Scancode.BTN_TOP to "Top", + Scancode.BTN_TOP2 to "Top 2", + Scancode.BTN_PINKIE to "Pinkie", + Scancode.BTN_BASE to "Base", + Scancode.BTN_BASE2 to "Base 2", + Scancode.BTN_BASE3 to "Base 3", + Scancode.BTN_BASE4 to "Base 4", + Scancode.BTN_BASE5 to "Base 5", + Scancode.BTN_BASE6 to "Base 6", + Scancode.BTN_DEAD to "Dead", + + // Gamepad buttons + Scancode.BTN_SOUTH to "South Button", + Scancode.BTN_EAST to "East Button", + Scancode.BTN_C to "C Button", + Scancode.BTN_NORTH to "North Button", + Scancode.BTN_WEST to "West Button", + Scancode.BTN_Z to "Z Button", + Scancode.BTN_TL to "Top Left", + Scancode.BTN_TR to "Top Right", + Scancode.BTN_TL2 to "Top Left 2", + Scancode.BTN_TR2 to "Top Right 2", + Scancode.BTN_SELECT to "Select", + Scancode.BTN_START to "Start", + Scancode.BTN_MODE to "Mode", + Scancode.BTN_THUMBL to "Left Thumb", + Scancode.BTN_THUMBR to "Right Thumb", + + // Digital pen buttons + Scancode.BTN_TOOL_PEN to "Pen Tool", + Scancode.BTN_TOOL_RUBBER to "Rubber Tool", + Scancode.BTN_TOOL_BRUSH to "Brush Tool", + Scancode.BTN_TOOL_PENCIL to "Pencil Tool", + Scancode.BTN_TOOL_AIRBRUSH to "Airbrush Tool", + Scancode.BTN_TOOL_FINGER to "Finger Tool", + Scancode.BTN_TOOL_MOUSE to "Mouse Tool", + Scancode.BTN_TOOL_LENS to "Lens Tool", + Scancode.BTN_TOOL_QUINTTAP to "Quint Tap Tool", + Scancode.BTN_STYLUS3 to "Stylus 3", + Scancode.BTN_TOUCH to "Touch", + Scancode.BTN_STYLUS to "Stylus", + Scancode.BTN_STYLUS2 to "Stylus 2", + Scancode.BTN_TOOL_DOUBLETAP to "Double Tap Tool", + Scancode.BTN_TOOL_TRIPLETAP to "Triple Tap Tool", + Scancode.BTN_TOOL_QUADTAP to "Quad Tap Tool", + + // Wheel buttons + Scancode.BTN_GEAR_DOWN to "Gear Down", + Scancode.BTN_GEAR_UP to "Gear Up", + + // Remote control keys + Scancode.KEY_OK to "OK", + Scancode.KEY_SELECT to "Select", + Scancode.KEY_GOTO to "Goto", + Scancode.KEY_CLEAR to "Clear", + Scancode.KEY_POWER2 to "Power 2", + Scancode.KEY_OPTION to "Option", + Scancode.KEY_INFO to "Info", + Scancode.KEY_TIME to "Time", + Scancode.KEY_VENDOR to "Vendor", + Scancode.KEY_ARCHIVE to "Archive", + Scancode.KEY_PROGRAM to "Program", + Scancode.KEY_CHANNEL to "Channel", + Scancode.KEY_FAVORITES to "Favorites", + Scancode.KEY_EPG to "EPG", + Scancode.KEY_PVR to "PVR", + Scancode.KEY_MHP to "MHP", + Scancode.KEY_LANGUAGE to "Language", + Scancode.KEY_TITLE to "Title", + Scancode.KEY_SUBTITLE to "Subtitle", + Scancode.KEY_ANGLE to "Angle", + Scancode.KEY_ZOOM to "Zoom", + Scancode.KEY_MODE to "Mode", + Scancode.KEY_KEYBOARD to "Keyboard", + Scancode.KEY_SCREEN to "Screen", + Scancode.KEY_PC to "PC", + Scancode.KEY_TV to "TV", + Scancode.KEY_TV2 to "TV 2", + Scancode.KEY_VCR to "VCR", + Scancode.KEY_VCR2 to "VCR 2", + Scancode.KEY_SAT to "Satellite", + Scancode.KEY_SAT2 to "Satellite 2", + Scancode.KEY_CD to "CD", + Scancode.KEY_TAPE to "Tape", + Scancode.KEY_RADIO to "Radio", + Scancode.KEY_TUNER to "Tuner", + Scancode.KEY_PLAYER to "Player", + Scancode.KEY_TEXT to "Text", + Scancode.KEY_DVD to "DVD", + Scancode.KEY_AUX to "Aux", + Scancode.KEY_MP3 to "MP3", + Scancode.KEY_AUDIO to "Audio", + Scancode.KEY_VIDEO to "Video", + Scancode.KEY_DIRECTORY to "Directory", + Scancode.KEY_LIST to "List", + Scancode.KEY_MEMO to "Memo", + Scancode.KEY_CALENDAR to "Calendar", + Scancode.KEY_RED to "Red", + Scancode.KEY_GREEN to "Green", + Scancode.KEY_YELLOW to "Yellow", + Scancode.KEY_BLUE to "Blue", + Scancode.KEY_CHANNELUP to "Channel Up", + Scancode.KEY_CHANNELDOWN to "Channel Down", + Scancode.KEY_FIRST to "First", + Scancode.KEY_LAST to "Last", + Scancode.KEY_AB to "A-B", + Scancode.KEY_NEXT to "Next", + Scancode.KEY_RESTART to "Restart", + Scancode.KEY_SLOW to "Slow", + Scancode.KEY_SHUFFLE to "Shuffle", + Scancode.KEY_BREAK to "Break", + Scancode.KEY_PREVIOUS to "Previous", + Scancode.KEY_DIGITS to "Digits", + Scancode.KEY_TEEN to "Teen", + Scancode.KEY_TWEN to "Twen", + + // Phone keys + Scancode.KEY_PHONE to "Phone", + Scancode.KEY_VIDEOPHONE to "Video Phone", + Scancode.KEY_PICKUP_PHONE to "Pick Up Phone", + Scancode.KEY_HANGUP_PHONE to "Hang Up Phone", + + // Application keys + Scancode.KEY_GAMES to "Games", + Scancode.KEY_ZOOMIN to "Zoom In", + Scancode.KEY_ZOOMOUT to "Zoom Out", + Scancode.KEY_ZOOMRESET to "Zoom Reset", + Scancode.KEY_WORDPROCESSOR to "Word Processor", + Scancode.KEY_EDITOR to "Editor", + Scancode.KEY_SPREADSHEET to "Spreadsheet", + Scancode.KEY_GRAPHICSEDITOR to "Graphics Editor", + Scancode.KEY_PRESENTATION to "Presentation", + Scancode.KEY_DATABASE to "Database", + Scancode.KEY_NEWS to "News", + Scancode.KEY_VOICEMAIL to "Voicemail", + Scancode.KEY_ADDRESSBOOK to "Address Book", + Scancode.KEY_MESSENGER to "Messenger", + Scancode.KEY_DISPLAYTOGGLE to "Display Toggle", + Scancode.KEY_SPELLCHECK to "Spell Check", + Scancode.KEY_LOGOFF to "Log Off", + + // Currency keys + Scancode.KEY_DOLLAR to "Dollar", + Scancode.KEY_EURO to "Euro", + + // Media control keys + Scancode.KEY_FRAMEBACK to "Frame Back", + Scancode.KEY_FRAMEFORWARD to "Frame Forward", + Scancode.KEY_CONTEXT_MENU to "Context Menu", + Scancode.KEY_MEDIA_REPEAT to "Media Repeat", + Scancode.KEY_10CHANNELSUP to "10 Channels Up", + Scancode.KEY_10CHANNELSDOWN to "10 Channels Down", + Scancode.KEY_IMAGES to "Images", + Scancode.KEY_NOTIFICATION_CENTER to "Notification Center", + + // Numeric keypad + Scancode.KEY_NUMERIC_0 to "Numeric 0", + Scancode.KEY_NUMERIC_1 to "Numeric 1", + Scancode.KEY_NUMERIC_2 to "Numeric 2", + Scancode.KEY_NUMERIC_3 to "Numeric 3", + Scancode.KEY_NUMERIC_4 to "Numeric 4", + Scancode.KEY_NUMERIC_5 to "Numeric 5", + Scancode.KEY_NUMERIC_6 to "Numeric 6", + Scancode.KEY_NUMERIC_7 to "Numeric 7", + Scancode.KEY_NUMERIC_8 to "Numeric 8", + Scancode.KEY_NUMERIC_9 to "Numeric 9", + Scancode.KEY_NUMERIC_STAR to "Numeric *", + Scancode.KEY_NUMERIC_POUND to "Numeric #", + Scancode.KEY_NUMERIC_A to "Numeric A", + Scancode.KEY_NUMERIC_B to "Numeric B", + Scancode.KEY_NUMERIC_C to "Numeric C", + Scancode.KEY_NUMERIC_D to "Numeric D", + Scancode.KEY_NUMERIC_11 to "Numeric 11", + Scancode.KEY_NUMERIC_12 to "Numeric 12", + + // System control keys + Scancode.KEY_BUTTONCONFIG to "Button Config", + Scancode.KEY_TASKMANAGER to "Task Manager", + Scancode.KEY_JOURNAL to "Journal", + Scancode.KEY_CONTROLPANEL to "Control Panel", + Scancode.KEY_APPSELECT to "App Select", + Scancode.KEY_SCREENSAVER to "Screen Saver", + Scancode.KEY_VOICECOMMAND to "Voice Command", + Scancode.KEY_ASSISTANT to "Assistant", + Scancode.KEY_KBD_LAYOUT_NEXT to "Keyboard Layout Next", + Scancode.KEY_EMOJI_PICKER to "Emoji Picker", + Scancode.KEY_DICTATE to "Dictate", + + // D-pad buttons + Scancode.BTN_DPAD_UP to "D-pad Up", + Scancode.BTN_DPAD_DOWN to "D-pad Down", + Scancode.BTN_DPAD_LEFT to "D-pad Left", + Scancode.BTN_DPAD_RIGHT to "D-pad Right", + + // Text editing keys + Scancode.KEY_DEL_EOL to "Delete End of Line", + Scancode.KEY_DEL_EOS to "Delete End of Screen", + Scancode.KEY_INS_LINE to "Insert Line", + Scancode.KEY_DEL_LINE to "Delete Line", + + // Function modifier keys + Scancode.KEY_FN to "Fn", + Scancode.KEY_FN_ESC to "Fn+Esc", + Scancode.KEY_FN_F1 to "Fn+F1", + Scancode.KEY_FN_F2 to "Fn+F2", + Scancode.KEY_FN_F3 to "Fn+F3", + Scancode.KEY_FN_F4 to "Fn+F4", + Scancode.KEY_FN_F5 to "Fn+F5", + Scancode.KEY_FN_F6 to "Fn+F6", + Scancode.KEY_FN_F7 to "Fn+F7", + Scancode.KEY_FN_F8 to "Fn+F8", + Scancode.KEY_FN_F9 to "Fn+F9", + Scancode.KEY_FN_F10 to "Fn+F10", + Scancode.KEY_FN_F11 to "Fn+F11", + Scancode.KEY_FN_F12 to "Fn+F12", + Scancode.KEY_FN_1 to "Fn+1", + Scancode.KEY_FN_2 to "Fn+2", + Scancode.KEY_FN_D to "Fn+D", + Scancode.KEY_FN_E to "Fn+E", + Scancode.KEY_FN_F to "Fn+F", + Scancode.KEY_FN_S to "Fn+S", + Scancode.KEY_FN_B to "Fn+B", + Scancode.KEY_FN_RIGHT_SHIFT to "Fn+Right Shift", + + // Braille keys + Scancode.KEY_BRL_DOT1 to "Braille Dot 1", + Scancode.KEY_BRL_DOT2 to "Braille Dot 2", + Scancode.KEY_BRL_DOT3 to "Braille Dot 3", + Scancode.KEY_BRL_DOT4 to "Braille Dot 4", + Scancode.KEY_BRL_DOT5 to "Braille Dot 5", + Scancode.KEY_BRL_DOT6 to "Braille Dot 6", + Scancode.KEY_BRL_DOT7 to "Braille Dot 7", + Scancode.KEY_BRL_DOT8 to "Braille Dot 8", + Scancode.KEY_BRL_DOT9 to "Braille Dot 9", + Scancode.KEY_BRL_DOT10 to "Braille Dot 10", + + // Camera keys + Scancode.KEY_CAMERA_FOCUS to "Camera Focus", + Scancode.KEY_CAMERA_ZOOMIN to "Camera Zoom In", + Scancode.KEY_CAMERA_ZOOMOUT to "Camera Zoom Out", + Scancode.KEY_CAMERA_UP to "Camera Up", + Scancode.KEY_CAMERA_DOWN to "Camera Down", + Scancode.KEY_CAMERA_LEFT to "Camera Left", + Scancode.KEY_CAMERA_RIGHT to "Camera Right", + Scancode.KEY_CAMERA_ACCESS_ENABLE to "Camera Access Enable", + Scancode.KEY_CAMERA_ACCESS_DISABLE to "Camera Access Disable", + Scancode.KEY_CAMERA_ACCESS_TOGGLE to "Camera Access Toggle", + + // Wireless and touchpad keys + Scancode.KEY_WPS_BUTTON to "WPS Button", + Scancode.KEY_TOUCHPAD_TOGGLE to "Touchpad Toggle", + Scancode.KEY_TOUCHPAD_ON to "Touchpad On", + Scancode.KEY_TOUCHPAD_OFF to "Touchpad Off", + + // Sensor keys + Scancode.KEY_ALS_TOGGLE to "ALS Toggle", + Scancode.KEY_ROTATE_LOCK_TOGGLE to "Rotate Lock Toggle", + + // Macro keys (first 10) + Scancode.KEY_MACRO1 to "Macro 1", + Scancode.KEY_MACRO2 to "Macro 2", + Scancode.KEY_MACRO3 to "Macro 3", + Scancode.KEY_MACRO4 to "Macro 4", + Scancode.KEY_MACRO5 to "Macro 5", + Scancode.KEY_MACRO6 to "Macro 6", + Scancode.KEY_MACRO7 to "Macro 7", + Scancode.KEY_MACRO8 to "Macro 8", + Scancode.KEY_MACRO9 to "Macro 9", + Scancode.KEY_MACRO10 to "Macro 10", + ) + + fun getScancodeLabel(scancode: Int): String? { + return SCANCODE_LABELS[scancode] + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ShareUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ShareUtils.kt index 5b999c61bb..836188899d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ShareUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ShareUtils.kt @@ -50,7 +50,8 @@ object ShareUtils { ctx, 1, broadcast, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT, + PendingIntent.FLAG_IMMUTABLE or + PendingIntent.FLAG_CANCEL_CURRENT, ), ).build(), ) @@ -58,6 +59,8 @@ object ShareUtils { intent.putExtra(Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, customActions) } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ctx.startActivity(intent) } } catch (_: ActivityNotFoundException) { 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 8f6c870c49..fdbc390a85 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 @@ -4,9 +4,10 @@ import io.github.sds100.keymapper.base.actions.ActionData import io.github.sds100.keymapper.base.actions.pinchscreen.PinchPickCoordinateResult import io.github.sds100.keymapper.base.actions.swipescreen.SwipePickCoordinateResult import io.github.sds100.keymapper.base.actions.tapscreen.PickCoordinateResult -import io.github.sds100.keymapper.base.constraints.Constraint +import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.system.apps.ChooseAppShortcutResult import io.github.sds100.keymapper.base.system.intents.ConfigIntentResult +import io.github.sds100.keymapper.base.trigger.TriggerSetupShortcut import io.github.sds100.keymapper.system.apps.ActivityInfo import io.github.sds100.keymapper.system.bluetooth.BluetoothDeviceInfo import kotlinx.serialization.Serializable @@ -31,10 +32,15 @@ abstract class NavDestination(val isCompose: Boolean = false) { const val ID_CHOOSE_CONSTRAINT = "choose_constraint" const val ID_CHOOSE_BLUETOOTH_DEVICE = "choose_bluetooth_device" const val ID_SETTINGS = "settings" + const val ID_DEFAULT_OPTIONS_SETTINGS = "default_options_settings" + const val ID_AUTOMATIC_CHANGE_IME_SETTINGS = "automatic_change_ime_settings" const val ID_ABOUT = "about" const val ID_CONFIG_KEY_MAP = "config_key_map" - const val ID_SHIZUKU_SETTINGS = "shizuku_settings" const val ID_INTERACT_UI_ELEMENT_ACTION = "interact_ui_element_action" + const val ID_SHELL_COMMAND_ACTION = "shell_command_action" + const val ID_PRO_MODE = "pro_mode" + const val ID_LOG = "log" + const val ID_ADVANCED_TRIGGERS = "advanced_triggers" } @Serializable @@ -69,7 +75,8 @@ abstract class NavDestination(val isCompose: Boolean = false) { } @Serializable - data class PickCoordinate(val result: PickCoordinateResult? = null) : NavDestination() { + data class PickCoordinate(val result: PickCoordinateResult? = null) : + NavDestination() { override val id: String = ID_PICK_COORDINATE } @@ -86,7 +93,8 @@ abstract class NavDestination(val isCompose: Boolean = false) { } @Serializable - data class ConfigIntent(val result: ConfigIntentResult? = null) : NavDestination() { + data class ConfigIntent(val result: ConfigIntentResult? = null) : + NavDestination() { override val id: String = ID_CONFIG_INTENT } @@ -106,7 +114,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { } @Serializable - data object ChooseConstraint : NavDestination(isCompose = true) { + data object ChooseConstraint : NavDestination(isCompose = true) { override val id: String = ID_CHOOSE_CONSTRAINT } @@ -116,38 +124,75 @@ abstract class NavDestination(val isCompose: Boolean = false) { } @Serializable - data object Settings : NavDestination() { + data object Settings : NavDestination(isCompose = true) { override val id: String = ID_SETTINGS } + @Serializable + data object DefaultOptionsSettings : NavDestination(isCompose = true) { + override val id: String = ID_DEFAULT_OPTIONS_SETTINGS + } + + @Serializable + data object AutomaticChangeImeSettings : NavDestination(isCompose = true) { + override val id: String = ID_AUTOMATIC_CHANGE_IME_SETTINGS + } + @Serializable data object About : NavDestination() { override val id: String = ID_ABOUT } @Serializable - data class OpenKeyMap(val keyMapUid: String, val showAdvancedTriggers: Boolean = false) : - NavDestination(isCompose = true) { + data class OpenKeyMap(val keyMapUid: String) : NavDestination(isCompose = true) { override val id: String = ID_CONFIG_KEY_MAP } @Serializable data class NewKeyMap( val groupUid: String?, - val showAdvancedTriggers: Boolean = false, - val floatingButtonToUse: String? = null, + /** + * The trigger shortcut to immediately launch + * when navigating to the screen to create a key map. + */ + val triggerSetupShortcut: TriggerSetupShortcut? = null, ) : NavDestination(isCompose = true) { override val id: String = ID_CONFIG_KEY_MAP } - @Serializable - data object ShizukuSettings : NavDestination() { - override val id: String = ID_SHIZUKU_SETTINGS - } - @Serializable data class InteractUiElement(val actionJson: String?) : NavDestination(isCompose = true) { override val id: String = ID_INTERACT_UI_ELEMENT_ACTION } + + @Serializable + data class ConfigShellCommand(val actionJson: String?) : + NavDestination(isCompose = true) { + override val id: String = ID_SHELL_COMMAND_ACTION + } + + @Serializable + data object ProMode : NavDestination(isCompose = true) { + override val id: String = ID_PRO_MODE + } + + @Serializable + data object ProModeSetup : NavDestination(isCompose = true) { + const val ID_PRO_MODE_SETUP = "pro_mode_setup_wizard" + override val id: String = ID_PRO_MODE_SETUP + } + + @Serializable + data object Log : NavDestination(isCompose = true) { + override val id: String = ID_LOG + } + + /** + * This returns a trigger setup shortcut if an advanced trigger is used. + */ + @Serializable + data object AdvancedTriggers : NavDestination(isCompose = true) { + override val id: String = ID_ADVANCED_TRIGGERS + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigateEvent.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigateEvent.kt index b55f78d6f3..5fe956ded8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigateEvent.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigateEvent.kt @@ -1,3 +1,9 @@ package io.github.sds100.keymapper.base.utils.navigation -data class NavigateEvent(val key: String, val destination: NavDestination<*>) +import androidx.navigation.NavOptions + +data class NavigateEvent( + val key: String, + val destination: NavDestination<*>, + val navOptions: NavOptions? = null, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigationProvider.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigationProvider.kt index 0cea5a666a..e47277917a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigationProvider.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigationProvider.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.utils.navigation +import android.annotation.SuppressLint import android.os.Bundle import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -14,6 +15,7 @@ import androidx.navigation.NavDirections import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.fragment.findNavController +import androidx.savedstate.SavedState import io.github.sds100.keymapper.base.NavBaseAppDirections import io.github.sds100.keymapper.base.actions.keyevent.ChooseKeyCodeFragment import io.github.sds100.keymapper.base.actions.keyevent.ConfigKeyEventActionFragment @@ -27,6 +29,9 @@ import io.github.sds100.keymapper.base.system.apps.ChooseAppShortcutFragment import io.github.sds100.keymapper.base.system.bluetooth.ChooseBluetoothDeviceFragment import io.github.sds100.keymapper.base.system.intents.ConfigIntentFragment import io.github.sds100.keymapper.system.bluetooth.BluetoothDeviceInfo +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -41,9 +46,9 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -import javax.inject.Inject -import javax.inject.Singleton +import timber.log.Timber /** * This class handles communication of navigation requests and results between view models, @@ -86,6 +91,8 @@ class NavigationProviderImpl @Inject constructor() : NavigationProvider { private val _popBackStack = MutableStateFlow(null) val popBackStack: StateFlow = _popBackStack.asStateFlow() + var savedState: SavedState? = null + fun handledPop() { _popBackStack.update { null } } @@ -106,7 +113,11 @@ class NavigationProviderImpl @Inject constructor() : NavigationProvider { // wait for the view to collect so navigating can happen _onNavigate.subscriptionCount.first { it > 0 } - _onNavigate.emit(event) + Timber.d("Navigation: Navigating to ${event.destination} with key ${event.key}") + + withContext(Dispatchers.Main.immediate) { + _onNavigate.emit(event) + } } override fun onNavResult(result: NavResult) { @@ -114,15 +125,18 @@ class NavigationProviderImpl @Inject constructor() : NavigationProvider { } override suspend fun popBackStack() { + Timber.d("Navigation: Popping back stack") _popBackStack.value = Unit } /** - * @param data The data in String or JSON format to return. + * @param jsonData The data in String or JSON format to return. */ - override suspend fun popBackStackWithResult(data: String) { + override suspend fun popBackStackWithResult(jsonData: String) { _onReturnResult.subscriptionCount.first { it > 0 } - _onReturnResult.emit(data) + + Timber.d("Navigation: Popping back stack with result") + _onReturnResult.emit(jsonData) } } @@ -138,7 +152,7 @@ interface NavigationProvider { val onReturnResult: StateFlow fun handledReturnResult() - suspend fun popBackStackWithResult(data: String) + suspend fun popBackStackWithResult(jsonData: String) suspend fun popBackStack() } @@ -163,12 +177,10 @@ suspend inline fun NavigationProvider.navigate( } @Composable -fun SetupNavigation( - navigationProvider: NavigationProviderImpl, - navController: NavHostController, -) { +fun SetupNavigation(navigationProvider: NavigationProviderImpl, navController: NavHostController) { + @SuppressLint("StateFlowValueCalledInComposition") val navEvent: NavigateEvent? by navigationProvider.onNavigate - .collectAsStateWithLifecycle(null) + .collectAsStateWithLifecycle(navigationProvider.onNavigate.value) val returnResult: String? by navigationProvider.onReturnResult .collectAsStateWithLifecycle(null) @@ -225,7 +237,7 @@ fun SetupNavigation( ?.savedStateHandle ?.set("request_key", navEvent.key) - navController.navigate(navEvent.destination) + navController.navigate(navEvent.destination, navOptions = navEvent.navOptions) navigationProvider.handledNavigateRequest() } @@ -351,11 +363,10 @@ private fun getDirection(destination: NavDestination<*>, requestKey: String): Na ) NavDestination.About -> NavBaseAppDirections.actionGlobalAboutFragment() - NavDestination.Settings -> NavBaseAppDirections.toSettingsFragment() - - NavDestination.ShizukuSettings -> NavBaseAppDirections.toShizukuSettingsFragment() - else -> throw IllegalArgumentException("Can not find a direction for this destination: $destination") + else -> throw IllegalArgumentException( + "Can not find a direction for this destination: $destination", + ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/CheckBoxListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/CheckBoxListItem.kt index e14210c6e1..4390ecd9e2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/CheckBoxListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/CheckBoxListItem.kt @@ -1,7 +1,4 @@ package io.github.sds100.keymapper.base.utils.ui -data class CheckBoxListItem( - override val id: String, - val isChecked: Boolean, - val label: String, -) : ListItem +data class CheckBoxListItem(override val id: String, val isChecked: Boolean, val label: String) : + ListItem diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ChooseAppStoreModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ChooseAppStoreModel.kt deleted file mode 100644 index e243c84ba7..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ChooseAppStoreModel.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.sds100.keymapper.base.utils.ui - -data class ChooseAppStoreModel( - val playStoreLink: String? = null, - val fdroidLink: String? = null, - val githubLink: String? = null, -) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/DialogModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/DialogModel.kt index 40c359c0f3..70567190f9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/DialogModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/DialogModel.kt @@ -27,22 +27,12 @@ sealed class DialogModel { val autoCompleteEntries: List = emptyList(), ) : DialogModel() - data class SingleChoice( - val items: List>, - ) : DialogModel() + data class SingleChoice(val items: List>) : DialogModel() data class MultiChoice(val items: List>) : DialogModel>() data class Toast(val text: String) : DialogModel() - data class ChooseAppStore( - val title: CharSequence, - val message: CharSequence, - val model: ChooseAppStoreModel, - val positiveButtonText: CharSequence? = null, - val negativeButtonText: CharSequence? = null, - ) : DialogModel() - data class OpenUrl(val url: String) : DialogModel() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/DialogProvider.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/DialogProvider.kt index 3e65e66f17..1d6d4fa121 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/DialogProvider.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/DialogProvider.kt @@ -1,7 +1,6 @@ package io.github.sds100.keymapper.base.utils.ui import android.content.Context -import android.view.LayoutInflater import android.view.View import android.widget.Toast import androidx.databinding.ViewDataBinding @@ -12,8 +11,9 @@ import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.OnLifecycleEvent import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.databinding.DialogChooseAppStoreBinding import io.github.sds100.keymapper.system.url.UrlUtils +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -24,8 +24,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.runBlocking -import javax.inject.Inject -import javax.inject.Singleton @Singleton class DialogProviderImpl @Inject constructor() : DialogProvider { @@ -60,10 +58,7 @@ fun DialogProvider.onUserResponse(key: String, response: Any?) { onUserResponse(OnDialogResponseEvent(key, response)) } -suspend inline fun DialogProvider.showDialog( - key: String, - ui: DialogModel, -): R? { +suspend inline fun DialogProvider.showDialog(key: String, ui: DialogModel): R? { showDialog(ShowDialogEvent(key, ui)) /* @@ -76,32 +71,19 @@ suspend inline fun DialogProvider.showDialog( ).first() as R? } -fun DialogProvider.showDialogs( - fragment: Fragment, - binding: ViewDataBinding, -) { +fun DialogProvider.showDialogs(fragment: Fragment, binding: ViewDataBinding) { showDialogs(fragment.requireContext(), fragment.viewLifecycleOwner, binding.root) } -fun DialogProvider.showDialogs( - fragment: Fragment, - rootView: View, -) { +fun DialogProvider.showDialogs(fragment: Fragment, rootView: View) { showDialogs(fragment.requireContext(), fragment.viewLifecycleOwner, rootView) } -fun DialogProvider.showDialogs( - activity: FragmentActivity, - rootView: View, -) { +fun DialogProvider.showDialogs(activity: FragmentActivity, rootView: View) { showDialogs(activity, activity, rootView) } -fun DialogProvider.showDialogs( - ctx: Context, - lifecycleOwner: LifecycleOwner, - rootView: View, -) { +fun DialogProvider.showDialogs(ctx: Context, lifecycleOwner: LifecycleOwner, rootView: View) { // must be onCreate because dismissing in onDestroy lifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.CREATED) { showDialog.onEach { event -> @@ -159,21 +141,6 @@ fun DialogProvider.showDialogs( response = Unit } - is DialogModel.ChooseAppStore -> { - val view = DialogChooseAppStoreBinding.inflate(LayoutInflater.from(ctx)).apply { - model = event.ui.model - }.root - - response = ctx.materialAlertDialogCustomView( - lifecycleOwner, - event.ui.title, - event.ui.message, - positiveButtonText = event.ui.positiveButtonText, - negativeButtonText = event.ui.negativeButtonText, - view = view, - ) - } - is DialogModel.OpenUrl -> { UrlUtils.openUrl(ctx, event.ui.url) response = Unit diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/DialogUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/DialogUtils.kt index c3d465875c..43a5005a49 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/DialogUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/DialogUtils.kt @@ -15,6 +15,7 @@ import com.google.android.material.textfield.TextInputLayout import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.databinding.DialogEdittextStringBinding import io.github.sds100.keymapper.common.utils.resumeIfNotCompleted +import kotlin.coroutines.resume import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest @@ -24,39 +25,36 @@ import splitties.alertdialog.appcompat.negativeButton import splitties.alertdialog.appcompat.okButton import splitties.alertdialog.appcompat.title import splitties.alertdialog.material.materialAlertDialog -import kotlin.coroutines.resume -suspend fun Context.materialAlertDialog( - lifecycleOwner: LifecycleOwner, - model: DialogModel.Alert, -) = suspendCancellableCoroutine { continuation -> +suspend fun Context.materialAlertDialog(lifecycleOwner: LifecycleOwner, model: DialogModel.Alert) = + suspendCancellableCoroutine { continuation -> - materialAlertDialog { - title = model.title - setMessage(model.message) + materialAlertDialog { + title = model.title + setMessage(model.message) - setPositiveButton(model.positiveButtonText) { _, _ -> - continuation.resumeIfNotCompleted(DialogResponse.POSITIVE) - } + setPositiveButton(model.positiveButtonText) { _, _ -> + continuation.resumeIfNotCompleted(DialogResponse.POSITIVE) + } - setNeutralButton(model.neutralButtonText) { _, _ -> - continuation.resumeIfNotCompleted(DialogResponse.NEUTRAL) - } + setNeutralButton(model.neutralButtonText) { _, _ -> + continuation.resumeIfNotCompleted(DialogResponse.NEUTRAL) + } - setNegativeButton(model.negativeButtonText) { _, _ -> - continuation.resumeIfNotCompleted(DialogResponse.NEGATIVE) - } + setNegativeButton(model.negativeButtonText) { _, _ -> + continuation.resumeIfNotCompleted(DialogResponse.NEGATIVE) + } - show().apply { - resumeNullOnDismiss(continuation) - dismissOnDestroy(lifecycleOwner) + show().apply { + resumeNullOnDismiss(continuation) + dismissOnDestroy(lifecycleOwner) - continuation.invokeOnCancellation { - dismiss() + continuation.invokeOnCancellation { + dismiss() + } } } } -} suspend fun Context.materialAlertDialogCustomView( lifecycleOwner: LifecycleOwner, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/RecyclerViewFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/RecyclerViewFragment.kt index 937b7b37a2..e06b4143f8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/RecyclerViewFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/RecyclerViewFragment.kt @@ -81,7 +81,11 @@ abstract class RecyclerViewFragment : Fragment() { ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> val insets = - insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or WindowInsetsCompat.Type.ime()) + insets.getInsets( + WindowInsetsCompat.Type.systemBars() or + WindowInsetsCompat.Type.displayCutout() or + WindowInsetsCompat.Type.ime(), + ) v.updatePadding(insets.left, insets.top, insets.right, insets.bottom) WindowInsetsCompat.CONSUMED } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceExt.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceExt.kt index 59789c69af..0168c4f0a9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceExt.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceExt.kt @@ -24,11 +24,14 @@ import com.google.android.material.color.MaterialColors // Using varargs doesn't work since prints [LJava.lang.object@32f...etc fun Context.str(@StringRes resId: Int, formatArg: Any? = null): String = getString(resId, formatArg) -fun Context.str(@StringRes resId: Int, formatArgArray: Array): String = getString(resId, *formatArgArray) +fun Context.str(@StringRes resId: Int, formatArgArray: Array): String = + getString(resId, *formatArgArray) -fun View.str(@StringRes resId: Int, formatArgs: Any? = null): String = context.str(resId, formatArgs) +fun View.str(@StringRes resId: Int, formatArgs: Any? = null): String = + context.str(resId, formatArgs) -fun Fragment.str(@StringRes resId: Int, formatArgs: Any? = null): String = requireContext().str(resId, formatArgs) +fun Fragment.str(@StringRes resId: Int, formatArgs: Any? = null): String = + requireContext().str(resId, formatArgs) fun Context.strArray(@ArrayRes resId: Int): Array = resources.getStringArray(resId) fun Fragment.strArray(@ArrayRes resId: Int): Array = requireContext().strArray(resId) @@ -116,7 +119,8 @@ fun View.str( /** * Get a resource drawable. Can be safely used to get vector drawables on pre-lollipop. */ -fun Context.drawable(@DrawableRes resId: Int): Drawable = AppCompatResources.getDrawable(this, resId)!! +fun Context.drawable(@DrawableRes resId: Int): Drawable = + AppCompatResources.getDrawable(this, resId)!! fun View.drawable(@DrawableRes resId: Int): Drawable = context.drawable(resId) fun Fragment.drawable(@DrawableRes resId: Int): Drawable = requireContext().drawable(resId) @@ -131,9 +135,11 @@ fun Context.color(@ColorRes resId: Int, harmonize: Boolean = false): Int { } } -fun View.color(@ColorRes resId: Int, harmonize: Boolean = false): Int = context.color(resId, harmonize) +fun View.color(@ColorRes resId: Int, harmonize: Boolean = false): Int = + context.color(resId, harmonize) -fun Fragment.color(@ColorRes resId: Int, harmonize: Boolean = false): Int = requireContext().color(resId, harmonize) +fun Fragment.color(@ColorRes resId: Int, harmonize: Boolean = false): Int = + requireContext().color(resId, harmonize) @ColorInt fun Context.styledColor(@AttrRes attr: Int) = withStyledAttributes(attr) { getColor(it, 0) } @@ -153,12 +159,15 @@ fun Fragment.int(@IntegerRes resId: Int) = requireContext().int(resId) fun Context.intArray(@ArrayRes resId: Int): IntArray = resources.getIntArray(resId) fun Fragment.intArray(@ArrayRes resId: Int): IntArray = resources.getIntArray(resId) -fun Context.styledColorSL(@AttrRes attr: Int): ColorStateList? = withStyledAttributes(attr) { getColorStateList(it) } +fun Context.styledColorSL(@AttrRes attr: Int): ColorStateList? = withStyledAttributes(attr) { + getColorStateList(it) +} fun Fragment.styledColorSL(@AttrRes attr: Int) = context!!.styledColorSL(attr) fun View.styledColorSL(@AttrRes attr: Int) = context.styledColorSL(attr) -fun Context.colorSl(@ColorRes color: Int): ColorStateList? = ContextCompat.getColorStateList(this, color) +fun Context.colorSl(@ColorRes color: Int): ColorStateList? = + ContextCompat.getColorStateList(this, color) fun View.colorSl(@ColorRes color: Int) = context.colorSl(color) @@ -172,12 +181,13 @@ inline fun Context.withStyledAttributes( styledAttrs.func(styledAttrs.getIndex(0)).also { styledAttrs.recycle() } } -fun Context.obtainStyledAttr(@AttrRes attrRes: Int): TypedArray = if (Looper.getMainLooper().isCurrentThread) { - uiThreadConfinedCachedAttrArray[0] = attrRes - obtainStyledAttributes(uiThreadConfinedCachedAttrArray) -} else { - synchronized(cachedAttrArray) { - cachedAttrArray[0] = attrRes - obtainStyledAttributes(cachedAttrArray) +fun Context.obtainStyledAttr(@AttrRes attrRes: Int): TypedArray = + if (Looper.getMainLooper().isCurrentThread) { + uiThreadConfinedCachedAttrArray[0] = attrRes + obtainStyledAttributes(uiThreadConfinedCachedAttrArray) + } else { + synchronized(cachedAttrArray) { + cachedAttrArray[0] = attrRes + obtainStyledAttributes(cachedAttrArray) + } } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceProvider.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceProvider.kt index ca41420107..6c24de3d8d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceProvider.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceProvider.kt @@ -6,23 +6,16 @@ import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.StringRes import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @Singleton -class ResourceProviderImpl @Inject constructor( - @ApplicationContext context: Context, - private val coroutineScope: CoroutineScope, -) : ResourceProvider { +class ResourceProviderImpl @Inject constructor(@ApplicationContext context: Context) : + ResourceProvider { private val ctx = context.applicationContext - override val onThemeChange = MutableSharedFlow() - - override fun getString(resId: Int, args: Array): String = ctx.str(resId, formatArgArray = args) + override fun getString(resId: Int, args: Array): String = + ctx.str(resId, formatArgArray = args) override fun getText(resId: Int): CharSequence = ctx.getText(resId) @@ -33,17 +26,9 @@ class ResourceProviderImpl @Inject constructor( override fun getDrawable(resId: Int): Drawable = ctx.drawable(resId) override fun getColor(color: Int): Int = ctx.color(color) - - fun onThemeChange() { - coroutineScope.launch { - onThemeChange.emit(Unit) - } - } } interface ResourceProvider { - val onThemeChange: Flow - fun getString(@StringRes resId: Int, args: Array): String fun getString(@StringRes resId: Int, arg: Any): String fun getString(@StringRes resId: Int): String diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SimpleRecyclerViewFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SimpleRecyclerViewFragment.kt index 263f6e8d96..0287650447 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SimpleRecyclerViewFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SimpleRecyclerViewFragment.kt @@ -8,7 +8,8 @@ import com.google.android.material.bottomappbar.BottomAppBar import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.databinding.FragmentSimpleRecyclerviewBinding -abstract class SimpleRecyclerViewFragment : RecyclerViewFragment() { +abstract class SimpleRecyclerViewFragment : + RecyclerViewFragment() { @MenuRes open val appBarMenu: Int = R.menu.menu_recyclerview_fragment diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SnackBarUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SnackBarUtils.kt index f529e2b017..15aa8e1317 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SnackBarUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SnackBarUtils.kt @@ -3,8 +3,8 @@ package io.github.sds100.keymapper.base.utils.ui import androidx.coordinatorlayout.widget.CoordinatorLayout import com.google.android.material.snackbar.Snackbar import io.github.sds100.keymapper.base.R -import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine object SnackBarUtils { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SquareImageButton.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SquareImageButton.kt index 6a87f6ca99..f78b652837 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SquareImageButton.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SquareImageButton.kt @@ -4,7 +4,8 @@ import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageButton -class SquareImageButton(context: Context, attrs: AttributeSet?) : AppCompatImageButton(context, attrs) { +class SquareImageButton(context: Context, attrs: AttributeSet?) : + AppCompatImageButton(context, attrs) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { // have equal sides. super.onMeasure(heightMeasureSpec, heightMeasureSpec) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/UnsavedChangesDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/UnsavedChangesDialog.kt index 8342f5b818..b11c34673b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/UnsavedChangesDialog.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/UnsavedChangesDialog.kt @@ -10,16 +10,15 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme @Composable -fun UnsavedChangesDialog( - onDismiss: () -> Unit, - onDiscardClick: () -> Unit, -) { +fun UnsavedChangesDialog(onDismiss: () -> Unit, onDiscardClick: () -> Unit) { AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.dialog_title_unsaved_changes)) }, text = { Text(stringResource(R.string.dialog_message_unsaved_changes)) }, confirmButton = { - TextButton(onClick = onDiscardClick) { Text(stringResource(R.string.pos_discard_changes)) } + TextButton(onClick = onDiscardClick) { + Text(stringResource(R.string.pos_discard_changes)) + } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.neg_keep_editing)) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ViewModelHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ViewModelHelper.kt index 117e4364cb..cbb148c6fc 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ViewModelHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ViewModelHelper.kt @@ -17,8 +17,12 @@ object ViewModelHelper { val dialog = DialogModel.Alert( title = resourceProvider.getString(R.string.dialog_title_key_mapper_crashed), message = resourceProvider.getText(R.string.dialog_message_key_mapper_crashed), - positiveButtonText = resourceProvider.getString(R.string.dialog_button_read_dont_kill_my_app_yes), - negativeButtonText = resourceProvider.getString(R.string.dialog_button_read_dont_kill_my_app_no), + positiveButtonText = resourceProvider.getString( + R.string.dialog_button_read_dont_kill_my_app_yes, + ), + negativeButtonText = resourceProvider.getString( + R.string.dialog_button_read_dont_kill_my_app_no, + ), neutralButtonText = resourceProvider.getString(R.string.pos_restart), ) @@ -42,8 +46,12 @@ object ViewModelHelper { dialogProvider: DialogProvider, ): DialogResponse { val dialog = DialogModel.Alert( - title = resourceProvider.getString(R.string.dialog_title_accessibility_service_explanation), - message = resourceProvider.getString(R.string.dialog_message_accessibility_service_explanation), + title = resourceProvider.getString( + R.string.dialog_title_accessibility_service_explanation, + ), + message = resourceProvider.getString( + R.string.dialog_message_accessibility_service_explanation, + ), positiveButtonText = resourceProvider.getString(R.string.enable), negativeButtonText = resourceProvider.getString(R.string.neg_cancel), ) @@ -60,9 +68,15 @@ object ViewModelHelper { dialogProvider: DialogProvider, ) { val dialog = DialogModel.Alert( - title = resourceProvider.getString(R.string.dialog_title_cant_find_accessibility_settings_page), - message = resourceProvider.getText(R.string.dialog_message_cant_find_accessibility_settings_page), - positiveButtonText = resourceProvider.getString(R.string.pos_start_service_with_adb_guide), + title = resourceProvider.getString( + R.string.dialog_title_cant_find_accessibility_settings_page, + ), + message = resourceProvider.getText( + R.string.dialog_message_cant_find_accessibility_settings_page, + ), + positiveButtonText = resourceProvider.getString( + R.string.pos_start_service_with_adb_guide, + ), negativeButtonText = resourceProvider.getString(R.string.neg_cancel), ) @@ -101,8 +115,12 @@ object ViewModelHelper { restartService: () -> Boolean, ) { val dialog = DialogModel.Alert( - title = resourceProvider.getString(R.string.dialog_title_accessibility_service_explanation), - message = resourceProvider.getString(R.string.dialog_message_restart_accessibility_service), + title = resourceProvider.getString( + R.string.dialog_title_accessibility_service_explanation, + ), + message = resourceProvider.getString( + R.string.dialog_message_restart_accessibility_service, + ), positiveButtonText = resourceProvider.getString(R.string.pos_restart), negativeButtonText = resourceProvider.getString(R.string.neg_cancel), ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CheckBoxText.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CheckBoxText.kt index cbe29ef5af..71aefc47ce 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CheckBoxText.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CheckBoxText.kt @@ -43,7 +43,11 @@ fun CheckBoxText( style = if (isEnabled) { MaterialTheme.typography.bodyLarge } else { - MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.surfaceVariant) + MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.5f, + ), + ) }, maxLines = 2, overflow = TextOverflow.Ellipsis, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CollapsableFloatingActionButton.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CollapsableFloatingActionButton.kt index 706c2299b8..8c6334a3ef 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CollapsableFloatingActionButton.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CollapsableFloatingActionButton.kt @@ -7,11 +7,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @Composable @@ -20,10 +22,12 @@ fun CollapsableFloatingActionButton( onClick: () -> Unit = {}, text: String, showText: Boolean, + containerColor: Color = FloatingActionButtonDefaults.containerColor, ) { FloatingActionButton( modifier = modifier, onClick = onClick, + containerColor = containerColor, ) { Row( modifier = Modifier.padding(horizontal = 16.dp), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt index 226b93841f..8485de387f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import io.github.sds100.keymapper.base.keymaps.chipHeight +import io.github.sds100.keymapper.base.home.chipHeight @Composable fun CompactChip( @@ -58,11 +58,7 @@ fun CompactChip( } @Composable -fun ErrorCompactChip( - onClick: () -> Unit, - text: String, - enabled: Boolean, -) { +fun ErrorCompactChip(onClick: () -> Unit, text: String, enabled: Boolean) { CompactChip( text = text, icon = { @@ -80,11 +76,7 @@ fun ErrorCompactChip( } @Composable -private fun CompactChipContent( - icon: @Composable (() -> Unit)?, - text: String, - contentColor: Color, -) { +private fun CompactChipContent(icon: @Composable (() -> Unit)?, text: String, contentColor: Color) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeButtonExt.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeButtonExt.kt new file mode 100644 index 0000000000..f07cb2545f --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeButtonExt.kt @@ -0,0 +1,14 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable + +@Composable +fun ButtonDefaults.filledTonalButtonColorsError(): ButtonColors { + return ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt index 391aa53804..bece2124d7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt @@ -162,7 +162,9 @@ class DragDropState internal constructor( null } - if (draggingItem.index < itemCount - ignoreLastItems && targetItem.index < itemCount - ignoreLastItems) { + if (draggingItem.index < itemCount - ignoreLastItems && + targetItem.index < itemCount - ignoreLastItems + ) { if (scrollToIndex != null) { scope.launch { // this is needed to neutralize automatic keeping the first item first. @@ -196,14 +198,15 @@ class DragDropState internal constructor( get() = this.offset + this.size } -fun Modifier.dragContainer(dragDropState: DragDropState): Modifier = this.pointerInput(dragDropState) { - detectDragGesturesAfterLongPress( - onDrag = { change, offset -> - change.consume() - dragDropState.onDrag(offset = offset) - }, - onDragStart = { offset -> dragDropState.onDragStart(offset) }, - onDragEnd = { dragDropState.onDragInterrupted() }, - onDragCancel = { dragDropState.onDragInterrupted() }, - ) -} +fun Modifier.dragContainer(dragDropState: DragDropState): Modifier = + this.pointerInput(dragDropState) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + dragDropState.onDrag(offset = offset) + }, + onDragStart = { offset -> dragDropState.onDragStart(offset) }, + onDragEnd = { dragDropState.onDragInterrupted() }, + onDragCancel = { dragDropState.onDragInterrupted() }, + ) + } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/HeaderText.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/HeaderText.kt new file mode 100644 index 0000000000..d2e6020c04 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/HeaderText.kt @@ -0,0 +1,16 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun HeaderText(modifier: Modifier = Modifier, text: String) { + Text( + modifier = modifier, + text = text, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) +} 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 new file mode 100644 index 0000000000..f250d0aa14 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt @@ -0,0 +1,105 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +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.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * A reusable segmented button row that follows KeyMapper's design patterns. + * + * @param modifier The modifier to apply to the segmented button row + * @param buttonStates List of pairs containing the data and display text for each button + * @param selectedState The currently selected state + * @param onStateSelected Callback when a button is selected + * @param isCompact Whether to use compact styling (smaller shapes, auto-sizing text) + * @param isEnabled Whether the buttons are enabled + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KeyMapperSegmentedButtonRow( + modifier: Modifier = Modifier, + buttonStates: List>, + selectedState: T?, + onStateSelected: (T) -> Unit, + isCompact: Boolean = false, + isEnabled: Boolean = true, +) { + val colors = if (isEnabled) { + SegmentedButtonDefaults.colors() + } else { + // The disabled border color of the inactive button is by default not greyed out enough + SegmentedButtonDefaults.colors( + disabledInactiveBorderColor = + SegmentedButtonDefaults.colors().inactiveBorderColor.copy(alpha = 0.5f), + ) + } + + SingleChoiceSegmentedButtonRow( + modifier = modifier, + ) { + for (content in buttonStates) { + val (state, label) = content + val isSelected = state == selectedState + val isDisabled = !isEnabled + val isUnselectedDisabled = isDisabled && !isSelected + + if (isCompact) { + SegmentedButton( + modifier = Modifier.height(36.dp), + selected = isSelected, + onClick = { onStateSelected(state) }, + enabled = isEnabled, + icon = { }, + shape = SegmentedButtonDefaults.itemShape( + index = buttonStates.indexOf(content), + count = buttonStates.size, + baseShape = MaterialTheme.shapes.extraSmall, + ), + colors = colors, + ) { + BasicText( + modifier = if (isUnselectedDisabled) Modifier.alpha(0.5f) else Modifier, + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + autoSize = TextAutoSize.StepBased( + maxFontSize = LocalTextStyle.current.fontSize, + minFontSize = 10.sp, + ), + ) + } + } else { + SegmentedButton( + selected = isSelected, + onClick = { onStateSelected(state) }, + enabled = isEnabled, + shape = SegmentedButtonDefaults.itemShape( + index = buttonStates.indexOf(content), + count = buttonStates.size, + ), + colors = colors, + ) { + Text( + modifier = if (isUnselectedDisabled) Modifier.alpha(0.5f) else Modifier, + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperTapTarget.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperTapTarget.kt deleted file mode 100644 index 5169ea85ad..0000000000 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperTapTarget.kt +++ /dev/null @@ -1,64 +0,0 @@ -package io.github.sds100.keymapper.base.utils.ui.compose - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.canopas.lib.showcase.component.ShowcaseStyle -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.onboarding.OnboardingTapTarget - -@Composable -fun KeyMapperTapTarget( - tapTarget: OnboardingTapTarget, - showSkipButton: Boolean = true, - onSkipClick: () -> Unit = {}, -) { - val textColor = MaterialTheme.colorScheme.onPrimary - Column { - Text( - text = stringResource(tapTarget.titleRes), - color = textColor, - style = MaterialTheme.typography.titleLarge, - ) - - Spacer(Modifier.height(16.dp)) - - Text( - text = stringResource(tapTarget.messageRes), - color = textColor, - style = MaterialTheme.typography.bodyMedium, - ) - - if (showSkipButton) { - Spacer(Modifier.height(16.dp)) - OutlinedButton( - onClick = onSkipClick, - border = BorderStroke( - width = 1.dp, - color = textColor, - ), - ) { - Text( - text = stringResource(R.string.tap_target_skip_tutorial_button), - color = textColor, - ) - } - } - } -} - -@Composable -fun keyMapperShowcaseStyle(): ShowcaseStyle { - return ShowcaseStyle( - backgroundColor = MaterialTheme.colorScheme.primary, - backgroundAlpha = 0.99f, - ) -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/OptionButton.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/OptionButton.kt new file mode 100644 index 0000000000..835108fffd --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/OptionButton.kt @@ -0,0 +1,49 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +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 + +@Composable +fun OptionButton(modifier: Modifier = Modifier, title: String, text: String, onClick: () -> Unit) { + Surface(modifier = modifier, onClick = onClick, shape = MaterialTheme.shapes.medium) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + OptionButton( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.title_pref_pro_mode), + text = stringResource(R.string.summary_pref_pro_mode), + onClick = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/OptionPageButton.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/OptionPageButton.kt new file mode 100644 index 0000000000..f85de7a9d3 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/OptionPageButton.kt @@ -0,0 +1,93 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +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.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.utils.ui.compose.icons.ProModeIcon + +@Composable +fun OptionPageButton( + modifier: Modifier = Modifier, + title: String, + text: String, + icon: ImageVector? = null, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Surface( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.medium, + enabled = enabled, + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .alpha(if (enabled) 1f else 0.38f), + ) { + if (icon != null) { + Icon( + modifier = Modifier + .align(Alignment.CenterVertically) + .size(24.dp), + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.width(16.dp)) + } + + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Icon( + modifier = Modifier.align(Alignment.CenterVertically), + imageVector = Icons.Rounded.ChevronRight, + contentDescription = null, + ) + } + } +} + +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + OptionPageButton( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.title_pref_pro_mode), + text = stringResource(R.string.summary_pref_pro_mode), + icon = KeyMapperIcons.ProModeIcon, + onClick = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SetupRequirementRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SetupRequirementRow.kt new file mode 100644 index 0000000000..f65b906060 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SetupRequirementRow.kt @@ -0,0 +1,168 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.utils.ProModeStatus + +@Composable +fun ProModeRequirementRow( + modifier: Modifier = Modifier, + isVisible: Boolean, + proModeStatus: ProModeStatus, + buttonColors: ButtonColors = ButtonDefaults.filledTonalButtonColors(), + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(animationSpec = tween(200)), + exit = fadeOut(animationSpec = tween(200)) + shrinkVertically(animationSpec = tween(200)), + ) { + SetupRequirementRow( + modifier = modifier, + text = stringResource(R.string.trigger_setup_pro_mode_title), + ) { + if (proModeStatus == ProModeStatus.UNSUPPORTED) { + Text( + text = stringResource(R.string.trigger_setup_pro_mode_unsupported), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } else { + SetupRequirementButton( + enabledText = stringResource(R.string.trigger_setup_pro_mode_enable_button), + disabledText = stringResource(R.string.trigger_setup_pro_mode_running_button), + isEnabled = proModeStatus != ProModeStatus.ENABLED, + colors = buttonColors, + onClick = onClick, + ) + } + } + } +} + +@Composable +fun AccessibilityServiceRequirementRow( + modifier: Modifier = Modifier, + isServiceEnabled: Boolean, + buttonColors: ButtonColors = ButtonDefaults.filledTonalButtonColors(), + onClick: () -> Unit, +) { + SetupRequirementRow( + modifier = modifier, + text = stringResource(R.string.trigger_setup_accessibility_service_title), + ) { + SetupRequirementButton( + enabledText = stringResource( + R.string.trigger_setup_accessibility_service_enable_button, + ), + disabledText = stringResource( + R.string.trigger_setup_accessibility_service_running_button, + ), + isEnabled = !isServiceEnabled, + colors = buttonColors, + onClick = onClick, + ) + } +} + +@Composable +fun InputMethodRequirementRow( + modifier: Modifier = Modifier, + isEnabled: Boolean, + isChosen: Boolean, + enablingRequiresUserInput: Boolean, + buttonColors: ButtonColors = ButtonDefaults.filledTonalButtonColors(), + onEnableClick: () -> Unit, + onChooseClick: () -> Unit, +) { + SetupRequirementRow( + modifier = modifier, + text = stringResource(R.string.trigger_setup_input_method_title), + ) { + val enabledText = when { + !isEnabled && enablingRequiresUserInput -> stringResource( + R.string.trigger_setup_input_method_enable_button, + ) + !isChosen -> stringResource(R.string.trigger_setup_input_method_choose_button) + else -> "" + } + + val disabledText = stringResource(R.string.trigger_setup_input_method_running_button) + + SetupRequirementButton( + enabledText = enabledText, + disabledText = disabledText, + isEnabled = !isEnabled || !isChosen, + colors = buttonColors, + onClick = if (!isEnabled && enablingRequiresUserInput) onEnableClick else onChooseClick, + ) + } +} + +@Composable +fun SetupRequirementRow( + modifier: Modifier = Modifier, + text: String, + actionContent: @Composable () -> Unit, +) { + Row(modifier = modifier, verticalAlignment = Alignment.Companion.CenterVertically) { + Spacer(Modifier.width(8.dp)) + + Text( + modifier = Modifier.weight(1f), + text = text, + style = MaterialTheme.typography.bodyLarge, + ) + + Spacer(Modifier.width(8.dp)) + + actionContent() + } +} + +@Composable +fun SetupRequirementButton( + modifier: Modifier = Modifier, + enabledText: String, + disabledText: String, + isEnabled: Boolean, + colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(), + onClick: () -> Unit, +) { + FilledTonalButton( + modifier = modifier, + onClick = onClick, + enabled = isEnabled, + colors = colors, + ) { + if (isEnabled) { + Text(text = enabledText) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Check, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = disabledText) + } + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SimpleListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SimpleListItem.kt index 91e4a622b5..1b7d92f3f2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SimpleListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SimpleListItem.kt @@ -36,10 +36,7 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.drawable @Composable -fun SimpleListItemHeader( - modifier: Modifier = Modifier, - text: String, -) { +fun SimpleListItemHeader(modifier: Modifier = Modifier, text: String) { Surface { Text( modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -125,7 +122,11 @@ fun SimpleListItem( if (model.subtitle != null) { Text( text = model.subtitle, - color = if (model.isSubtitleError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + color = if (model.isSubtitleError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -198,7 +199,9 @@ private fun PreviewDrawable() { model = SimpleListItemModel( "app", title = "Key Mapper", - icon = ComposeIconInfo.Drawable(LocalContext.current.drawable(R.mipmap.ic_launcher_round)), + icon = ComposeIconInfo.Drawable( + LocalContext.current.drawable(R.mipmap.ic_launcher_round), + ), subtitle = null, isSubtitleError = true, ), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt index 303b1299f0..7c2c6772f5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt @@ -96,7 +96,9 @@ fun SliderOptionText( thumb = { state -> KeyMapperSliderThumb(interactionSource) }, - steps = (((valueRange.endInclusive - valueRange.start) / stepSize.toFloat()).toInt()) - 1, + steps = + (((valueRange.endInclusive - valueRange.start) / stepSize.toFloat()).toInt()) - + 1, ) Spacer(modifier = Modifier.width(8.dp)) @@ -115,7 +117,9 @@ fun SliderOptionText( IconButton(onClick = { onValueChange(defaultValue) }) { Icon( Icons.Rounded.RestartAlt, - contentDescription = stringResource(R.string.slider_reset_content_description), + contentDescription = stringResource( + R.string.slider_reset_content_description, + ), ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt new file mode 100644 index 0000000000..1060a84f53 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt @@ -0,0 +1,65 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@Composable +fun SwitchPreferenceCompose( + modifier: Modifier = Modifier, + title: String, + text: String?, + icon: ImageVector, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.medium, + onClick = { + onCheckedChange(!isChecked) + }, + ) { + Row( + modifier = Modifier.Companion + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + modifier = Modifier.align(Alignment.CenterVertically), + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + + Column(modifier = Modifier.Companion.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + if (text != null) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Switch( + checked = isChecked, + onCheckedChange = onCheckedChange, + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/ActionKey.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/ActionKey.kt new file mode 100644 index 0000000000..2afcddf7de --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/ActionKey.kt @@ -0,0 +1,120 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.ActionKey: ImageVector + get() { + if (_ActionKey != null) { + return _ActionKey!! + } + _ActionKey = ImageVector.Builder( + name = "ActionKey", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(864f, 920f) + lineTo(741f, 798f) + quadToRelative(-18f, 11f, -38.5f, 16.5f) + reflectiveQuadTo(660f, 820f) + quadToRelative(-66f, 0f, -113f, -47f) + reflectiveQuadToRelative(-47f, -113f) + quadToRelative(0f, -66f, 47f, -113f) + reflectiveQuadToRelative(113f, -47f) + quadToRelative(66f, 0f, 113f, 47f) + reflectiveQuadToRelative(47f, 113f) + quadToRelative(0f, 23f, -6f, 43.5f) + reflectiveQuadTo(797f, 742f) + lineTo(920f, 864f) + lineToRelative(-56f, 56f) + close() + moveTo(220f, 820f) + quadToRelative(-66f, 0f, -113f, -47f) + reflectiveQuadTo(60f, 660f) + quadToRelative(0f, -66f, 47f, -113f) + reflectiveQuadToRelative(113f, -47f) + quadToRelative(66f, 0f, 113f, 47f) + reflectiveQuadToRelative(47f, 113f) + quadToRelative(0f, 66f, -47f, 113f) + reflectiveQuadToRelative(-113f, 47f) + close() + moveTo(220f, 740f) + quadToRelative(33f, 0f, 56.5f, -23.5f) + reflectiveQuadTo(300f, 660f) + quadToRelative(0f, -33f, -23.5f, -56.5f) + reflectiveQuadTo(220f, 580f) + quadToRelative(-33f, 0f, -56.5f, 23.5f) + reflectiveQuadTo(140f, 660f) + quadToRelative(0f, 33f, 23.5f, 56.5f) + reflectiveQuadTo(220f, 740f) + close() + moveTo(660f, 740f) + quadToRelative(33f, 0f, 56.5f, -23.5f) + reflectiveQuadTo(740f, 660f) + quadToRelative(0f, -33f, -23.5f, -56.5f) + reflectiveQuadTo(660f, 580f) + quadToRelative(-33f, 0f, -56.5f, 23.5f) + reflectiveQuadTo(580f, 660f) + quadToRelative(0f, 33f, 23.5f, 56.5f) + reflectiveQuadTo(660f, 740f) + close() + moveTo(220f, 380f) + quadToRelative(-66f, 0f, -113f, -47f) + reflectiveQuadTo(60f, 220f) + quadToRelative(0f, -66f, 47f, -113f) + reflectiveQuadToRelative(113f, -47f) + quadToRelative(66f, 0f, 113f, 47f) + reflectiveQuadToRelative(47f, 113f) + quadToRelative(0f, 66f, -47f, 113f) + reflectiveQuadToRelative(-113f, 47f) + close() + moveTo(660f, 380f) + quadToRelative(-66f, 0f, -113f, -47f) + reflectiveQuadToRelative(-47f, -113f) + quadToRelative(0f, -66f, 47f, -113f) + reflectiveQuadToRelative(113f, -47f) + quadToRelative(66f, 0f, 113f, 47f) + reflectiveQuadToRelative(47f, 113f) + quadToRelative(0f, 66f, -47f, 113f) + reflectiveQuadToRelative(-113f, 47f) + close() + moveTo(220f, 300f) + quadToRelative(33f, 0f, 56.5f, -23.5f) + reflectiveQuadTo(300f, 220f) + quadToRelative(0f, -33f, -23.5f, -56.5f) + reflectiveQuadTo(220f, 140f) + quadToRelative(-33f, 0f, -56.5f, 23.5f) + reflectiveQuadTo(140f, 220f) + quadToRelative(0f, 33f, 23.5f, 56.5f) + reflectiveQuadTo(220f, 300f) + close() + moveTo(660f, 300f) + quadToRelative(33f, 0f, 56.5f, -23.5f) + reflectiveQuadTo(740f, 220f) + quadToRelative(0f, -33f, -23.5f, -56.5f) + reflectiveQuadTo(660f, 140f) + quadToRelative(-33f, 0f, -56.5f, 23.5f) + reflectiveQuadTo(580f, 220f) + quadToRelative(0f, 33f, 23.5f, 56.5f) + reflectiveQuadTo(660f, 300f) + close() + moveTo(220f, 660f) + close() + moveTo(220f, 220f) + close() + moveTo(660f, 220f) + close() + } + }.build() + + return _ActionKey!! + } + +@Suppress("ObjectPropertyName") +private var _ActionKey: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/FakeShizuku.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/FakeShizuku.kt new file mode 100644 index 0000000000..3fbf8d0150 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/FakeShizuku.kt @@ -0,0 +1,45 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.FakeShizuku: ImageVector + get() { + if (_FakeShizuku != null) { + return _FakeShizuku!! + } + _FakeShizuku = ImageVector.Builder( + name = "FakeShizuku", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path(fill = SolidColor(Color(0xFF4053B9))) { + moveTo(12f, 24f) + curveTo(18.627f, 24f, 24f, 18.627f, 24f, 12f) + curveTo(24f, 5.373f, 18.627f, 0f, 12f, 0f) + curveTo(5.373f, 0f, 0f, 5.373f, 0f, 12f) + curveTo(0f, 18.627f, 5.373f, 24f, 12f, 24f) + close() + } + path(fill = SolidColor(Color(0xFF717DC0))) { + moveTo(15.384f, 17.771f) + lineTo(8.74f, 17.752f) + lineTo(5.434f, 11.989f) + lineTo(8.772f, 6.244f) + lineTo(15.416f, 6.263f) + lineTo(18.722f, 12.026f) + lineTo(15.384f, 17.771f) + close() + } + }.build() + + return _FakeShizuku!! + } + +@Suppress("ObjectPropertyName") +private var _FakeShizuku: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/FolderManaged.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/FolderManaged.kt new file mode 100644 index 0000000000..84e2a94dea --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/FolderManaged.kt @@ -0,0 +1,131 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.FolderManaged: ImageVector + get() { + if (_FolderManaged != null) { + return _FolderManaged!! + } + _FolderManaged = ImageVector.Builder( + name = "FolderManaged", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(720f, 760f) + quadToRelative(33f, 0f, 56.5f, -23.5f) + reflectiveQuadTo(800f, 680f) + quadToRelative(0f, -33f, -23.5f, -56.5f) + reflectiveQuadTo(720f, 600f) + quadToRelative(-33f, 0f, -56.5f, 23.5f) + reflectiveQuadTo(640f, 680f) + quadToRelative(0f, 33f, 23.5f, 56.5f) + reflectiveQuadTo(720f, 760f) + close() + moveTo(712f, 880f) + quadToRelative(-14f, 0f, -24.5f, -9f) + reflectiveQuadTo(674f, 848f) + lineToRelative(-6f, -28f) + quadToRelative(-12f, -5f, -22.5f, -10.5f) + reflectiveQuadTo(624f, 796f) + lineToRelative(-29f, 9f) + quadToRelative(-13f, 4f, -25.5f, -1f) + reflectiveQuadTo(550f, 788f) + lineToRelative(-8f, -14f) + quadToRelative(-7f, -12f, -5f, -26f) + reflectiveQuadToRelative(13f, -23f) + lineToRelative(22f, -19f) + quadToRelative(-2f, -12f, -2f, -26f) + reflectiveQuadToRelative(2f, -26f) + lineToRelative(-22f, -19f) + quadToRelative(-11f, -9f, -13f, -22.5f) + reflectiveQuadToRelative(5f, -25.5f) + lineToRelative(9f, -15f) + quadToRelative(7f, -11f, 19f, -16f) + reflectiveQuadToRelative(25f, -1f) + lineToRelative(29f, 9f) + quadToRelative(11f, -8f, 21.5f, -13.5f) + reflectiveQuadTo(668f, 540f) + lineToRelative(6f, -29f) + quadToRelative(3f, -14f, 13.5f, -22.5f) + reflectiveQuadTo(712f, 480f) + horizontalLineToRelative(16f) + quadToRelative(14f, 0f, 24.5f, 9f) + reflectiveQuadToRelative(13.5f, 23f) + lineToRelative(6f, 28f) + quadToRelative(12f, 5f, 22.5f, 10.5f) + reflectiveQuadTo(816f, 564f) + lineToRelative(29f, -9f) + quadToRelative(13f, -4f, 25.5f, 1f) + reflectiveQuadToRelative(19.5f, 16f) + lineToRelative(8f, 14f) + quadToRelative(7f, 12f, 5f, 26f) + reflectiveQuadToRelative(-13f, 23f) + lineToRelative(-22f, 19f) + quadToRelative(2f, 12f, 2f, 26f) + reflectiveQuadToRelative(-2f, 26f) + lineToRelative(22f, 19f) + quadToRelative(11f, 9f, 13f, 22.5f) + reflectiveQuadToRelative(-5f, 25.5f) + lineToRelative(-9f, 15f) + quadToRelative(-7f, 11f, -19f, 16f) + reflectiveQuadToRelative(-25f, 1f) + lineToRelative(-29f, -9f) + quadToRelative(-11f, 8f, -21.5f, 13.5f) + reflectiveQuadTo(772f, 820f) + lineToRelative(-6f, 29f) + quadToRelative(-3f, 14f, -13.5f, 22.5f) + reflectiveQuadTo(728f, 880f) + horizontalLineToRelative(-16f) + close() + moveTo(160f, 720f) + verticalLineToRelative(-480f) + verticalLineToRelative(172f) + verticalLineToRelative(-12f) + verticalLineToRelative(320f) + close() + moveTo(160f, 800f) + quadToRelative(-33f, 0f, -56.5f, -23.5f) + reflectiveQuadTo(80f, 720f) + verticalLineToRelative(-480f) + quadToRelative(0f, -33f, 23.5f, -56.5f) + reflectiveQuadTo(160f, 160f) + horizontalLineToRelative(207f) + quadToRelative(16f, 0f, 30.5f, 6f) + reflectiveQuadToRelative(25.5f, 17f) + lineToRelative(57f, 57f) + horizontalLineToRelative(320f) + quadToRelative(33f, 0f, 56.5f, 23.5f) + reflectiveQuadTo(880f, 320f) + verticalLineToRelative(80f) + quadToRelative(0f, 17f, -11.5f, 28.5f) + reflectiveQuadTo(840f, 440f) + quadToRelative(-17f, 0f, -28.5f, -11.5f) + reflectiveQuadTo(800f, 400f) + verticalLineToRelative(-80f) + lineTo(447f, 320f) + lineToRelative(-80f, -80f) + lineTo(160f, 240f) + verticalLineToRelative(480f) + horizontalLineToRelative(280f) + quadToRelative(17f, 0f, 28.5f, 11.5f) + reflectiveQuadTo(480f, 760f) + quadToRelative(0f, 17f, -11.5f, 28.5f) + reflectiveQuadTo(440f, 800f) + lineTo(160f, 800f) + close() + } + }.build() + + return _FolderManaged!! + } + +@Suppress("ObjectPropertyName") +private var _FolderManaged: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/IndeterminateQuestionBox.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/IndeterminateQuestionBox.kt new file mode 100644 index 0000000000..4f64475258 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/IndeterminateQuestionBox.kt @@ -0,0 +1,129 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.IndeterminateQuestionBox: ImageVector + get() { + if (_IndeterminateQuestionBox != null) { + return _IndeterminateQuestionBox!! + } + _IndeterminateQuestionBox = ImageVector.Builder( + name = "IndeterminateQuestionBox", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(200f, 840f) + quadToRelative(-33f, 0f, -56.5f, -23.5f) + reflectiveQuadTo(120f, 760f) + verticalLineToRelative(-120f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(160f, 600f) + quadToRelative(17f, 0f, 28.5f, 11.5f) + reflectiveQuadTo(200f, 640f) + verticalLineToRelative(120f) + horizontalLineToRelative(120f) + quadToRelative(17f, 0f, 28.5f, 11.5f) + reflectiveQuadTo(360f, 800f) + quadToRelative(0f, 17f, -11.5f, 28.5f) + reflectiveQuadTo(320f, 840f) + lineTo(200f, 840f) + close() + moveTo(760f, 840f) + lineTo(640f, 840f) + quadToRelative(-17f, 0f, -28.5f, -11.5f) + reflectiveQuadTo(600f, 800f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(640f, 760f) + horizontalLineToRelative(120f) + verticalLineToRelative(-120f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(800f, 600f) + quadToRelative(17f, 0f, 28.5f, 11.5f) + reflectiveQuadTo(840f, 640f) + verticalLineToRelative(120f) + quadToRelative(0f, 33f, -23.5f, 56.5f) + reflectiveQuadTo(760f, 840f) + close() + moveTo(120f, 200f) + quadToRelative(0f, -33f, 23.5f, -56.5f) + reflectiveQuadTo(200f, 120f) + horizontalLineToRelative(120f) + quadToRelative(17f, 0f, 28.5f, 11.5f) + reflectiveQuadTo(360f, 160f) + quadToRelative(0f, 17f, -11.5f, 28.5f) + reflectiveQuadTo(320f, 200f) + lineTo(200f, 200f) + verticalLineToRelative(120f) + quadToRelative(0f, 17f, -11.5f, 28.5f) + reflectiveQuadTo(160f, 360f) + quadToRelative(-17f, 0f, -28.5f, -11.5f) + reflectiveQuadTo(120f, 320f) + verticalLineToRelative(-120f) + close() + moveTo(840f, 200f) + verticalLineToRelative(120f) + quadToRelative(0f, 17f, -11.5f, 28.5f) + reflectiveQuadTo(800f, 360f) + quadToRelative(-17f, 0f, -28.5f, -11.5f) + reflectiveQuadTo(760f, 320f) + verticalLineToRelative(-120f) + lineTo(640f, 200f) + quadToRelative(-17f, 0f, -28.5f, -11.5f) + reflectiveQuadTo(600f, 160f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(640f, 120f) + horizontalLineToRelative(120f) + quadToRelative(33f, 0f, 56.5f, 23.5f) + reflectiveQuadTo(840f, 200f) + close() + moveTo(480f, 720f) + quadToRelative(21f, 0f, 35.5f, -14.5f) + reflectiveQuadTo(530f, 670f) + quadToRelative(0f, -21f, -14.5f, -35.5f) + reflectiveQuadTo(480f, 620f) + quadToRelative(-21f, 0f, -35.5f, 14.5f) + reflectiveQuadTo(430f, 670f) + quadToRelative(0f, 21f, 14.5f, 35.5f) + reflectiveQuadTo(480f, 720f) + close() + moveTo(480f, 308f) + quadToRelative(26f, 0f, 45.5f, 16f) + reflectiveQuadToRelative(19.5f, 41f) + quadToRelative(0f, 23f, -14.5f, 41f) + reflectiveQuadTo(499f, 439f) + quadToRelative(-26f, 23f, -39.5f, 43.5f) + reflectiveQuadTo(444f, 532f) + quadToRelative(-1f, 14f, 10f, 24.5f) + reflectiveQuadToRelative(26f, 10.5f) + quadToRelative(14f, 0f, 25.5f, -10f) + reflectiveQuadToRelative(13.5f, -25f) + quadToRelative(2f, -17f, 12f, -30f) + reflectiveQuadToRelative(29f, -32f) + quadToRelative(35f, -35f, 46.5f, -56.5f) + reflectiveQuadTo(618f, 362f) + quadToRelative(0f, -54f, -39f, -88f) + reflectiveQuadToRelative(-99f, -34f) + quadToRelative(-41f, 0f, -73.5f, 18.5f) + reflectiveQuadTo(357f, 311f) + quadToRelative(-6f, 12f, -0.5f, 24.5f) + reflectiveQuadTo(375f, 353f) + quadToRelative(13f, 5f, 26.5f, 0f) + reflectiveQuadToRelative(21.5f, -16f) + quadToRelative(11f, -14f, 25.5f, -21.5f) + reflectiveQuadTo(480f, 308f) + close() + } + }.build() + + return _IndeterminateQuestionBox!! + } + +@Suppress("ObjectPropertyName") +private var _IndeterminateQuestionBox: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/KeyMapperIcon.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/KeyMapperIcon.kt new file mode 100644 index 0000000000..9a918c25f7 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/KeyMapperIcon.kt @@ -0,0 +1,313 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.KeyMapperIcon: ImageVector + get() { + if (_KeyMapperIcon != null) { + return _KeyMapperIcon!! + } + _KeyMapperIcon = ImageVector.Builder( + name = "KeyMapperIcon", + defaultWidth = 61.637.dp, + defaultHeight = 61.637.dp, + viewportWidth = 61.637f, + viewportHeight = 61.637f, + ).apply { + group( + clipPathData = PathData { + moveTo(-42f, -40.363f) + lineTo(102f, -40.363f) + lineTo(102f, 103.637f) + lineTo(-42f, 103.637f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-42f, -40.363f) + lineTo(102f, -40.363f) + lineTo(102f, 103.637f) + lineTo(-42f, 103.637f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-42.094f, -40.457f) + lineTo(102.095f, -40.457f) + lineTo(102.095f, 103.731f) + lineTo(-42.094f, 103.731f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-42.047f, -40.409f) + lineTo(102.047f, -40.409f) + lineTo(102.047f, 103.684f) + lineTo(-42.047f, 103.684f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-42.047f, -40.409f) + lineTo(102.047f, -40.409f) + lineTo(102.047f, 103.684f) + lineTo(-42.047f, 103.684f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-4.667f, -3.029f) + lineTo(64.667f, -3.029f) + lineTo(64.667f, 66.304f) + lineTo(-4.667f, 66.304f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(6f, 7.637f) + lineTo(54f, 7.637f) + lineTo(54f, 55.637f) + lineTo(6f, 55.637f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-4.8f, 7.504f) + lineTo(64.8f, 7.504f) + lineTo(64.8f, 55.771f) + lineTo(-4.8f, 55.771f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(5.867f, -3.163f) + lineTo(54.133f, -3.163f) + lineTo(54.133f, 66.437f) + lineTo(5.867f, 66.437f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(0.533f, 2.171f) + lineTo(59.467f, 2.171f) + lineTo(59.467f, 61.104f) + lineTo(0.533f, 61.104f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-18f, -16.363f) + lineTo(78f, -16.363f) + lineTo(78f, 79.637f) + lineTo(-18f, 79.637f) + close() + }, + ) { + } + path( + fill = SolidColor(Color(0xFFD32F2F)), + strokeLineWidth = 1.27586f, + ) { + moveTo(4f, 24.637f) + lineTo(33f, 24.637f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 37f, 28.637f) + lineTo(37f, 57.637f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 33f, 61.637f) + lineTo(4f, 61.637f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 0f, 57.637f) + lineTo(0f, 28.637f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = true, 4f, 24.637f) + close() + } + path( + fill = SolidColor(Color.White), + strokeLineWidth = 1.3f, + ) { + moveTo(21.9f, 37.514f) + lineTo(21.9f, 31.937f) + curveTo(21.9f, 31.222f, 21.315f, 30.637f, 20.6f, 30.637f) + horizontalLineToRelative(-5.2f) + curveToRelative(-0.715f, 0f, -1.3f, 0.585f, -1.3f, 1.3f) + verticalLineToRelative(5.577f) + curveToRelative(0f, 0.169f, 0.065f, 0.338f, 0.195f, 0.455f) + lineToRelative(3.25f, 3.25f) + curveToRelative(0.26f, 0.26f, 0.663f, 0.26f, 0.923f, 0f) + lineToRelative(3.25f, -3.25f) + curveToRelative(0.117f, -0.117f, 0.182f, -0.273f, 0.182f, -0.455f) + close() + moveTo(11.877f, 39.737f) + lineTo(6.3f, 39.737f) + curveTo(5.585f, 39.737f, 5f, 40.322f, 5f, 41.037f) + verticalLineToRelative(5.2f) + curveToRelative(0f, 0.715f, 0.585f, 1.3f, 1.3f, 1.3f) + lineTo(11.877f, 47.537f) + curveToRelative(0.169f, 0f, 0.338f, -0.065f, 0.455f, -0.195f) + lineToRelative(3.25f, -3.25f) + curveToRelative(0.26f, -0.26f, 0.26f, -0.663f, 0f, -0.923f) + lineToRelative(-3.25f, -3.25f) + curveToRelative(-0.117f, -0.117f, -0.273f, -0.182f, -0.455f, -0.182f) + close() + moveTo(14.1f, 49.76f) + verticalLineToRelative(5.577f) + curveTo(14.1f, 56.052f, 14.685f, 56.637f, 15.4f, 56.637f) + horizontalLineToRelative(5.2f) + curveToRelative(0.715f, 0f, 1.3f, -0.585f, 1.3f, -1.3f) + lineTo(21.9f, 49.76f) + curveToRelative(0f, -0.169f, -0.065f, -0.338f, -0.195f, -0.455f) + lineToRelative(-3.25f, -3.25f) + curveToRelative(-0.26f, -0.26f, -0.663f, -0.26f, -0.923f, 0f) + lineToRelative(-3.25f, 3.25f) + curveToRelative(-0.117f, 0.117f, -0.182f, 0.273f, -0.182f, 0.455f) + close() + moveTo(23.655f, 39.932f) + lineTo(20.405f, 43.182f) + curveToRelative(-0.26f, 0.26f, -0.26f, 0.663f, 0f, 0.923f) + lineToRelative(3.25f, 3.25f) + curveToRelative(0.117f, 0.117f, 0.286f, 0.195f, 0.455f, 0.195f) + horizontalLineToRelative(5.59f) + curveTo(30.415f, 47.55f, 31f, 46.965f, 31f, 46.25f) + lineTo(31f, 41.05f) + curveTo(31f, 40.335f, 30.415f, 39.75f, 29.7f, 39.75f) + lineTo(24.123f, 39.75f) + curveToRelative(-0.182f, -0.013f, -0.338f, 0.052f, -0.468f, 0.182f) + close() + } + path( + fill = SolidColor(Color(0xFF212121)), + fillAlpha = 0f, + strokeLineWidth = 1.27586f, + ) { + moveTo(22.717f, 4.354f) + curveTo(21.679f, 5.075f, 21f, 6.272f, 21f, 7.637f) + verticalLineToRelative(29f) + curveToRelative(0f, 2.216f, 1.784f, 4f, 4f, 4f) + horizontalLineToRelative(29f) + curveToRelative(1.365f, 0f, 2.562f, -0.679f, 3.283f, -1.717f) + curveTo(56.636f, 39.37f, 55.851f, 39.637f, 55f, 39.637f) + lineTo(26f, 39.637f) + curveToRelative(-2.216f, 0f, -4f, -1.784f, -4f, -4f) + lineTo(22f, 6.637f) + curveToRelative(0f, -0.851f, 0.267f, -1.636f, 0.717f, -2.283f) + close() + } + path( + fill = SolidColor(Color(0xFF1565C0)), + strokeLineWidth = 1.27586f, + ) { + moveTo(55f, 2.862f) + lineTo(26f, 2.862f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = false, 22f, 6.862f) + lineTo(22f, 35.862f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = false, 26f, 39.862f) + lineTo(55f, 39.862f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = false, 59f, 35.862f) + lineTo(59f, 6.862f) + arcTo(4f, 4f, 0f, isMoreThanHalf = false, isPositiveArc = false, 55f, 2.862f) + close() + } + path( + fill = SolidColor(Color.White), + strokeLineWidth = 1.3f, + ) { + moveTo(51.4f, 11.437f) + lineTo(30.6f, 11.437f) + curveToRelative(-1.43f, 0f, -2.587f, 1.17f, -2.587f, 2.6f) + lineTo(28f, 27.037f) + curveToRelative(0f, 1.43f, 1.17f, 2.6f, 2.6f, 2.6f) + lineTo(51.4f, 29.637f) + curveTo(52.83f, 29.637f, 54f, 28.467f, 54f, 27.037f) + lineTo(54f, 14.037f) + curveToRelative(0f, -1.43f, -1.17f, -2.6f, -2.6f, -2.6f) + close() + moveTo(39.7f, 15.337f) + horizontalLineToRelative(2.6f) + verticalLineToRelative(2.6f) + lineTo(39.7f, 17.937f) + close() + moveTo(39.7f, 19.237f) + horizontalLineToRelative(2.6f) + lineTo(42.3f, 21.837f) + lineTo(39.7f, 21.837f) + close() + moveTo(35.8f, 15.337f) + horizontalLineToRelative(2.6f) + verticalLineToRelative(2.6f) + lineTo(35.8f, 17.937f) + close() + moveTo(35.8f, 19.237f) + horizontalLineToRelative(2.6f) + lineTo(38.4f, 21.837f) + lineTo(35.8f, 21.837f) + close() + moveTo(34.5f, 21.837f) + horizontalLineToRelative(-2.6f) + verticalLineToRelative(-2.6f) + horizontalLineToRelative(2.6f) + close() + moveTo(34.5f, 17.937f) + horizontalLineToRelative(-2.6f) + verticalLineToRelative(-2.6f) + horizontalLineToRelative(2.6f) + close() + moveTo(44.9f, 27.037f) + horizontalLineToRelative(-7.8f) + curveTo(36.385f, 27.037f, 35.8f, 26.452f, 35.8f, 25.737f) + curveTo(35.8f, 25.022f, 36.385f, 24.437f, 37.1f, 24.437f) + horizontalLineToRelative(7.8f) + curveToRelative(0.715f, 0f, 1.3f, 0.585f, 1.3f, 1.3f) + curveToRelative(0f, 0.715f, -0.585f, 1.3f, -1.3f, 1.3f) + close() + moveTo(46.2f, 21.837f) + horizontalLineToRelative(-2.6f) + verticalLineToRelative(-2.6f) + horizontalLineToRelative(2.6f) + close() + moveTo(46.2f, 17.937f) + horizontalLineToRelative(-2.6f) + verticalLineToRelative(-2.6f) + horizontalLineToRelative(2.6f) + close() + moveTo(50.1f, 21.837f) + horizontalLineToRelative(-2.6f) + verticalLineToRelative(-2.6f) + horizontalLineToRelative(2.6f) + close() + moveTo(50.1f, 17.937f) + horizontalLineToRelative(-2.6f) + verticalLineToRelative(-2.6f) + horizontalLineToRelative(2.6f) + close() + } + }.build() + + return _KeyMapperIcon!! + } + +@Suppress("ObjectPropertyName") +private var _KeyMapperIcon: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/ModeOffOn.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/ModeOffOn.kt new file mode 100644 index 0000000000..75a46ee7c6 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/ModeOffOn.kt @@ -0,0 +1,71 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.ModeOffOn: ImageVector + get() { + if (_ModeOffOn != null) { + return _ModeOffOn!! + } + _ModeOffOn = ImageVector.Builder( + name = "ModeOffOn", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(480f, 480f) + quadToRelative(-17f, 0f, -28.5f, -11.5f) + reflectiveQuadTo(440f, 440f) + verticalLineToRelative(-320f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(480f, 80f) + quadToRelative(17f, 0f, 28.5f, 11.5f) + reflectiveQuadTo(520f, 120f) + verticalLineToRelative(320f) + quadToRelative(0f, 17f, -11.5f, 28.5f) + reflectiveQuadTo(480f, 480f) + close() + moveTo(480f, 840f) + quadToRelative(-75f, 0f, -140.5f, -28.5f) + reflectiveQuadToRelative(-114f, -77f) + quadToRelative(-48.5f, -48.5f, -77f, -114f) + reflectiveQuadTo(120f, 480f) + quadToRelative(0f, -61f, 20f, -118.5f) + reflectiveQuadTo(198f, 256f) + quadToRelative(11f, -14f, 28f, -13.5f) + reflectiveQuadToRelative(30f, 13.5f) + quadToRelative(11f, 11f, 10f, 27f) + reflectiveQuadToRelative(-11f, 30f) + quadToRelative(-27f, 36f, -41f, 79f) + reflectiveQuadToRelative(-14f, 88f) + quadToRelative(0f, 117f, 81.5f, 198.5f) + reflectiveQuadTo(480f, 760f) + quadToRelative(117f, 0f, 198.5f, -81.5f) + reflectiveQuadTo(760f, 480f) + quadToRelative(0f, -46f, -13.5f, -89.5f) + reflectiveQuadTo(704f, 311f) + quadToRelative(-10f, -13f, -11f, -28.5f) + reflectiveQuadToRelative(10f, -26.5f) + quadToRelative(12f, -12f, 29f, -12.5f) + reflectiveQuadToRelative(28f, 12.5f) + quadToRelative(39f, 48f, 59.5f, 105f) + reflectiveQuadTo(840f, 480f) + quadToRelative(0f, 75f, -28.5f, 140.5f) + reflectiveQuadToRelative(-77f, 114f) + quadToRelative(-48.5f, 48.5f, -114f, 77f) + reflectiveQuadTo(480f, 840f) + close() + } + }.build() + + return _ModeOffOn!! + } + +@Suppress("ObjectPropertyName") +private var _ModeOffOn: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/ProModeDisabled.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/ProModeDisabled.kt new file mode 100644 index 0000000000..7e6435500b --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/ProModeDisabled.kt @@ -0,0 +1,162 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.ProModeIconDisabled: ImageVector + get() { + if (_ProModeDisabled != null) { + return _ProModeDisabled!! + } + _ProModeDisabled = ImageVector.Builder( + name = "ProModeDisabled", + defaultWidth = 32.dp, + defaultHeight = 32.dp, + viewportWidth = 32f, + viewportHeight = 32f, + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + lineTo(32f, 0f) + lineTo(32f, 32f) + lineTo(0f, 32f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(0f, 0f) + lineTo(32f, 0f) + lineTo(32f, 32f) + lineTo(0f, 32f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-0f, -0f) + lineTo(32f, -0f) + lineTo(32f, 32f) + lineTo(-0f, 32f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-0f, 32f) + lineTo(32f, 32f) + lineTo(32f, -0f) + lineTo(-0f, -0f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(0f, 0f) + lineTo(32f, 0f) + lineTo(32f, 32f) + lineTo(0f, 32f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-0f, 32f) + lineTo(32f, 32f) + lineTo(32f, -0f) + lineTo(-0f, -0f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-0f, 32f) + lineTo(32f, 32f) + lineTo(32f, -0f) + lineTo(-0f, -0f) + close() + }, + ) { + } + path(fill = SolidColor(Color.Black)) { + moveToRelative(4f, 11f) + verticalLineToRelative(10f) + horizontalLineToRelative(2f) + verticalLineToRelative(-4f) + horizontalLineToRelative(2f) + arcToRelative(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 2f, -2f) + verticalLineTo(13f) + arcTo(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 8f, 11f) + horizontalLineTo(4f) + moveToRelative(2f, 2f) + horizontalLineToRelative(2f) + verticalLineToRelative(2f) + horizontalLineTo(6f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveToRelative(13f, 11f) + verticalLineToRelative(10f) + horizontalLineToRelative(2f) + verticalLineToRelative(-4f) + horizontalLineToRelative(0.8f) + lineToRelative(1.2f, 4f) + horizontalLineToRelative(2f) + lineTo(17.76f, 16.85f) + curveTo(18.5f, 16.55f, 19f, 15.84f, 19f, 15f) + verticalLineToRelative(-2f) + arcToRelative(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, -2f, -2f) + horizontalLineToRelative(-4f) + moveToRelative(2f, 2f) + horizontalLineToRelative(2f) + verticalLineToRelative(2f) + horizontalLineToRelative(-2f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveToRelative(24f, 11f) + arcToRelative(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, -2f, 2f) + verticalLineToRelative(6f) + arcToRelative(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 2f, 2f) + horizontalLineToRelative(2f) + arcToRelative(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 2f, -2f) + verticalLineToRelative(-6f) + arcToRelative(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, -2f, -2f) + horizontalLineToRelative(-2f) + moveToRelative(0f, 2f) + horizontalLineToRelative(2f) + verticalLineToRelative(6f) + horizontalLineToRelative(-2f) + close() + } + path( + fill = SolidColor(Color(0xFF808080)), + stroke = SolidColor(Color.Black), + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + pathFillType = PathFillType.EvenOdd, + ) { + moveTo(26.664f, 5.353f) + lineTo(5.354f, 26.753f) + } + }.build() + + return _ProModeDisabled!! + } + +@Suppress("ObjectPropertyName") +private var _ProModeDisabled: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/ProModeIcon.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/ProModeIcon.kt new file mode 100644 index 0000000000..ec3368f4da --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/ProModeIcon.kt @@ -0,0 +1,150 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathData +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.ProModeIcon: ImageVector + get() { + if (_ProMode != null) { + return _ProMode!! + } + _ProMode = ImageVector.Builder( + name = "ProMode", + defaultWidth = 32.dp, + defaultHeight = 32.dp, + viewportWidth = 32f, + viewportHeight = 32f, + ).apply { + group( + clipPathData = PathData { + moveTo(0f, 0f) + lineTo(32f, 0f) + lineTo(32f, 32f) + lineTo(0f, 32f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(0f, 0f) + lineTo(32f, 0f) + lineTo(32f, 32f) + lineTo(0f, 32f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-0f, -0f) + lineTo(32f, -0f) + lineTo(32f, 32f) + lineTo(-0f, 32f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-0f, 32f) + lineTo(32f, 32f) + lineTo(32f, -0f) + lineTo(-0f, -0f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(0f, 0f) + lineTo(32f, 0f) + lineTo(32f, 32f) + lineTo(0f, 32f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-0f, 32f) + lineTo(32f, 32f) + lineTo(32f, -0f) + lineTo(-0f, -0f) + close() + }, + ) { + } + group( + clipPathData = PathData { + moveTo(-0f, 32f) + lineTo(32f, 32f) + lineTo(32f, -0f) + lineTo(-0f, -0f) + close() + }, + ) { + } + path(fill = SolidColor(Color.Black)) { + moveToRelative(4f, 11f) + verticalLineToRelative(10f) + horizontalLineToRelative(2f) + verticalLineToRelative(-4f) + horizontalLineToRelative(2f) + arcToRelative(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 2f, -2f) + verticalLineTo(13f) + arcTo(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 8f, 11f) + horizontalLineTo(4f) + moveToRelative(2f, 2f) + horizontalLineToRelative(2f) + verticalLineToRelative(2f) + horizontalLineTo(6f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveToRelative(13f, 11f) + verticalLineToRelative(10f) + horizontalLineToRelative(2f) + verticalLineToRelative(-4f) + horizontalLineToRelative(0.8f) + lineToRelative(1.2f, 4f) + horizontalLineToRelative(2f) + lineTo(17.76f, 16.85f) + curveTo(18.5f, 16.55f, 19f, 15.84f, 19f, 15f) + verticalLineToRelative(-2f) + arcToRelative(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, -2f, -2f) + horizontalLineToRelative(-4f) + moveToRelative(2f, 2f) + horizontalLineToRelative(2f) + verticalLineToRelative(2f) + horizontalLineToRelative(-2f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveToRelative(24f, 11f) + arcToRelative(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, -2f, 2f) + verticalLineToRelative(6f) + arcToRelative(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 2f, 2f) + horizontalLineToRelative(2f) + arcToRelative(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, 2f, -2f) + verticalLineToRelative(-6f) + arcToRelative(2f, 2f, 0f, isMoreThanHalf = false, isPositiveArc = false, -2f, -2f) + horizontalLineToRelative(-2f) + moveToRelative(0f, 2f) + horizontalLineToRelative(2f) + verticalLineToRelative(6f) + horizontalLineToRelative(-2f) + close() + } + }.build() + + return _ProMode!! + } + +@Suppress("ObjectPropertyName") +private var _ProMode: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/SignalWifiNotConnected.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/SignalWifiNotConnected.kt new file mode 100644 index 0000000000..00e8a9f9db --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/SignalWifiNotConnected.kt @@ -0,0 +1,91 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.SignalWifiNotConnected: ImageVector + get() { + if (_SignalWifiNotConnected != null) { + return _SignalWifiNotConnected!! + } + _SignalWifiNotConnected = ImageVector.Builder( + name = "SignalWifiNotConnected", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(423f, 783f) + lineTo(61f, 421f) + quadToRelative(-13f, -13f, -18.5f, -28f) + reflectiveQuadTo(38f, 361f) + quadToRelative(1f, -17f, 7f, -32f) + reflectiveQuadToRelative(20f, -26f) + quadToRelative(81f, -71f, 194.5f, -107f) + reflectiveQuadTo(480f, 160f) + quadToRelative(125f, 0f, 234f, 41f) + reflectiveQuadToRelative(203f, 122f) + quadToRelative(9f, 8f, 13.5f, 17.5f) + reflectiveQuadTo(935f, 361f) + quadToRelative(0f, 11f, -3.5f, 21f) + reflectiveQuadTo(920f, 400f) + quadToRelative(-28f, -36f, -69.5f, -58f) + reflectiveQuadTo(760f, 320f) + quadToRelative(-83f, 0f, -141.5f, 58.5f) + reflectiveQuadTo(560f, 520f) + quadToRelative(0f, 49f, 22f, 90.5f) + reflectiveQuadToRelative(58f, 69.5f) + lineTo(537f, 783f) + quadToRelative(-12f, 12f, -26.5f, 18f) + reflectiveQuadToRelative(-30.5f, 6f) + quadToRelative(-16f, 0f, -30.5f, -6f) + reflectiveQuadTo(423f, 783f) + close() + moveTo(760f, 800f) + quadToRelative(-17f, 0f, -29.5f, -12.5f) + reflectiveQuadTo(718f, 758f) + quadToRelative(0f, -17f, 12.5f, -29.5f) + reflectiveQuadTo(760f, 716f) + quadToRelative(17f, 0f, 29.5f, 12.5f) + reflectiveQuadTo(802f, 758f) + quadToRelative(0f, 17f, -12.5f, 29.5f) + reflectiveQuadTo(760f, 800f) + close() + moveTo(876f, 503f) + quadToRelative(0f, 23f, -10f, 41f) + reflectiveQuadToRelative(-38f, 46f) + quadToRelative(-17f, 17f, -24.5f, 28f) + reflectiveQuadToRelative(-9.5f, 25f) + quadToRelative(-2f, 12f, -11.5f, 20.5f) + reflectiveQuadTo(761f, 672f) + quadToRelative(-13f, 0f, -22f, -9f) + reflectiveQuadToRelative(-7f, -21f) + quadToRelative(3f, -23f, 14f, -40f) + reflectiveQuadToRelative(37f, -43f) + quadToRelative(21f, -21f, 27f, -31.5f) + reflectiveQuadToRelative(6f, -26.5f) + quadToRelative(0f, -18f, -14f, -31.5f) + reflectiveQuadTo(765f, 456f) + quadToRelative(-15f, 0f, -29f, 7f) + reflectiveQuadToRelative(-24f, 20f) + quadToRelative(-7f, 9f, -17.5f, 13f) + reflectiveQuadToRelative(-21.5f, -1f) + quadToRelative(-11f, -5f, -16.5f, -15f) + reflectiveQuadToRelative(0.5f, -20f) + quadToRelative(16f, -28f, 44.5f, -44f) + reflectiveQuadToRelative(63.5f, -16f) + quadToRelative(49f, 0f, 80f, 29f) + reflectiveQuadToRelative(31f, 74f) + close() + } + }.build() + + return _SignalWifiNotConnected!! + } + +@Suppress("ObjectPropertyName") +private var _SignalWifiNotConnected: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/SportsEsports.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/SportsEsports.kt new file mode 100644 index 0000000000..faeb2f2711 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/SportsEsports.kt @@ -0,0 +1,115 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.SportsEsports: ImageVector + get() { + if (_SportsEsports != null) { + return _SportsEsports!! + } + _SportsEsports = ImageVector.Builder( + name = "SportsEsports", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(182f, 760f) + quadToRelative(-51f, 0f, -79f, -35.5f) + reflectiveQuadTo(82f, 638f) + lineToRelative(42f, -300f) + quadToRelative(9f, -60f, 53.5f, -99f) + reflectiveQuadTo(282f, 200f) + horizontalLineToRelative(396f) + quadToRelative(60f, 0f, 104.5f, 39f) + reflectiveQuadToRelative(53.5f, 99f) + lineToRelative(42f, 300f) + quadToRelative(7f, 51f, -21f, 86.5f) + reflectiveQuadTo(778f, 760f) + quadToRelative(-21f, 0f, -39f, -7.5f) + reflectiveQuadTo(706f, 730f) + lineToRelative(-90f, -90f) + lineTo(344f, 640f) + lineToRelative(-90f, 90f) + quadToRelative(-15f, 15f, -33f, 22.5f) + reflectiveQuadToRelative(-39f, 7.5f) + close() + moveTo(198f, 674f) + lineTo(312f, 560f) + horizontalLineToRelative(336f) + lineToRelative(114f, 114f) + quadToRelative(2f, 2f, 16f, 6f) + quadToRelative(11f, 0f, 17.5f, -6.5f) + reflectiveQuadTo(800f, 656f) + lineToRelative(-44f, -308f) + quadToRelative(-4f, -29f, -26f, -48.5f) + reflectiveQuadTo(678f, 280f) + lineTo(282f, 280f) + quadToRelative(-30f, 0f, -52f, 19.5f) + reflectiveQuadTo(204f, 348f) + lineToRelative(-44f, 308f) + quadToRelative(-2f, 11f, 4.5f, 17.5f) + reflectiveQuadTo(182f, 680f) + quadToRelative(2f, 0f, 16f, -6f) + close() + moveTo(680f, 520f) + quadToRelative(17f, 0f, 28.5f, -11.5f) + reflectiveQuadTo(720f, 480f) + quadToRelative(0f, -17f, -11.5f, -28.5f) + reflectiveQuadTo(680f, 440f) + quadToRelative(-17f, 0f, -28.5f, 11.5f) + reflectiveQuadTo(640f, 480f) + quadToRelative(0f, 17f, 11.5f, 28.5f) + reflectiveQuadTo(680f, 520f) + close() + moveTo(600f, 400f) + quadToRelative(17f, 0f, 28.5f, -11.5f) + reflectiveQuadTo(640f, 360f) + quadToRelative(0f, -17f, -11.5f, -28.5f) + reflectiveQuadTo(600f, 320f) + quadToRelative(-17f, 0f, -28.5f, 11.5f) + reflectiveQuadTo(560f, 360f) + quadToRelative(0f, 17f, 11.5f, 28.5f) + reflectiveQuadTo(600f, 400f) + close() + moveTo(480f, 480f) + close() + moveTo(310f, 450f) + verticalLineToRelative(40f) + quadToRelative(0f, 13f, 8.5f, 21.5f) + reflectiveQuadTo(340f, 520f) + quadToRelative(13f, 0f, 21.5f, -8.5f) + reflectiveQuadTo(370f, 490f) + verticalLineToRelative(-40f) + horizontalLineToRelative(40f) + quadToRelative(13f, 0f, 21.5f, -8.5f) + reflectiveQuadTo(440f, 420f) + quadToRelative(0f, -13f, -8.5f, -21.5f) + reflectiveQuadTo(410f, 390f) + horizontalLineToRelative(-40f) + verticalLineToRelative(-40f) + quadToRelative(0f, -13f, -8.5f, -21.5f) + reflectiveQuadTo(340f, 320f) + quadToRelative(-13f, 0f, -21.5f, 8.5f) + reflectiveQuadTo(310f, 350f) + verticalLineToRelative(40f) + horizontalLineToRelative(-40f) + quadToRelative(-13f, 0f, -21.5f, 8.5f) + reflectiveQuadTo(240f, 420f) + quadToRelative(0f, 13f, 8.5f, 21.5f) + reflectiveQuadTo(270f, 450f) + horizontalLineToRelative(40f) + close() + } + }.build() + + return _SportsEsports!! + } + +@Suppress("ObjectPropertyName") +private var _SportsEsports: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/SwitchText.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/SwitchText.kt new file mode 100644 index 0000000000..f05dc715e0 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/SwitchText.kt @@ -0,0 +1,57 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun SwitchText( + modifier: Modifier = Modifier, + text: String, + isChecked: Boolean, + isEnabled: Boolean = true, + onCheckedChange: (Boolean) -> Unit, +) { + Surface(modifier = modifier, shape = MaterialTheme.shapes.medium, color = Color.Transparent) { + Row( + modifier = Modifier + .clickable(enabled = isEnabled) { onCheckedChange(!isChecked) } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + enabled = isEnabled, + checked = isChecked, + // This is null so tapping on the checkbox highlights the whole row. + onCheckedChange = null, + ) + + Text( + modifier = Modifier.padding(horizontal = 12.dp), + + text = text, + style = if (isEnabled) { + MaterialTheme.typography.bodyLarge + } else { + MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.5f, + ), + ) + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/VoiceSelection.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/VoiceSelection.kt new file mode 100644 index 0000000000..e531929e46 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/VoiceSelection.kt @@ -0,0 +1,112 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.VoiceSelection: ImageVector + get() { + if (_VoiceSelection != null) { + return _VoiceSelection!! + } + _VoiceSelection = ImageVector.Builder( + name = "VoiceSelection", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(737f, 892f) + quadToRelative(-17f, 0f, -28.5f, -11.5f) + reflectiveQuadTo(697f, 852f) + quadToRelative(0f, -8f, 3f, -16f) + reflectiveQuadToRelative(8f, -13f) + quadToRelative(43f, -43f, 67.5f, -99f) + reflectiveQuadTo(800f, 602f) + quadToRelative(0f, -65f, -24.5f, -121.5f) + reflectiveQuadTo(708f, 381f) + quadToRelative(-5f, -5f, -8f, -13f) + reflectiveQuadToRelative(-3f, -16f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(737f, 312f) + quadToRelative(8f, 0f, 15.5f, 3.5f) + reflectiveQuadTo(765f, 324f) + quadToRelative(54f, 54f, 84.5f, 125.5f) + reflectiveQuadTo(880f, 602f) + quadToRelative(0f, 82f, -30.5f, 153f) + reflectiveQuadTo(765f, 880f) + quadToRelative(-5f, 5f, -12.5f, 8.5f) + reflectiveQuadTo(737f, 892f) + close() + moveTo(623f, 778f) + quadToRelative(-17f, 0f, -28.5f, -11.5f) + reflectiveQuadTo(583f, 738f) + quadToRelative(0f, -8f, 3.5f, -15.5f) + reflectiveQuadTo(595f, 710f) + quadToRelative(21f, -21f, 33f, -48.5f) + reflectiveQuadToRelative(12f, -59.5f) + quadToRelative(0f, -32f, -12.5f, -59.5f) + reflectiveQuadTo(595f, 494f) + quadToRelative(-6f, -5f, -9f, -12.5f) + reflectiveQuadToRelative(-3f, -15.5f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(623f, 426f) + quadToRelative(8f, 0f, 16f, 3f) + reflectiveQuadToRelative(13f, 9f) + quadToRelative(32f, 32f, 50f, 74f) + reflectiveQuadToRelative(18f, 90f) + quadToRelative(0f, 48f, -18f, 90f) + reflectiveQuadToRelative(-50f, 74f) + quadToRelative(-5f, 6f, -13f, 9f) + reflectiveQuadToRelative(-16f, 3f) + close() + moveTo(320f, 600f) + horizontalLineToRelative(-80f) + verticalLineToRelative(11f) + quadToRelative(0f, 35f, 21.5f, 61.5f) + reflectiveQuadTo(316f, 708f) + lineToRelative(12f, 3f) + quadToRelative(40f, 10f, 45f, 50f) + reflectiveQuadToRelative(-31f, 60f) + quadToRelative(-51f, 29f, -107f, 42f) + reflectiveQuadTo(120f, 879f) + quadToRelative(-17f, 1f, -28.5f, -10.5f) + reflectiveQuadTo(80f, 840f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(120f, 799f) + quadToRelative(36f, -2f, 70.5f, -8.5f) + reflectiveQuadTo(259f, 772f) + quadToRelative(-46f, -23f, -72.5f, -66.5f) + reflectiveQuadTo(160f, 611f) + verticalLineToRelative(-51f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(200f, 520f) + horizontalLineToRelative(120f) + verticalLineToRelative(-80f) + quadToRelative(0f, -17f, 11.5f, -28.5f) + reflectiveQuadTo(360f, 400f) + horizontalLineToRelative(95f) + lineTo(342f, 174f) + quadToRelative(-8f, -15f, -2.5f, -30.5f) + reflectiveQuadTo(360f, 120f) + quadToRelative(15f, -8f, 30.5f, -2.5f) + reflectiveQuadTo(414f, 138f) + lineToRelative(113f, 226f) + quadToRelative(20f, 40f, -3f, 78f) + reflectiveQuadToRelative(-68f, 38f) + horizontalLineToRelative(-56f) + verticalLineToRelative(40f) + quadToRelative(0f, 33f, -23.5f, 56.5f) + reflectiveQuadTo(320f, 600f) + close() + } + }.build() + + return _VoiceSelection!! + } + +@Suppress("ObjectPropertyName") +private var _VoiceSelection: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/WandStars.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/WandStars.kt new file mode 100644 index 0000000000..0215618831 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/WandStars.kt @@ -0,0 +1,99 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.WandStars: ImageVector + get() { + if (_WandStars != null) { + return _WandStars!! + } + _WandStars = ImageVector.Builder( + name = "WandStars24Dp000000FILL0Wght400GRAD0Opsz24", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveToRelative(646f, 522f) + lineToRelative(-86f, 138f) + quadToRelative(-11f, 17f, -30.5f, 14f) + reflectiveQuadTo(505f, 651f) + lineToRelative(-28f, -112f) + lineToRelative(-273f, 273f) + quadToRelative(-11f, 11f, -27.5f, 11.5f) + reflectiveQuadTo(148f, 812f) + quadToRelative(-11f, -11f, -11f, -28f) + reflectiveQuadToRelative(11f, -28f) + lineToRelative(273f, -274f) + lineToRelative(-112f, -28f) + quadToRelative(-20f, -5f, -23f, -24.5f) + reflectiveQuadToRelative(14f, -30.5f) + lineToRelative(138f, -85f) + lineToRelative(-12f, -163f) + quadToRelative(-2f, -20f, 16f, -29f) + reflectiveQuadToRelative(33f, 4f) + lineToRelative(125f, 105f) + lineToRelative(151f, -61f) + quadToRelative(19f, -8f, 33f, 6f) + reflectiveQuadToRelative(6f, 33f) + lineToRelative(-61f, 151f) + lineToRelative(105f, 124f) + quadToRelative(13f, 15f, 4f, 33f) + reflectiveQuadToRelative(-29f, 16f) + lineToRelative(-163f, -11f) + close() + moveTo(134f, 254f) + quadToRelative(-6f, -6f, -6f, -14f) + reflectiveQuadToRelative(6f, -14f) + lineToRelative(52f, -52f) + quadToRelative(6f, -6f, 14f, -6f) + reflectiveQuadToRelative(14f, 6f) + lineToRelative(52f, 52f) + quadToRelative(6f, 6f, 6f, 14f) + reflectiveQuadToRelative(-6f, 14f) + lineToRelative(-52f, 52f) + quadToRelative(-6f, 6f, -14f, 6f) + reflectiveQuadToRelative(-14f, -6f) + lineToRelative(-52f, -52f) + close() + moveTo(555f, 517f) + lineTo(603f, 438f) + lineTo(696f, 445f) + lineTo(636f, 374f) + lineTo(671f, 288f) + lineTo(585f, 323f) + lineTo(514f, 264f) + lineTo(521f, 356f) + lineTo(442f, 405f) + lineTo(532f, 427f) + lineTo(555f, 517f) + close() + moveTo(706f, 826f) + lineTo(654f, 774f) + quadToRelative(-6f, -6f, -6f, -14f) + reflectiveQuadToRelative(6f, -14f) + lineToRelative(52f, -52f) + quadToRelative(6f, -6f, 14f, -6f) + reflectiveQuadToRelative(14f, 6f) + lineToRelative(52f, 52f) + quadToRelative(6f, 6f, 6f, 14f) + reflectiveQuadToRelative(-6f, 14f) + lineToRelative(-52f, 52f) + quadToRelative(-6f, 6f, -14f, 6f) + reflectiveQuadToRelative(-14f, -6f) + close() + moveTo(569f, 390f) + close() + } + }.build() + + return _WandStars!! + } + +@Suppress("ObjectPropertyName") +private var _WandStars: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/tiles/ToggleKeyMapperKeyboardTile.kt b/base/src/main/java/io/github/sds100/keymapper/tiles/ToggleKeyMapperKeyboardTile.kt index de57b65f2d..4a668480d2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/tiles/ToggleKeyMapperKeyboardTile.kt +++ b/base/src/main/java/io/github/sds100/keymapper/tiles/ToggleKeyMapperKeyboardTile.kt @@ -21,8 +21,8 @@ import io.github.sds100.keymapper.base.utils.ui.launchRepeatOnLifecycle import io.github.sds100.keymapper.base.utils.ui.str import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.onSuccess -import kotlinx.coroutines.flow.first import javax.inject.Inject +import kotlinx.coroutines.flow.first @RequiresApi(Build.VERSION_CODES.N) @AndroidEntryPoint diff --git a/base/src/main/java/io/github/sds100/keymapper/tiles/ToggleMappingsTile.kt b/base/src/main/java/io/github/sds100/keymapper/tiles/ToggleMappingsTile.kt index f03f80df9e..693a50dfe6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/tiles/ToggleMappingsTile.kt +++ b/base/src/main/java/io/github/sds100/keymapper/tiles/ToggleMappingsTile.kt @@ -18,9 +18,9 @@ import io.github.sds100.keymapper.base.utils.ui.str import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState +import javax.inject.Inject import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine -import javax.inject.Inject @RequiresApi(Build.VERSION_CODES.N) @AndroidEntryPoint diff --git a/base/src/main/res/drawable/ic_outline_message_24.xml b/base/src/main/res/drawable/ic_outline_message_24.xml new file mode 100644 index 0000000000..22107b3ca3 --- /dev/null +++ b/base/src/main/res/drawable/ic_outline_message_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/base/src/main/res/drawable/pro_mode.xml b/base/src/main/res/drawable/pro_mode.xml new file mode 100644 index 0000000000..a0062fc0ed --- /dev/null +++ b/base/src/main/res/drawable/pro_mode.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/base/src/main/res/drawable/profile_pic_bydario.png b/base/src/main/res/drawable/profile_pic_bydario.png deleted file mode 100644 index cbd6e85dad..0000000000 Binary files a/base/src/main/res/drawable/profile_pic_bydario.png and /dev/null differ diff --git a/base/src/main/res/drawable/profile_pic_kekero.png b/base/src/main/res/drawable/profile_pic_kekero.png deleted file mode 100644 index 1f2c7aee26..0000000000 Binary files a/base/src/main/res/drawable/profile_pic_kekero.png and /dev/null differ diff --git a/base/src/main/res/layout/dialog_choose_app_store.xml b/base/src/main/res/layout/dialog_choose_app_store.xml deleted file mode 100644 index af7de866af..0000000000 --- a/base/src/main/res/layout/dialog_choose_app_store.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/base/src/main/res/layout/fragment_about.xml b/base/src/main/res/layout/fragment_about.xml index 2ca991c4b6..1ae3fae328 100644 --- a/base/src/main/res/layout/fragment_about.xml +++ b/base/src/main/res/layout/fragment_about.xml @@ -259,32 +259,6 @@ bind:title="@{@string/about_team_jambl3r_name}" bind:url="@{@string/about_team_jambl3r_url}" /> - - - - - diff --git a/base/src/main/res/layout/fragment_config_key_event.xml b/base/src/main/res/layout/fragment_config_key_event.xml index 70b32c74a4..54ceababbe 100644 --- a/base/src/main/res/layout/fragment_config_key_event.xml +++ b/base/src/main/res/layout/fragment_config_key_event.xml @@ -88,22 +88,6 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/textViewKeycodeLabel" /> - - + app:layout_constraintTop_toBottomOf="@id/buttonChooseKeycode"> - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/base/src/main/res/navigation/nav_base_app.xml b/base/src/main/res/navigation/nav_base_app.xml index e3f43da0f9..54b478e60c 100644 --- a/base/src/main/res/navigation/nav_base_app.xml +++ b/base/src/main/res/navigation/nav_base_app.xml @@ -5,24 +5,6 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/nav_base_app"> - - - - - - - - \ No newline at end of file diff --git a/base/src/main/res/navigation/nav_settings.xml b/base/src/main/res/navigation/nav_settings.xml deleted file mode 100644 index fbca7f6d25..0000000000 --- a/base/src/main/res/navigation/nav_settings.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/base/src/main/res/values-pt/strings.xml b/base/src/main/res/values-pt/strings.xml new file mode 100644 index 0000000000..19202eb5bc --- /dev/null +++ b/base/src/main/res/values-pt/strings.xml @@ -0,0 +1,1434 @@ + + + Liberte suas chaves! + O Key Mapper precisa usar um serviço de acessibilidade para detectar e alterar o que seus botões fazem enquanto você estiver fora do aplicativo. Seus mapeamentos de teclas só funcionarão depois que o serviço de acessibilidade for ativado. Ele também precisa estar ligado para criar gatilhos e testar ações. + Selecionado + Habilitar + ¯\\_(ツ)_/¯\n\nNada encontrado! + Requer root + Sem ação + Sem gatilho + Dispositivo desconhecido + Ativo + Desligado + Padrão do sistema + Este dispositivo + Qualquer dispositivo + Padrão + Ativar serviço de acessibilidade + Reiniciar o serviço de acessibilidade + Compartilhar + Nada aqui! + O Mapeador de Chaves não detectou nenhuma interação. Tente mostrar elementos adicionais. + Parar repetição… + Gatilho liberado + Gatilho repetido + Limite atingido + Gatilho liberado + Gatilho repetido + Mostrar apps ocultos + Modificador + IMPORTANTE!!! Essas coordenadas estão corretas apenas quando sua tela está na mesma orientação que a captura de tela! Esta ação cancelará qualquer toque ou gesto que você esteja fazendo na tela.\n\nSe precisar de ajuda para encontrar as coordenadas de um ponto na sua tela, tire uma captura de tela e toque na captura onde você deseja que esta ação pressione. + Nota: Ao usar \"pinçar para dentro\", X e Y são as coordenadas FINAIS; ao usar \"pinçar para fora\", X e Y são as coordenadas INICIAIS. + Ações para correção! + Eventos para correção! + Realizar ações + Pressionado até o gatilho… + Sem dispositivo + ¯\\_(ツ)_/¯\n\nSem extras! + Configuração da ação do evento de tecla concluída + Coordenada selecionada + Key Mapper registros + Novidades + Você pode usar um toque do sistema ou selecionar um arquivo de som personalizado.\n\nO arquivo de som personalizado será copiado para a pasta de dados privada do Key Mapper, o que significa que suas ações ainda funcionarão mesmo se o arquivo + Não foi possível encontrar dispositivos pareados. O bluetooth está ligado? + Toque em um mapa de teclas para usar como atalho. + Criar atalho de mapa de teclas + Ativado + Desabilitado + Redefinir + Visto que você tenha ativado o administrador do dispositivo, deve DESATIVÁ-LO se quiser desinstalar o aplicativo. + Aguarde %sms + Iniciar atividade: %s + Iniciar serviço: %s + Enviar transmissão: %s + ID do mapa principal + É necessária permissão para trabalhar corretamente no modo Não Perturbe! + Este acionador não funcionará enquanto o telefone estiver tocando ou durante uma chamada! + O sistema operacional não permite que os serviços de acessibilidade detectem os pressionamentos do botão de volume enquanto seu telefone está tocando ou durante uma chamada, mas permite que os serviços de método de entrada os detectem. Portanto, você deve usar um dos teclados do aplicativo se quiser que este acionador funcione. + Muitos dedos para realizar o gesto devido às limitações do Android. + A duração do gesto é muito alta devido às limitações do Android. + Você deve usar um teclado Key Mapper para que os gatilhos DPAD funcionem! + Seus mapas de teclas deixarão de funcionar aleatoriamente! + Seus mapas principais estão pausados! + Resumir + O serviço de acessibilidade precisa estar ativado para que seus mapas de teclas funcionem! + Seu telefone finalizou o aplicativo quando estava em segundo plano ou ele travou! + O serviço de acessibilidade está ativado! Seus mapas de teclas devem funcionar. + O registro extra está ativado! Desative isso se você não estiver tentando corrigir um problema. + Desligar + Ative as notificações para receber melhores mensagens na tela, mais ações e atualizações de serviço. + Sobre + + Abrir %s + Digita \'%s\' + Inserir %s%s + Inserir %s%s de %s + Abrir %s + Tela de toque (%d, %d) + Tela de toque (%s) + Deslizar com %d dedo(s) das coordenadas %d/%d para %d/%d em %dms + Deslizar com %d dedo(s) das coordenadas %d/%d para %d/%d em %dms (%s) + %s com %d dedo(s) nas coordenadas %d/%d com uma distância de pinçamento de %dpx em %dms + %s com %d dedo(s) nas coordenadas %d/%d com uma distância de pinçamento de %dpx em %dms (%s) + Ligar para %s + Toca o som: %s + Tocar som desconhecido + + + Opções: + Ações: + Acionar: + Restrições: + Deslize para cima + Deslize para baixo + Deslize para a esquerda + Deslize para a direita + Extras + + + Iniciar X + Iniciar Y + Finalizar X + Finalizar Y + Distância de pinçamento (px) + Tipo de pinçamento + Pinçar para dentro + Pinçar para fora + Código da chave + Do dispositivo + Nome do atalho + Descrição coordenada (opcional) + Entrada de texto + Abrir URL + Discar número + Ação + Categorias + Dados + Pacote + Classe + Nome + Valor (%s) + Descrição para Mapeador de Teclas (obrigatório) + Bandeiras + Descrição do Arquivo de Áudio + Mostrar SSID da rede WiFi + + + Pressione junto + Pressione em sequência + E + OU + Pressionar por curto período + Pressione por mais tempo + Pressione duas vezes + Short + Longo + Double + Confirmado + Negado + Atividade + Serviço + Receptor de Transmissão + + + Gatilho e ações + Eventos e mais + Acionar + Ações + Restrições + Opções + + + + + Escolha %s + “Backup” concluído! + Backup falhou! + Restauração bem sucedida! + A restauração falhou! + Backup automático concluído! + Falha ao realizar o backup automático! + Captura feita + A resolução da captura de tela não corresponde à resolução deste dispositivo! + Copiou UUID para clipboard + Você acionou um mapa de chaves + Copiou registro + Sem sons salvos! + Key Mapper usou Shizuku para permitir WRITE_SECURE_SETTINGS + Key Mapper usou root para permitir WRITE_SECURE_SETTINGS + + + Tempo limite do gatilho de sequência + Atraso de pressão longa + Tempo limite de pressionamento duplo + Atrasar até repetir + Limite repetição + Repita cada… + Duração da vibração + Quantas vezes? + Quantas repetições? + Atraso antes da próxima ação + Duração da pressão + Duração do deslize (ms) + Número de dedos + Coordenadas para definir com captura de tela + Iniciar + Finalizar + Duração do pinça (ms) + Número de dedos + + + %s está em primeiro plano + %s não está visível + %s está reproduzindo mídia + %s não está reproduzindo mídia + %s está conectado + %s está desconectado + A tela está ligada + A tela está desligada + Lanterna desligada + Lanterna ligada + A lanterna frontal está desligada + A lanterna frontal está acesa + E + OU + Aplicativo em primeiro plano + Aplicativo não está em primeiro plano + Dispositivo Bluetooth conectado + Dispositivo Bluetooth desconectado + A tela está ligada + A tela está desligada + Retrato (0°) + Paisagem (90°) + Retrato (180°) + Paisagem (270°) + Retrato (qualquer orientação) + Paisagem (qualquer orientação) + Aplicativo reproduzindo mídia + Aplicativo não reproduzindo mídia + Mídia em reprodução + Nenhuma mídia em reprodução + Lanterna ligada + Lanterna desligada + Wi-Fi ativado + Wi-Fi desativado + Conectado à rede Wi-Fi + Desconectado da rede Wi-Fi + Você terá que digitar o SSID manualmente, pois os aplicativos não têm permissão para consultar a lista de redes Wi-Fi conhecidas no Android 10 e versões mais recentes. + +Deixe em branco se alguma rede Wi-Fi precisar ser correspondida. + Qualquer + Conectado a rede Wi-Fi: %s + Desconectado da rede Wi-Fi: %s + Conectado à qualquer rede Wi-Fi + Desconectado, sem rede Wi-Fi + Método de entrada selecionado + %s foi selecionado + Método de entrada não selecionado + %s Não escolhido + O Dispositivo esta bloqueado + Este dispositivo está desbloqueado + A tela de bloqueio está sendo exibida + A tela de bloqueio não está aparecendo + Chamada telefônica + Fora de chamada + Tocando + Carregando + Descarregando + Retrato (0°) + Paisagem (90°) + Retrato (180°) + Paisagem (270°) + Tempo + Tempo entre %s e %s + Restrição de tempo + Hora de início + Editar hora de início + Hora do término + Editar hora de término + + + Pressione por mais tempo + Pressione duas vezes + + + + + + + Desativa bloqueio de app + Veja dontkillmyapp.com + \n\nApós ler vá para o próximo slide. + Guia do usuário + Reiniciar o serviço de acessibilidade + Desative e ative o serviço de acessibilidade. + Reportar um erro + Escolha o local tocando em \"criar relatório\". A seguir veja como enviar. + Criar relatório + Compartilhar relatório de erros + Você pode enviar via Discord ou GitHub. Anexe o erro na mensagem! + Discord + GitHub + + + Configurações + Sobre + Pesquisa + Ajuda + Reportar erro + Mostrar método de entrada + Salvar + Restaurar + Salvar tudo + Toque e pause + Toque para retomar + Salvar + Alternar mensagens curtas + Copiar + Limpar + + + Adicionar ação + Toque para gravar o gatilho + Gatilhos avançados + NOVO! + Feito + Corrigir + Pressione suas teclas + Adicionar restrição + Selecionar código de tecla + Adicionar extra + Criar atalho de acesso rápido + Crie o atalho manualmente + Guia de Intenção + Ajuda + Selecionar captura de tela (opcional) + Escolher atividade + Definir bandeiras + Sem limite + Escolher arquivo de som + Escolha o toque do sistema + Editar ação + Substituir ação + + + Permissão de super usuário necessária! + Não consigo encontrar a página de configurações de acessibilidade + Alterações não salvas + Você tem alterações não salvas. Se descartá-las, suas edições serão perdidas. + Conceda permissão de root ao Key Mapper no seu aplicativo de gerenciamento de root, como o Magisk. + Conceder permissão WRITE_SECURE_SETTINGS + Um PC/Mac é necessário para conceder esta permissão. Leia o guia online. + Seu dispositivo não parece ter uma página de configurações de serviços de acessibilidade. Toque em \"guia\" para ler o guia online que explica como consertar isso. + As teclas precisam ser listadas de cima para baixo na ordem em que serão pressionadas. + Um gatilho \"sequencial\" tem um tempo limite, diferentemente dos gatilhos paralelos. Isso significa que, após pressionar a primeira tecla, você terá um tempo definido para inserir as demais teclas no gatilho. Todas as teclas que. + O Android não permite que aplicativos vejam dispositivos Bluetooth não emparelhados. Eles só detectam conexão/desconexão. Se o dispositivo Bluetooth já estiver emparelhado ao iniciar o serviço de acessibilidade, reconecte-o para o aplicativo reconhecer. + Backup automático + Alterar local ou desativar o backup automático? + Se você tiver qualquer outro tipo de bloqueio de tela escolhido, como PIN ou padrão, então você não precisa se preocupar. Mas se você tiver um bloqueio de tela por senha, você NÃO conseguirá desbloquear seu telefone se usar o Método de Entrada Básico do Key Mapper, porque ele não possui uma interface gráfica. Você pode conceder ao Key Mapper a permissão WRITE_SECURE_SETTINGS para que ele possa mostrar uma notificação para alternar entre o teclado e o bloqueio de tela. Há um guia sobre como fazer isso se você tocar no ponto de interrogação na parte inferior da tela. + Selecione o método de entrada para ações que exigem um. Você pode alterar isso mais tarde tocando em \"Selecionar teclado para ações\" no menu inferior da tela inicial. + Você precisa escolher o layout de teclado \"Caps Lock para câmera\" para o seu teclado; caso contrário, a tecla Caps Lock ainda bloqueará as letras maiúsculas. Você pode encontrar essa configuração em configurações do dispositivo -> Idiomas e entrada -> Teclado físico -> Toque no seu teclado -> Configurar layouts de teclado. Isso irá remapear a tecla Caps Lock para KEYCODE_CAMERA, permitindo que o Key Mapper a remapeie corretamente.\n\nDepois de fazer isso, você deve remover a tecla de ativação Caps Lock e gravar a tecla Caps Lock novamente. Deve aparecer \"Câmera\" em vez de \"Caps Lock\" se você seguiu os passos corretamente. + Nenhum dispositivo externo conectado. + Instale o Teclado GUI do Key Mapper. + Isto é altamente recomendado! Este é um teclado adequado que você pode usar com o Key Mapper. O que vem embutido no Key Mapper (o Método de Entrada Básico) não possui teclado na tela. Escolha de onde deseja instalá-lo. + Instale o Teclado Leanback do Key Mapper. + Isto é altamente recomendado! Este é um teclado adequado para Android TV que você pode usar com o Key Mapper. O que vem embutido no Key Mapper (o Método de Entrada Básico) não possui teclado na tela. Escolha de onde deseja instalá-lo. + Instale o Teclado GUI do Key Mapper. + Escolha de onde deseja baixá-lo. + Instale o Teclado Leanback do Key Mapper. + Escolha de onde deseja baixá-lo. + Esta ação precisa de algumas configurações extras. + Existem 3 maneiras de configurar seu dispositivo para usar esta ação. Aqui estão as vantagens e desvantagens de cada uma. + +\n\n1. Baixe o Shizuku (recomendado). Você não precisa usar um teclado virtual diferente do que já está usando, mas levará um minuto para configurá-lo sempre que reiniciar o dispositivo. + +\n\n2. Baixe o Teclado GUI do Key Mapper. Este é um teclado virtual que você pode usar com o Key Mapper, mas não poderá usar o teclado que está usando no momento, como o Gboard. + +\n\n3. Não faça nada e use o teclado integrado do Key Mapper. Isso não é recomendado, pois você não terá um teclado virtual ao usar o Key Mapper! Não há vantagens. + Esta ação precisa de algumas configurações extras. + Existem 3 maneiras de configurar seu dispositivo para usar esta ação. Veja as vantagens e desvantagens de cada uma. + +\n\n1. Baixe o Shizuku (recomendado). Você não precisa usar um teclado virtual diferente do que já está usando, mas levará um minuto para configurá-lo sempre que reiniciar o dispositivo. + +\n\n2. Baixe o Teclado Leanback do Key Mapper. Este é um teclado virtual otimizado para Android TV que você pode usar com o Key Mapper, mas não poderá usar o teclado que está usando no momento, como o Gboard. + +\n\n3. Não faça nada e use o teclado integrado do Key Mapper. Isso não é recomendado, pois você não terá um teclado virtual ao usar o Key Mapper! Não há vantagens. + Desativar otimização de bateria + Você DEVE ler tudo isso senão você ficará frustrado no futuro!\n\nTocar em \"corrigir parcialmente\" pode impedir que o Android pare o aplicativo enquanto ele está em segundo plano.\n\nISSO NÃO É SUFICIENTE. A interface do seu OEM, como MIUI ou Samsung Experience, pode ter outros recursos de encerramento de aplicativos, então você DEVE desativá-los para o Key Mapper também, seguindo o guia online em dontkillmyapp.com. + Reinicie o serviço de acessibilidade desligando-o e ligando-o novamente. + Usar este gatilho pode causar uma tela preta quando você desbloqueia seu dispositivo após usar a configuração de fixação de tela nas configurações do seu dispositivo. Isso pode ser corrigido com uma reinicialização. Isso não acontece em todos os dispositivos, então fique atento e desative a configuração se isso ocorrer! + O Key Mapper foi interrompido + O Key Mapper tentou ser executado em segundo plano, mas foi interrompido pelo sistema. Isso pode acontecer se a otimização da bateria ou da memória estiver ativada. Para corrigir isso, tente seguir um guia online. + Prosseguir + Ignorar + Erro ao gerar relatório de erros + Corrigir erro + Você não tem nenhum aplicativo de arquivos instalado que permita criar um arquivo para o Key Mapper. Por favor, instale um gerenciador de arquivos. + Você não tem nenhum aplicativo de arquivos instalado que permita escolher um arquivo para o Key Mapper. Por favor, instale um gerenciador de arquivos. + O serviço de acessibilidade deve estar habilitado + @string/accessibility_service_explanation + Conceder acesso Não Perturbe + Você será levado à página de configurações do seu dispositivo para gerenciar quais aplicativos podem modificar o estado de Não Perturbe. Isso não está presente em alguns dispositivos, então toque em não mostrar novamente se você não vir o Key Mapper na lista. + Bom saber! + Se você vir este símbolo (⌨) ao lado de uma tecla de gatilho, DEVE usar um teclado com Mapeador de Teclas para que ela seja detectada. Esta é uma restrição no Android e é necessária apenas para alguns botões. + Importante! + Você precisa atualizar o teclado da interface gráfica do Key Mapper para que ele seja compatível com esta versão do Key Mapper. Alguns mapas de teclas podem não funcionar até você atualizar! + Atualizar agora + Ignorar + Ordenar por + Arraste as alças para ajustar as prioridades. O item no topo é o mais importante. Você também pode tocar em qualquer item para inverter sua ordem de classificação. + Exemplo: para classificar os mapas principais principalmente por suas Ações em ordem crescente e, secundariamente, por seus Gatilhos em ordem decrescente, mova as Ações para a primeira posição e os Gatilhos para a segunda. + Arraste a alça para %1$s + Mostrar exemplo + Ativar notificações + Algumas ações e opções precisam dessa permissão para funcionar. Você também pode receber notificações quando houver notícias importantes sobre o aplicativo. + Feito + Guia + Guia + Alterar + Corrigir parcialmente + Ok + Reiniciar + Nunca mais mostrar + Aplicar + Descartar alterações + Salvar + Entendido + Ligar + Desligar + Cancelar + Não mostrar novamente + Continue editando + Não, obrigado + Esconder + Guia on-line + Configurações + Documentação + O que mudou + Shizuku + Teclado GUI do Mapeador de Teclas + Teclado Leanback do Mapeador de Teclas + Não faça nada + Corrigir + + + Mapas de teclas de pausa/retomada + Aviso de teclado oculto + Teclado Toggle Key Mapper + Novas funcionalidades + Em execução + Toque para abrir o Key Mapper. + Pausar + Pausado + Toque para abrir o Key Mapper. + Retomar + Descartar + Reiniciar + O serviço de acessibilidade está desativado + Iniciar o serviço de acessibilidade. + O serviço de acessibilidade precisa ser reiniciado! + O serviço de acessibilidade travou! Seu telefone pode estar matando-o agressivamente! Toque para reiniciar o serviço de acessibilidade. + + Parar serviço + O teclado está escondido! + Toque em \'mostrar teclado\' para começar a mostrar o teclado novamente. + Teclado Toggle Key Mapper + Toque em \"alternar\" para alternar entre o teclado do Key Mapper. + Alternar + + + Atraso de pressão longa padrão + Por quanto tempo um botão deve ser pressionado para ser detectado como um pressionamento longo. O padrão é 500 ms. Pode ser substituído nas opções de um mapa de teclas. + Duração padrão do duplo pressionamento + Quão rápido um botão precisa ser pressionado duas vezes para ser detectado como um pressionamento duplo. O padrão é 300 ms. Pode ser substituído nas opções de um mapa de teclas. + Por quanto tempo vibrar se a vibração estiver habilitada para um mapa de teclas. O padrão é 200 ms. Pode ser substituído nas opções de um mapa de teclas. + Duração de vibração padrão + Quanto tempo o gatilho precisa ser mantido pressionado para que a ação comece a se repetir. O padrão é 400 ms. Pode ser substituído nas opções de um mapa de teclas. + Atraso padrão até repetição + O atraso entre cada vez que uma ação é repetida. O padrão é 50 ms. Pode ser substituído nas opções de um mapa de teclas. + Atraso padrão entre repetições + O tempo permitido para completar um gatilho de sequência. O padrão é 1000 ms. Pode ser substituído nas opções de um mapa de teclas. + Tempo limite de disparo de sequência padrão + Redefinir + Faça todos os mapas principais vibrarem + Cada vez que um mapa de teclas é acionado + Mostrar notificação de pausa/retomada + Ative e desative seus mapas principais + Alterar local de backup automático + Ativar backup automático + Faça backup periodicamente dos seus mapas principais + Alterar automaticamente o teclado na tela quando um dispositivo (por exemplo, um teclado) conecta/desconecta + O último teclado Key Mapper usado será automaticamente selecionado quando um dispositivo escolhido for conectado. Seu teclado normal será automaticamente selecionado quando o dispositivo for desconectado. + Alterar automaticamente o teclado na tela quando você começar a inserir texto + O último teclado não Key Mapper usado será selecionado automaticamente quando você tentar abrir o teclado. Seu teclado Key Mapper será selecionado automaticamente quando você parar de usá-lo. + Mensagem na tela + Mostrar ao alterar automaticamente o teclado + Solicitar permissão de root + Se o seu dispositivo estiver com acesso root, isso mostrará o pop-up de permissão de root do Magisk ou do seu aplicativo root. + Escolha o tema + Temas claros e escuros disponíveis + Alterne entre o teclado do Key Mapper e o teclado padrão ao tocar na notificação. + Alternar notificação de teclado do Key Mapper + Alterar automaticamente o teclado ao alternar os mapas de teclas + Selecione automaticamente o teclado do Key Mapper ao retomar seus mapas de teclas e selecione seu teclado padrão ao pausá-los. + Ocultar alertas da tela inicial + Ocultar os alertas na parte superior da tela inicial + Mostrar IDs de dispositivos + Diferencie dispositivos com o mesmo nome + Ativar o registro de depuração extra + Registre logs mais detalhados + Exibir log do Key Mapper + Compartilhe isso com o desenvolvedor se estiver tendo problemas + Compartilhar logcat + Compartilhe todo o log do sistema + Falha ao compartilhar logcat + Reportar problema + Apagar som # + Exclua arquivos de som que podem ser usados para a ação Som. + Conceder permissão + Permissão concedida + 1. Shizuku não está instalado! Toque para baixar o aplicativo Shizuku. + 1. Shizuku está instalado. + 2. Shizuku não foi iniciado! Toque para abrir o aplicativo Shizuku e então leia as instruções que explicam como iniciá-lo. + 2. Shizuku iniciado. + O Key Mapper não tem permissão para usar Shizuku. Toque para conceder essa permissão. + O Key Mapper usará Shizuku automaticamente. Toque para ler quais recursos do Key Mapper usam Shizuku. + Alterar opções padrão + Para gatilhos e ações + Redefinir tudo + PERIGO! + Tem certeza de que deseja redefinir todas as configurações do aplicativo para os padrões? Seus mapas de teclas existentes não serão afetados. As introduções e os pop-ups de aviso serão exibidos novamente. + Sim, redefinir + Redefinir tudo + Personalize sua experiência + Mapas-chave + Gestão de dados + Opções de usuário avançado + Depuração + Notificações + + + Configurações de root + Essas opções só funcionarão em dispositivos root! Se você não sabe o que é root ou se seu dispositivo tem root, não deixe uma avaliação ruim se elas não funcionarem. :) + Requer permissão WRITE_SECURE_SETTINGS + Essas opções só são habilitadas se o Key Mapper tiver a permissão WRITE_SECURE_SETTINGS. Clique no botão abaixo para saber como conceder a permissão. + Suporte Shizuku + Shizuku é um aplicativo que permite que o Key Mapper faça coisas que somente aplicativos de sistema podem fazer. Você não precisa usar o teclado do Key Mapper, por exemplo. Toque para aprender como configurar isso. + Siga estas etapas para configurar o Shizuku + Trocar teclado automaticamente + Troque quando necessário e depois troque novamente + Escolha dispositivos + Escolha quais dispositivos acionam a troca automática de teclado + Registros + Isso pode adicionar latência aos seus mapas de teclas, portanto, ative-o somente se estiver tentando depurar o aplicativo ou se o desenvolvedor tiver solicitado. + Use o modo PRO + Detecção avançada de eventos importantes e muito mais + Luz + Escuro + Sistema + + + Mostrar caixa de diálogo de volume + Vibrar + Mostrar uma mensagem na tela + Vibrar novamente ao pressionar longamente + Repita + %dx + depois de %dms + todo %dms + até ser pressionado novamente + até ser liberado + Repita + Segure firme + Mantenha pressionado até ser pressionado novamente + Não remapeie + Permitir que outros aplicativos acionem este mapa de teclas + Copiar ID do mapa de chaves + + + + Acessibilidade + Alarme + DTMF + Música + Notificações + Anel de Transmissão + Sistema + Chamada de voz + Normal + Vibrar + Silencioso + Frente + Voltar + Alarmes + Prioridade + Nada + + + Pausar mapas de teclas + Retomar mapas-chave + Pausado + Em execução + Serviço desativado + O serviço de acessibilidade do Key Mapper está desabilitado + Teclado Toggle Key Mapper + + + Ctrl + Ctrl+Esquerda + Ctrl+Direita + Alt + Alt + Esquerda + Alt+Direita + Shift + Shift+Seta para a esquerda + ~Deslocar para a direita + Meta + Meta esquerda + Meta direita + Sym + Func + Caps Lock + Num Lock + Scroll Lock + + + Você deve estar usando um dos teclados do Key Mapper para que esta ação funcione! + O aplicativo com nome de pacote %s não está instalado! + Aplicativo %s desativado! + Você precisa conceder permissão ao Key Mapper para modificar as configurações do sistema. + Isso requer permissão de root! + Esta ação requer permissão da câmera! + Requer Android %s ou posterior + Requer Android %s ou posterior + Seu dispositivo não possui uma câmera. + Este dispositivo não oferece suporte ao NFC. + Seu dispositivo não possui um Sensor de Impressão Digital. + Este dispositivo não suporta wifi. + Seu dispositivo não suporta Bluetooth. + Seu dispositivo não oferece suporte à aplicação de políticas de dispositivo. + Seu dispositivo não possui uma câmera. + Seu dispositivo não possui nenhum recurso de telefonia. + Não é possível encontrar a página de configurações do teclado! + O Key Mapper precisa ser um administrador de dispositivo! + O Key Mapper não tem permissão para usar esse atalho + O aplicativo precisa de permissão para alterar o estado Não Perturbe! + Esta ação precisa de permissão para ler o estado do telefone! + Não é possível encontrar a página de permissão WRITE_SETTINGS! + Erro ao abrir este atalho de aplicativo + Não é possível encontrar as configurações de permissão de acesso Não Perturbe! + O Key Mapper precisa da permissão WRITE_SECURE_SETTINGS. + Não há nenhum aplicativo que possa iniciar esta chamada telefônica + A câmera está em uso! + Câmera desconectada! + Câmera desativada! + Erro de câmera! + Número máximo de câmeras em uso! + Sem flash frontal + Sem flash traseiro + Intensidade variável da lanterna não suportada + O serviço de acessibilidade precisa ser habilitado! + O serviço de acessibilidade precisa ser reiniciado! + Seu inicializador não suporta atalhos. + Um teclado Key Mapper precisa ser habilitado! + Não é possível encontrar o método de entrada %s + O seletor de método de entrada não pode ser exibido! + Falha ao encontrar o elemento de acessibilidade! + Falha ao executar a ação global %s! + Configurações de otimização de bateria não encontradas! Se existir, abra manualmente. + Extra (%s) não encontrado! + Você não pode ter restrições duplicadas! + Não pode estar vazio! + Dispositivo não encontrado! + Arquivo JSON vazio! + Acesso ao arquivo negado! %s + Erro de I/O desconhecido! + Cancelado! + Número inválido! + Deve ser pelo menos %s! + Deve ser no máximo %s! + A otimização da bateria está ativada! Desative isso porque isso pode fazer com que o Key Mapper pare de funcionar aleatoriamente. + Permissão de acesso à notificação negada! + Inválido! + Permissão negada para iniciar chamadas telefônicas! + Você precisará atualizar o Key Mapper para a versão mais recente para usar este backup. + Não há assistente de voz instalado! + Permissões insuficientes + Você só tem teclados Key Mapper instalados! + Não há aplicativos reproduzindo mídia! + Arquivo de origem não encontrado! %s + Arquivo de destino não encontrado! %s + Falha ao inserir gesto! + Falha ao modificar a configuração do sistema %s! + Você precisa habilitar %s! + Falha ao mudar o IME! + Seu dispositivo não tem aplicativo de câmera! + Seu dispositivo não tem assistente! + Seu dispositivo não tem aplicativo de configurações! + Nenhum aplicativo pode abrir esta URL! + Não é uma pasta! %s + Não é um arquivo! %s + Diretório não encontrado! %s + Não é possível encontrar o arquivo de som! + Permissão de armazenamento negada! + A origem e o destino não podem ser os mesmos! + Não há espaço restante no alvo! %s + Permissão Shizuku negada! + Shizuku não começou! + Este arquivo não tem nome! + Arquivo inválido. Deve ser um arquivo zip exportado do Key Mapper. + Você deve conceder permissão ao Key Mapper para ver seus dispositivos Bluetooth pareados. + URL incorreta. Você esqueceu o http://? + Negada permissão para ler localização precisa! + Permissão negada para atender e encerrar chamadas telefônicas! + Permissão negada para ver dispositivos Bluetooth pareados! + Permissão negada para mostrar notificações! + Devem ser 2 ou mais! + Deve ser %d ou menos! + Deve ser maior que 0! + Deve ser maior que 0! + Deve ser maior que 0! + Deve ser maior que 0! + Deve ser %d ou menos! + Elemento de interface do usuário não encontrado! + O modo PRO precisa ser iniciado + + + Alternar WiFi + Habilitar WiFi + Desativar WiFi + Alternar Bluetooth + Habilitar Bluetooth + Desativar Bluetooth + Aumentar o volume + Diminuir volume + Volume mudo + Alternar mudo + Reativar volume + Mostrar caixa de diálogo de volume + Aumentar o fluxo + Aumentar fluxo %s + Diminuir fluxo + Diminuir fluxo %s + Modos de toque de ciclo (Normal, Vibrar, Silencioso) + Modos de toque de ciclo (Normal, Vibrar) + Alterar modo de toque + Alterar para o modo %s + Alternar modo Não perturbe + Alternar somente o modo %s DND + Ativar o modo Não perturbe + Habilitar somente o modo %s DND + Desativar o modo Não perturbe + Habilitar rotação automática + Desativar rotação automática + Alternar rotação automática + Modo retrato + Modo paisagem + Mudar orientação + Percorrer as rotações + Percorrer %s rotações + Alternar dados móveis + Habilitar dados móveis + Desativar dados móveis + Alternar brilho automático + Desativar brilho automático + Habilitar brilho automático + Aumentar o brilho da tela + Diminuir o brilho da tela + Expandir gaveta de notificações + Alternar gaveta de notificações + Expandir configurações rápidas + Alternar gaveta de configurações rápidas + Recolher barra de status + Pausar reprodução de mídia + Pausar a reprodução de mídia para um aplicativo + Pausar mídia por %s + Retomar reprodução de mídia + Retomar a reprodução de mídia para um aplicativo + Retomar mídia para %s + Reproduzir/Pausar reprodução de mídia + Reproduzir/Pausar reprodução de mídia para um aplicativo + Reproduzir/Pausar mídia para %s + Próxima faixa + Próxima faixa para um aplicativo + Próxima faixa para %s + Faixa anterior + Faixa anterior para um aplicativo + Faixa anterior para %s + Avanço rápido + Avanço rápido para um aplicativo + Avanço rápido para %s + Nem todos os aplicativos de mídia suportam avanço rápido. Por exemplo, Google Play Music. + Rebobinar + Retroceder para um aplicativo + Rebobinar para %s + Nem todos os aplicativos de mídia suportam retrocesso. Por exemplo, Google Play Music. + Pare a mídia + Parar mídia para um aplicativo + Parar mídia para %s + Dê um passo à frente na mídia + Dê um passo à frente na mídia para um aplicativo + Avance a mídia para %s + Retroceder na mídia + Retroceder a mídia para um aplicativo + Retroceder a mídia por %s + Volte + Ir para casa + Abertura recente + Abrir menu + Alternar tela dividida + Ir para o último aplicativo. (Pressione duas vezes em recentes) + Alternar lanterna + Habilitar lanterna + Desativar lanterna + Alternar lanterna + Alternar lanterna (%s) + Habilitar lanterna + Habilitar lanterna (%s) + Desativar lanterna + Alterar o brilho da lanterna + Aumentar o brilho da lanterna %s + Lanterna fraca %s + Alternar lanterna frontal + Alternar lanterna frontal (%s) + Habilitar lanterna frontal + Habilitar lanterna frontal (%s) + Desativar lanterna frontal + Alterar o brilho da lanterna frontal + Aumentar o brilho da lanterna frontal %s + Lanterna frontal fraca %s + Habilitar NFC + Desativar NFC + Alternar NFC + Captura de tela + Iniciar assistente de voz + Assistente de dispositivo de inicialização + Abra a câmera + Dispositivo de bloqueio + Dispositivo de bloqueio seguro + Você só poderá fazer login novamente com seu PIN. O scanner de impressão digital e o desbloqueio facial serão desabilitados. Esta é a única maneira confiável que encontrei para bloquear dispositivos não rooteados antes do Android Pie 9 + Dispositivo de dormir/acordar + Não faça nada + Mover o cursor + Mover para o caractere anterior + Mover para o próximo caractere + Mover para a palavra anterior + Mover para a próxima palavra + Mover para a linha anterior + Mover para a próxima linha + Mover para o início do parágrafo + Ir para o final do parágrafo + Mover para o início da página + Ir para o final da página + Alternar teclado + Esta ação só funcionará se você tiver tocado em um campo de entrada onde o teclado deveria ser exibido. + Mostrar teclado + Ocultar teclado + Mostrar seletor de teclado + Trocar teclado + Mudar para %s + Corte + Copiar + Colar + Selecionar palavra no cursor + Abrir configurações + Mostrar menu de energia + Alternar modo avião + Habilitar modo avião + Desativar modo avião + Iniciar aplicativo + Alguns dispositivos exigem que os aplicativos tenham permissão para serem iniciados em segundo plano. Toque em \"Saiba mais\" para ver as instruções em nosso site. + Ler mais + Ignorar + Aviso! + Atalho para iniciar aplicativo + Código de chave de entrada + Evento de tecla de entrada + Tela de toque + Deslize a tela + Tela de pinça + Texto de entrada + Abrir URL + Enviar intenção + Iniciar chamada telefônica + Atender chamada telefônica + Terminar chamada telefônica + Tocar som + Descartar notificação mais recente + Descartar todas as notificações + Tela de controles do dispositivo + Solicitação HTTP + Método HTTP + Descrição + Não pode estar vazio! + URL + Não pode estar vazio! + URL incorreta. Você esqueceu o http://? + Corpo da solicitação (opcional) + Cabeçalho de autorização (opcional) + Você deve acrescentar \'Bearer\' se necessário + Interaja com o elemento do aplicativo + O Key Mapper pode detectar e interagir com elementos do aplicativo, como menus, abas, botões e caixas de seleção. Você precisa gravar sua interação com o aplicativo para que o Key Mapper saiba o que você quer fazer. + Inciar gravação + Parar gravação (%s min restantes) + + %d elemento detectado + %d elementos detectados + + Escolha o aplicativo para interagir + Grave novamente + Escolha o elemento do aplicativo + Escolha o elemento com o qual você deseja que seu mapa de teclas interaja. + Não consegue encontrar o que procura? + Nem todos os aplicativos são compatíveis. Para aplicativos incompatíveis, você pode tentar a ação \"Tocar na Tela\". + Possíveis interações + Selecione como você deseja interagir com o elemento da interface do usuário. + Tipo de interação do filtro + Mostrar elementos adicionais + Qualquer + Tocar + Toque e segure + Foco + Rolar para frente + Rolar para trás + Expandir + Colapso + Desconhecido: %d + Detalhes da interação + Descrição + Aplicativo + Descrição do texto/conteúdo + Nome da classe + Dica de ferramenta/dica + Exibir ID do recurso + ID exclusivo + Tipos de interação + + + Navegação + Volume + Mídia + Teclado + Aplicativos + Entrada + Lanterna + Conectividade + Interface + Telefone + Tela + Notificações + Especial + + + Booleano + Matriz booleana + Inteiro + Matriz de inteiros + String + Matriz de strings + Long + Mariz long + Byte + Matriz de bytes + Double + Matriz dupla + Char + Matriz de caracteres + Float + Matriz float + Short + Matriz short + Só pode ser \"true\" ou \"false\" + Uma lista separada por vírgulas de \"true\" e \"false\". Por exemplo, true, false, true + Um inteiro válido na linguagem de programação Java. + Uma lista separada por vírgulas de inteiros válidos na linguagem de programação Java. Por exemplo, 100.399 + Uma lista separada por vírgulas. Por exemplo, categoria1, categoria2 + Qualquer texto. + Uma lista de strings separadas por vírgulas. Por exemplo string1, string2 + Um Long válido na linguagem de programação Java. + Uma lista separada por vírgulas de Longs válidos na linguagem de programação Java. Por exemplo, 102302234234234,399083423234429 + Um Byte válido na linguagem de programação Java. + Uma lista separada por vírgulas de bytes válidos na linguagem de programação Java. Por exemplo, 123,3 + Um Double válido na linguagem de programação Java. + Uma lista separada por vírgulas de Doubles válidos na linguagem de programação Java. Por exemplo 1.0,3.234 + Um Char válido na linguagem de programação Java. Por exemplo, \'a\' ou \'b\' + Uma lista separada por vírgulas de Chars válidos na linguagem de programação Java. Por exemplo, a,b,c + Um Float válido na linguagem de programação Java. Por exemplo 3.145 + Uma lista separada por vírgulas de Floats válidos na linguagem de programação Java. Por exemplo 1241.123 + Um Short válido na linguagem de programação Java. Por exemplo 2342 + Uma lista separada por vírgulas de Shorts válidos na linguagem de programação Java. Por exemplo 3242,12354 + As flags para um Intent são armazenadas como flags de bits. Essas flags alteram a maneira como o Intent é tratado. Se isso estiver em branco para um Intent de Atividade, o Key Mapper usará FLAG_ACTIVITY_NEW_TASK por padrão. Para obter muito mais informações, toque em \'docs\' para ver a documentação dos desenvolvedores Android. + + + Pular tutorial + Crie seu primeiro mapa de teclas! + Um mapa de teclas é uma regra que informa ao seu dispositivo o que fazer quando um botão é pressionado. + Escolha uma ação + Uma ação é o que deve acontecer quando você pressiona o gatilho. + Escolha uma restrição (opcional) + Se você quiser que o mapa de teclas funcione apenas em determinadas situações, por exemplo, quando um aplicativo estiver aberto. + + + GitHub + Site + Traduções + Versão %s + Avaliar + O que mudou + Discord + Coisas chatas + Licença + A licença de código aberto para este aplicativo. + Política de Privacidade + Não coletamos nenhuma informação pessoal, mas aqui está uma política de privacidade dizendo isso. + Nossa equipe + Desenvolvedor + Designer de experiência do usuário + Tradutor (Polonês) + Tradutor (Checo) + Tradutor (Espanhol) + + + Mapeador de teclas: tecla lateral + Qualquer assistente + Tecla lateral/botão liga/desliga + Assistente de voz + Gatilhos avançados + O desenvolvedor não acredita que anúncios sejam uma forma sustentável ou fácil de usar de monetização, então esses gatilhos pagos ajudam a apoiar o desenvolvimento\u00A0❤️. Você também terá suporte prioritário. + Tecla\u00A0Lateral & botão assistente + Você sabia que pode remapear a tecla lateral, o botão liga/desliga ou o assistente do dispositivo? Em vez de iniciar o assistente ou o menu liga/desliga, seu dispositivo pode executar uma ação de sua escolha. Funciona mesmo quando! + Você deve adquirir o recurso de gatilho de tecla lateral. + Saber mais + Você deve comprar botões flutuantes. + O botão foi excluído. + Botão flutuante + A compra não pode ser verificada. Você tem conexão com a internet? + + Desbloquear (%s) + Usar + Carregando… + Comprado! + Tentar buscar o preço novamente + Compra cancelada. + Isso requer um recurso pago que só pode ser adquirido baixando o Key Mapper no Google Play. + Ocorreu um erro de rede. Você tem conexão com a internet? + Este produto não foi encontrado. + O Google Play encontrou um erro. + Estamos aguardando a confirmação do Google Play de que sua compra foi bem-sucedida. Abra novamente o aplicativo Key\u00A0Mapper quando a cobrança for concluída com sucesso no seu cartão. + Compra inválida. + Pagamento pendente + Algo deu errado 😕 + Tentar novamente + Entre em contato com o desenvolvedor + Você precisa adquirir o recurso de gatilho de tecla lateral! Toque no mapa de teclas e adquira-o clicando em \"Gatilhos avançados\". + Você precisa adquirir o recurso de botões flutuantes! Toque no mapa de teclas e adquira-o clicando em \"Gatilhos avançados\". + Obrigado por apoiar o aplicativo\u00A0❤️! + Sua compra foi bem-sucedida. Como usuário pagante do Key\u00A0Mapper, você receberá suporte prioritário para usar o aplicativo. Agora há um botão na loja para entrar em contato com o desenvolvedor. + Os gatilhos avançados são um recurso pago, mas você baixou a versão FOSS do Key Mapper, que não inclui este módulo nem a cobrança do Google Play. Baixe o Key Mapper no Google Play para obter acesso. + Baixar Play build + + Quer remapear os botões do DPAD? + Você deve configurar o teclado GUI do Key Mapper seguindo as etapas abaixo. + 1. Instale o aplicativo de teclado + Instalar + Instalado + 2. Habilitar o teclado + Habilitar + Ativado + 3. Use o teclado + Alterar teclado + Teclado selecionado + Configuração concluída! Toque em \"Concluído\" e o gatilho do DPAD deverá funcionar. + + Botão não detectado? + Você pode tentar usar o aplicativo Key Mapper GUI Keyboard para gravar seu gatilho em vez do serviço de acessibilidade. + Configuração concluída! Toque em \"Concluído\" e tente gravar o gatilho novamente. Se não funcionar, o Android não permitirá que ele seja remapeado 🫤. + + Tocar + para mostrar/ocultar este menu. + Você pode colocar botões em qualquer aplicativo, até mesmo na tela de bloqueio. + Descartar + Ajuda + Alternar layout + Adicionar botão + Volte + Ocultar botões + Mostrar botões + Excluir + Configurar + Usar gatilho + Excluir + Texto do botão (Dica: use um emoji) + Tamanho do botão: + Opacidade da borda: + Opacidade de fundo: + Cancelar + Feito + O botão deve ter texto! + Botões flutuantes + Botões flutuantes são exibidos sobre os aplicativos que você deseja. Eles funcionam como botões reais, e você pode posicioná-los, estilizá-los e mapeá-los como quiser. + Botão flutuante %s (%s) + Botão flutuante excluído + Restrições + Deseja que este botão apareça na tela apenas em alguns aplicativos? Adicione uma restrição \"Aplicativo em primeiro plano\" para este mapa de teclas na aba \"Restrições\". + Escolha um layout + Voltar + Ajuda + Precisa de ajuda? + Botão Criar + Botão Escolher + Desistir + Você precisa criar um botão flutuante antes de poder usá-lo como um gatilho. + Você precisa escolher um botão flutuante para usar como gatilho. + Botão Configurar + Editar layout + Requer Android 11 ou mais recente. + Não tem botões suficientes? Agora você pode fazer os seus! + Botões flutuantes são exibidos sobre os aplicativos que você deseja. Eles funcionam como botões reais, e você pode posicioná-los, estilizá-los e mapeá-los como quiser. + + Acionar + Ações + Restrições + Opções + Mapas-chave + Botões flutuantes + Novo mapa de teclas + Novo layout + Toque em um mapa de teclas para configurá-lo.\nPressione e segure para mais opções. + Crie um mapa de teclas! + Não tem botões suficientes? + Faça o seu próprio! + Botões flutuantes são exibidos sobre os aplicativos que você deseja. Eles funcionam como botões reais, e você pode posicioná-los, estilizá-los e mapeá-los como quiser. + Ocultar esta página + Obrigado por apoiar o Key\u00A0Mapper\u00A0❤️! + Crie seu primeiro layout! + Os layouts permitem organizar botões flutuantes em grupos. Eles não afetam quais botões podem ser exibidos. Qualquer botão de qualquer layout pode ser exibido ao mesmo tempo. + Alterar nome do layout %s + Excluir layout %s + Botões flutuantes + Sem botões + Botões de edição + Adicionar botões + Alterar nome do layout + Salvar + O nome não pode estar vazio! + O nome deve ser único! + Excluir %s + Tem certeza de que deseja excluir este layout de botões flutuantes? + Sim, apague + Cancelar + Ocultar layouts flutuantes + Você pode encontrar botões flutuantes no botão Gatilhos avançados ao criar um gatilho. + Botões flutuantes + Menu + Organizar + Mais + Ajuda + Selecionar tudo + Desmarcar tudo + Pare de selecionar + Subir um grupo + Pausado + + 1 aviso + %d avisos + + Em execução + Configurações + Excluir grupo + Sobre + Exportar tudo + Importar + Escolha o teclado + Importando… + Importação realizada com sucesso! + Exportador… + Falha: %s + Carregando arquivo… + Importação bem-sucedida! + Importando… + Erro + Abra o aplicativo + + Importando 1 mapa de teclas + Importando %d mapas de teclas + + Gostaria de substituir todos os seus mapas de teclas existentes ou adicioná-los à lista? + Cancelar + Descartar + Acrescentar + Substituir + Duplicado + Excluir + Exportar + Ativado + Desabilitado + Misturado + Mover para o grupo + + Excluir 1 mapa de teclas + Excluir %d mapas de teclas + + Tem certeza de que deseja excluir esses mapas principais? + Sim, apague + Cancelar + Salvar em arquivos + Novo grupo + Novo subgrupo + Ver tudo + Esconder + Restrições de grupo + Nova restrição + Excluir restrição de grupo + Este grupo + + Remover + Editar + Opções de tecla de gatilho + Dispositivo + Tipo de assistente + Tipo de gesto de impressão digital + Tipo de clique + Use o botão flutuante + Use o gesto de impressão digital + Use o gatilho da tecla lateral + Botão flutuante + Gatilho de tecla lateral + Gesto de impressão digital + Botão flutuante: %s + Deslize para cima no leitor de impressão digital + Deslize para baixo no leitor de impressão digital + Deslize para a esquerda no leitor de impressão digital + Deslize para a direita no leitor de impressão digital + Gatilhos avançados + Código-chave %d + Código de digitalização %d + Código de digitalização + As chaves podem ser identificadas por um \"código de chave\" ou um \"código de leitura\". Um código de leitura é mais exclusivo do que um código de chave, mas seu gatilho pode não funcionar em outros dispositivos. Recomendamos o uso de códigos de chave. + Código de chave desconhecido + Use o código de chave %d + Use o código de digitalização %d + Nenhum código de digitalização salvo + Use o modo PRO + Adicionar mais + + Remover + Editar + Teste + Adicione ações para definir o que o mapa de teclas deve fazer quando acionado. + Ações usadas recentemente + Opções de ação + Redefinir + Padrão: %s + Escolha uma ação + Procurar… + Nada aqui! + Ação de exclusão + Tem certeza de que deseja excluir esta ação? + Sim, apague + Cancelar + Execute estas ações ao acionar o mapa de teclas: + Escolha o lado + Brilho + Mínimo + Metade + Máx + Teste + Requer Android 13 ou mais recente. + Este dispositivo não permite alterar o brilho. + Mudança de brilho + Sem suporte + + Adicione restrições se quiser que os mapas de teclas funcionem apenas em algumas situações. + Restrições usadas recentemente + Remover + Excluir restrição + Tem certeza de que deseja excluir esta restrição? + Sim, apague + Cancelar + Modo lógico + Escolha uma restrição + Este mapa de teclas só será executado se: + + Grupo sem título + Editar nome do grupo + Salvar nome do grupo + O nome deve ser único! + Lar + Excluir grupo + Excluir grupo %s + Tem certeza de que deseja excluir este grupo? Todos os mapas principais deste grupo e seus subgrupos também serão excluídos! + Sim, apague + Cancelar + + +%d restrição herdada + +%d restrições herdadas + + + Modo PRO + Importante! + Remapear botões no modo PRO é perigoso e pode fazer com que eles parem de funcionar se você os remapear incorretamente.\n\nSe você cometer um erro, pode ser necessário reiniciar o dispositivo à força segurando os botões de ligar e volume por 30 segundos — consulte o manual do seu dispositivo ou a internet para saber como fazer isso. + %d… + Eu entendo + Entendido + Configurar + Root detectado + Você pode pular o processo de configuração concedendo permissão de root ao Key Mapper. Isso permitirá que o Key Mapper inicie automaticamente o modo PRO na inicialização. + Iniciar modo PRO + Shizuku detectada + Você pode pular o processo de configuração dando permissão ao Key Mapper Shizuku. + Iniciar Shizuku + Solicitar permissão + Iniciar modo PRO + Configurar com o Key Mapper + Continuar + Continuar (Android 11+) + Opções + Habilitar modo PRO para todos os mapas principais + O Key Mapper usará o ADB Shell para remapeamento + Essas configurações ficarão indisponíveis até que você confirme o aviso. + O serviço do modo PRO está em execução + Parar + Iniciar automaticamente na inicialização + O Modo PRO será iniciado sempre que você ligar ou reiniciar seu dispositivo + Dica de emergência + Se o botão liga/desliga parar de funcionar, mantenha-o pressionado por 10 segundos e solte para desativar o Modo PRO. + Assistente de configuração + Etapa %d de %d + Use o assistente de configuração interativo + Interagir automaticamente com as configurações + Habilitar primeiro o serviço de acessibilidade + Assista ao tutorial + Iniciar serviço + Vá para as configurações + Iniciar serviço + Ativar serviço de acessibilidade + O Key Mapper usa um serviço para ajudar você a configurar o modo PRO. Ele também é útil para mapeamentos de teclas comuns. + Habilitar opções do desenvolvedor + O Key Mapper precisa usar o Android Debug Bridge para iniciar o modo PRO, e você precisa habilitar as opções do desenvolvedor para isso. + Conectar a uma rede WiFi + O Key Mapper precisa de uma rede Wi-Fi para habilitar o ADB. Você não precisa de conexão com a internet.\n\nSem rede Wi-Fi? Use um ponto de acesso do celular de outra pessoa. + Habilitar depuração sem fio + O Key Mapper usa depuração sem fio para iniciar seu serviço de remapeamento e entrada. + Emparelhar depuração sem fio + O Key Mapper precisa ser pareado com a depuração sem fio antes de poder iniciar seu serviço de remapeamento e entrada. + Iniciar serviço + O Key Mapper precisa se conectar ao Android Debug Bridge para iniciar o serviço do modo PRO. + Permitir notificações + O Key Mapper precisa de permissão para notificá-lo caso haja algum problema com o processo de configuração. + Dê permissão + Assistente de configuração + O modo PRO está em execução + Agora você pode remapear botões quando a tela estiver desligada e usar mais ações. + Terminar + Habilitar opções do desenvolvedor + Toque repetidamente no número da compilação + Emparelhamento automático + Procurando código de pareamento e porta… + Não é possível encontrar a porta e o código de emparelhamento + Toque no botão para parear com o código de pareamento e digite o código aqui + Falha ao iniciar o modo PRO + Toque para configurar novamente. Tente o pareamento ADB e reinicie o telefone se o problema persistir. + Modo PRO de inicialização automática + Usando root + Usando shizuku + Usando ADB via WiFi + Modo PRO iniciado + Divirta-se remapeando! ❤️ + Falha no emparelhamento + Mantenha o pop-up do código de emparelhamento na tela ao enviar o código de emparelhamento + Código de pareamento de entrada + O que posso fazer com o modo PRO? + 📲 Você pode remapear mais botões, como o botão de energia. +⌨️ Use qualquer teclado com ações de código de tecla. +⭐️ As seguintes ações estão desbloqueadas: Wi-Fi, Bluetooth, dados móveis, NFC, modo avião, recolher barra de status e ligar/desligar a tela do dispositivo. + Mostrar informações do modo PRO + Descartar + O modo PRO parou inesperadamente + Reiniciando automaticamente… + Não reinicia automaticamente. Se você não estiver encerrando o serviço, informe o problema ao desenvolvedor. + + Descobrir + O que você quer remapear? + Botões no dispositivo + Periféricos & jogos + Botões flutuantes + Volume + Assistente + Liga/Desliga + Gesto de impressão digital + Teclado + Mouse + Gamepad + Outro + Personalizado + Entalhe + Tela de bloqueio + Aumente o nível do seu telefone + Botões flutuantes são atalhos instantâneos em qualquer aplicativo, jogo ou na tela de bloqueio. Seu apoio mantém o Key Mapper vivo ❤️ + Veja em ação + Descartar + Desbloqueie por apenas %s + + + Botão de volume + Teclado + Botão liga/desliga + Gatilho assistente + Complemento de gatilho assistente + Botão do mouse + Botão do gamepad + Outro botão + Gesto do leitor de impressão digital + Nenhum gatilho detectado + Toque para adicionar gatilho + Requisitos não atendidos + Obter ajuda + Você não pode remapear este botão + Você não pode usar este recurso + Você pode remapear este botão + Você pode remapear este dispositivo + Você pode usar esse recurso + Você pode remapear este botão + Você pode remapear este dispositivo + Você pode usar este recurso + Remapear quando a tela estiver desligada + Opções + Requisitos + Se seus botões ainda não forem detectados, entre em nosso servidor do Discord e nos avise. Faremos o possível para ajudar você a remapear seus botões. + Se seus botões não puderem ser detectados, entre em nosso servidor do Discord e nos avise. Faremos o possível para ajudar você a remapear seus botões. + Informação + Serviço de acessibilidade + Habilitar + Em execução + Comprado + Modo PRO + Ativar gratuitamente + Em execução + Não disponível nesta versão do Android + Se o assistente do seu dispositivo for acionado por um botão de hardware, talvez seja possível remapeá-lo gratuitamente com o modo PRO. Tente escolher \"Outro\" na página de acionamento e siga as instruções. + Este gatilho requer alguma configuração que varia de acordo com o dispositivo. Por favor, + leia as instruções + Leia as instruções + Selecione o tipo de gatilho + Se o assistente do seu dispositivo for acionado pelo botão liga/desliga, talvez seja possível remapear o assistente, o que significa que você não precisa usar o modo PRO. Tente escolher \"Assistente\" na página de acionamento. + Botão D-Pad + Outros botões simples + Você não poderá usar o teclado normal na tela ao remapear os botões DPAD. + Teclado Mapeador de Teclas + Habilitar teclado + Escolha o teclado + Em execução + + diff --git a/base/src/main/res/values-tr/strings.xml b/base/src/main/res/values-tr/strings.xml index 4342892e0e..8175a0e00b 100644 --- a/base/src/main/res/values-tr/strings.xml +++ b/base/src/main/res/values-tr/strings.xml @@ -5,9 +5,7 @@ seçildi Etkinleştir ¯\\_(ツ)_/¯\n\nBurada hiçbir şey yok! - İlk adım, tuş eşlemesini tetikleyecek bazı düğmeler eklemektir.\n\nÖnce ‘Tetikleyici Kaydet’ seçeneğine dokunun ve ardından yeniden eşlemek istediğiniz düğmelere basın. Bunlar burada görünecek.\n\nAlternatif olarak, bir tuş eşlemesini ‘gelişmiş tetikleyici’ kullanarak tetikleyebilirsiniz.\n\nİstediğiniz tuşları karıştırıp eşleştirebilirsiniz! Root gerektirir - Bas… Eylem yok Tetikleyici yok Bilinmeyen cihaz adı @@ -56,9 +54,7 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Servis başlat: %s Yayın gönder: %s Tuş eşleme kimliği - Kabuğu kullan (Yalnızca ROOT) Rahatsız Etmeyin modunda düzgün çalışması için izin gerekli! - Ekran kapalıyken tetikleme seçeneği, çalışması için root izni gerektirir! Bu tetikleyici, telefon çalarken veya görüşme sırasında çalışmaz! Android, telefonunuz çalarken veya bir görüşme sırasında erişilebilirlik servislerinin ses düğmesi basışlarını algılamasına izin vermez, ancak giriş yöntemi servisleri bunu algılayabilir. Bu nedenle, bu tetikleyicinin çalışmasını istiyorsanız Key Mapper klavyelerinden birini kullanmalısınız. Android sınırlamaları nedeniyle harekette çok fazla parmak var. @@ -78,7 +74,6 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. %s uygulamasını aç ‘%s’ yaz %s%s gir - Kabuk üzerinden %s gir %s cihazından %s%s gir %s uygulamasını aç Ekrana dokun (%d, %d) @@ -131,13 +126,16 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. WiFi ağ SSID’si - Aynı anda - Sırayla + Birlikte bas + Sırayla bas VE VEYA Kısa basış Uzun basış Çift basış + Kısa + Uzun + Çift Doğru Yanlış Etkinlik @@ -304,12 +302,12 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Eylem ekle - Tetikleyici kaydet + Tetikleyiciyi kaydetmek için dokunun Gelişmiş tetikleyiciler YENİ! Bitti Düzelt - Kaydediyor (%d…) + Tuşlarınıza basın Kısıtlama ekle Tuş kodu seç Ekstra ekle @@ -331,16 +329,15 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Erişilebilirlik ayarları sayfası bulunamadı Kaydedilmemiş değişiklikler Kaydedilmemiş değişiklikleriniz var. Bunları iptal ederseniz, düzenlemeleriniz kaybolacak. - Telefonunuzun rootlu olmadığını biliyorsanız veya rootun ne olduğunu bilmiyorsanız, yalnızca rootlu cihazlarda çalışan özellikleri kullanamazsınız. ‘Tamam’a dokunduğunuzda ayarlara yönlendirileceksiniz. - Ayarlarda, en alta kaydırın ve root özelliklerini/eylemlerini kullanabilmek için ‘Key Mapper root iznine sahip’ seçeneğine dokunun. + Lütfen Magisk gibi root yönetim uygulamanızda Key Mapper\'a root izni verin. WRITE_SECURE_SETTINGS izni ver Bu izni vermek için bir PC/Mac gereklidir. Çevrimiçi kılavuzu okuyun. Cihazınızda erişilebilirlik servisleri ayarları sayfası yok gibi görünüyor. Bunu nasıl düzelteceğinizi açıklayan çevrimiçi kılavuzu okumak için “kılavuz” seçeneğine dokunun. Tuşlar, basılı tutulacakları sırayla yukarıdan aşağıya listelenmelidir. Bir “sıra” tetikleyicisi, paralel tetikleyicilerden farklı olarak bir zaman aşımına sahiptir. Bu, ilk tuşa bastıktan sonra tetikleyicideki diğer tuşları girmeniz için belirli bir süreniz olacağı anlamına gelir. Tetikleyiciye eklediğiniz tüm tuşlar, zaman aşımı süresine ulaşılana kadar normal işlevlerini yerine getirmez. Bu zaman aşımını “Seçenekler” sekmesinden değiştirebilirsiniz. Android, uygulamaların bağlı (eşleştirilmemiş) Bluetooth cihazlarının listesini almasına izin vermez. Uygulamalar yalnızca bu cihazların bağlandığını ve bağlantısının kesildiğini algılayabilir. Bu nedenle, Bluetooth cihazınız erişilebilirlik servisi başladığında zaten bağlıysa, uygulamanın bunu bilmesi için cihazı yeniden bağlamanız gerekecek. + Otomatik yedekleme Konumu değiştir veya otomatik yedeklemeyi kapat? - Ekran açma/kapama kısıtlamaları, yalnızca “ekran kapalıyken tetikleyiciyi algıla” tuş eşleme seçeneğini açtıysanız çalışır. Bu seçenek, bazı tuşlar (örneğin ses düğmeleri) için ve yalnızca rootluysanız görünür. Desteklenen tuşların listesini Yardım sayfasında görebilirsiniz. PIN veya Desen gibi başka bir ekran kilidiniz varsa endişelenmenize gerek yok. Ancak yalnızca Parola ekran kilidi kullanıyorsanız, Key Mapper Temel Giriş Yöntemi’ni kullanırsanız telefonunuzun kilidini açamazsınız çünkü bu yöntemin bir arayüzü yoktur. Key Mapper’a WRITE_SECURE_SETTINGS izni vererek klavyeyi değiştirmek için bir bildirim gösterebilirsiniz. Bunu nasıl yapacağınızı öğrenmek için ekranın altındaki soru işaretine dokunun. Eylemler için bir giriş yöntemi gerektiren bir yöntem seçin. Bunu daha sonra ana ekranın alt menüsündeki “Eylemler için klavye seç” seçeneğiyle değiştirebilirsiniz. Caps Lock tuşunun hala büyük harf kilitlemesini engellemek için klavyenizde “Caps Lock’u kameraya” klavye düzenini seçmeniz gerekir. Bu ayarı cihaz ayarlarınızda -> Diller ve Giriş -> Fiziksel Klavye -> Klavyenize dokunun -> Klavye Düzenlerini Ayarla bölümünden bulabilirsiniz. Bu, Caps Lock tuşunu KEYCODE_CAMERA’ya yeniden eşleyerek Key Mapper’ın bunu doğru şekilde eşlemesini sağlar.\n\nBunu yaptıktan sonra Caps Lock tetikleyici tuşunu kaldırıp Caps Lock tuşunu tekrar kaydetmelisiniz. Adımları doğru yaptıysanız “Caps Lock” yerine “Kamera” yazmalıdır. @@ -396,8 +393,6 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Örnek: Tuş eşlemelerini öncelikle Eylemlerine göre artan sırayla ve ikincil olarak Tetikleyicilerine göre azalan sırayla sıralamak için Eylemleri birinci sıraya, Tetikleyicileri ikinci sıraya taşıyın. %1$s için tutamaç Örnek göster - Tanınmayan tuş kodu - Basılı düğme, giriş sistemi tarafından tanınmadı. Geçmişte Key Mapper bu tür düğmeleri tek bir düğme olarak algılıyordu. Şu anda uygulama, düğmeyi tarama koduna göre ayırt etmeye çalışıyor; bu, daha benzersiz olmalıdır. Ancak bu, benzersizliği garanti etmeyen geçici ve eksik bir çözümdür. Bildirimleri aç Bazı eylemler ve seçenekler bu izni gerektirir. Ayrıca uygulama hakkında önemli haberler olduğunda bildirim alabilirsiniz. Bitti @@ -430,13 +425,10 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Düzelt - Klavye seçici Tuş eşlemelerini duraklat/devam ettir Klavye gizli uyarısı Key Mapper klavyesini aç/kapat Yeni özellikler - Klavyenizi değiştirmek için dokunun. - Klavye seçici Çalışıyor Key Mapper’ı açmak için dokunun. Duraklat @@ -458,38 +450,34 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Aç/Kapat - Varsayılan uzun basış gecikmesi (ms) + Varsayılan uzun basış gecikmesi Bir düğmenin uzun basış olarak algılanması için ne kadar süre basılı tutulması gerektiği. Varsayılan 500ms’dir. Bir tuş eşlemesinin seçeneklerinde geçersiz kılınabilir. - Varsayılan çift basış süresi (ms) + Varsayılan çift basış süresi Bir düğmenin çift basış olarak algılanması için ne kadar hızlı çift basılması gerektiği. Varsayılan 300ms’dir. Bir tuş eşlemesinin seçeneklerinde geçersiz kılınabilir. Bir tuş eşlemesi için titreşim etkinse ne kadar süre titreşeceği. Varsayılan 200ms’dir. Bir tuş eşlemesinin seçeneklerinde geçersiz kılınabilir. - Varsayılan titreşim süresi (ms) + Varsayılan titreşim süresi Eylemin tekrarlanmaya başlaması için tetikleyicinin ne kadar süre basılı tutulması gerektiği. Varsayılan 400ms’dir. Bir tuş eşlemesinin seçeneklerinde geçersiz kılınabilir. - Varsayılan tekrar gecikmesi (ms) + Tekrara kadar varsayılan gecikme Bir eylemin her tekrar arasındaki gecikme. Varsayılan 50ms’dir. Bir tuş eşlemesinin seçeneklerinde geçersiz kılınabilir. - Varsayılan tekrarlar arası gecikme (ms) + Tekrarlar arası varsayılan gecikme Bir sıra tetikleyicisini tamamlamak için izin verilen süre. Varsayılan 1000ms’dir. Bir tuş eşlemesinin seçeneklerinde geçersiz kılınabilir. - Varsayılan sıra tetikleyici zaman aşımı (ms) + Varsayılan sıra tetikleyici zaman aşımı Sıfırla - Tüm tuş eşlemelerini titreşime zorla. - Titreşimi zorla - Klavye seçici bildirimi - Klavye seçmenize olanak tanıyan kalıcı bir bildirim göster. - Tuş eşlemelerini duraklat/devam ettir bildirimi - Tuş eşlemelerinizi başlatan/duraklatan kalıcı bir bildirim göster. - Tuş eşlemelerini belirtilen bir konuma otomatik olarak yedekle - Konum seçilmedi. - Cihazları seç - Klavye seçiciyi otomatik olarak göster - Seçtiğiniz bir cihaz bağlandığında veya bağlantısı kesildiğinde klavye seçici otomatik olarak gösterilir. Aşağıdan cihazları seçin. + Tüm tuş eşlemelerini titreştir + Bir tuş eşlemesi her tetiklendiğinde + Duraklat/devam ettir bildirimini göster + Tuş eşlemelerinizi açıp kapatın + Otomatik yedekleme konumunu değiştir + Otomatik yedeklemeyi aç + Tuş eşlemelerinizi periyodik olarak yedekleyin Bir cihaz (örneğin klavye) bağlandığında/bağlantısı kesildiğinde ekran klavyesini otomatik olarak değiştir Seçilen bir cihaz bağlandığında son kullanılan Key Mapper klavyesi otomatik olarak seçilir. Cihazın bağlantısı kesildiğinde normal klavyeniz otomatik olarak seçilir. Metin girmeye başladığınızda ekran klavyesini otomatik olarak değiştir Klavyeyi açmaya çalıştığınızda son kullanılan Key Mapper dışı klavye otomatik olarak seçilir. Klavyeyi kullanmayı bıraktığınızda Key Mapper klavyeniz otomatik olarak seçilir. - Klavyeyi otomatik olarak değiştirirken ekranda bir mesaj göster - Key Mapper root iznine sahip - Yalnızca rootlu cihazlarda çalışan özellikleri/eylemleri kullanmak istiyorsanız bunu etkinleştirin. Bu özelliklerin çalışması için Key Mapper’ın root erişim yönetim uygulamanızdan (örneğin Magisk, SuperSU) root izni almış olması gerekir. - Bunu yalnızca cihazınızın rootlu olduğunu biliyor ve Key Mapper’a root izni verdiyseniz açın. + Ekran üstü mesaj + Klavyeyi otomatik olarak değiştirirken göster + Root izni iste + Cihazınız rootlu ise bu, Magisk\'ten veya root uygulamanızdan root izni penceresini gösterecektir. Tema seç Açık ve koyu temalar mevcut Bildirime dokunduğunuzda Key Mapper klavyesi ile varsayılan klavyeniz arasında geçiş yapın. @@ -497,22 +485,16 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Tuş eşlemelerini açarken/kapatırken klavyeyi otomatik olarak değiştir Tuş eşlemelerinizi devam ettirdiğinizde Key Mapper klavyesini, duraklattığınızda ise varsayılan klavyenizi otomatik olarak seçin. Ana ekran uyarılarını gizle - Ana ekranın üstündeki uyarıları gizle. - Cihaza özgü tetikleyiciler için cihaz kimliğinin ilk 5 karakterini göster - Bu, aynı ada sahip cihazları ayırt etmek için kullanışlıdır. - ABD İngilizcesine ayarlanmış klavyeleri düzelt - Bu, bir erişilebilirlik servisi etkinleştirildiğinde doğru klavye düzenine sahip olmayan klavyeleri düzeltir. Daha fazla bilgi okuyup yapılandırmak için dokunun. - ABD İngilizcesine ayarlanmış klavyeleri düzelt - Android 11’de bir hata var; bir erişilebilirlik servisi açıldığında Android, tüm harici cihazları aynı dahili sanal cihaz olarak görüyor. Bu cihazları doğru şekilde tanımlayamadığı için hangi klavye düzenini kullanacağını bilemiyor ve örneğin bir Alman klavyesi olsa bile varsayılan olarak ABD İngilizcesini kullanıyor. Aşağıdaki adımları izleyerek Key Mapper ile bu sorunu çözebilirsiniz. - 4. Cihazları seç - 1. Key Mapper GUI Klavyesini yükle (isteğe bağlı) - 1. Key Mapper Leanback Klavyesini yükle (isteğe bağlı) - 2. Key Mapper GUI Klavyesini veya Key Mapper Temel Giriş Yöntemi’ni etkinleştir - 2. Key Mapper Leanback Klavyesini veya Key Mapper Temel Giriş Yöntemi’ni etkinleştir - 3. Az önce etkinleştirdiğiniz klavyeyi kullan - (Önerilen) Bu ayar için kullanıcı kılavuzunu okuyun. + Ana ekranın üstündeki uyarıları gizle + Cihaz kimliklerini göster + Aynı ada sahip cihazları ayırt et Ekstra günlüğü etkinleştir - Günlüğü görüntüle ve paylaş + Daha ayrıntılı günlükler kaydet + Key Mapper günlüğünü görüntüle + Sorun yaşıyorsanız bunu geliştiriciyle paylaşın + Logcat\'i paylaş + Tüm sistem günlüğünü paylaş + Logcat paylaşılamadı Sorun bildir Ses dosyalarını sil Ses eylemi için kullanılabilecek ses dosyalarını sil. @@ -524,37 +506,45 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. 2. Shizuku başlatıldı. 3. Key Mapper’ın Shizuku’yu kullanma izni yok. Bu izni vermek için dokunun. 3. Key Mapper otomatik olarak Shizuku’yu kullanacak. Key Mapper’ın hangi özelliklerinin Shizuku’yu kullandığını okumak için dokunun. - Varsayılan eşleme seçenekleri - Tuş eşlemeleriniz için varsayılan seçenekleri değiştirin. - Tüm ayarları sıfırla - TEHLİKE! Uygulamadaki tüm ayarları varsayılana sıfırla. Tuş eşlemeleriniz sıfırlanMAYACAK. + Varsayılan seçenekleri değiştir + Tetikleyiciler ve eylemler için + Tümünü sıfırla TEHLİKE! - Uygulamadaki tüm ayarları varsayılana sıfırlamak istediğinizden emin misiniz? Tuş eşlemeleriniz sıfırlanMAYACAK. Giriş ekranı ve tüm uyarı pop-up’ları tekrar görünecek. + Uygulamadaki tüm ayarları varsayılana sıfırlamak istediğinizden emin misiniz? Mevcut tuş eşlemeleriniz etkilenmeyecektir. Tanıtımlar ve uyarı pencereleri tekrar gösterilecektir. Evet, sıfırla + Tümünü sıfırla + Deneyiminizi özelleştirin + Tuş eşlemeleri + Veri yönetimi + Güçlü kullanıcı seçenekleri + Hata ayıklama + Bildirimler - Klavye seçiciyi otomatik olarak göster - Klavye seçiciyi otomatik olarak göstermeye olanak tanıyan ayarları görmek için dokunun. Root ayarları Bu seçenekler yalnızca rootlu cihazlarda çalışır! Rootun ne olduğunu veya cihazınızın rootlu olup olmadığını bilmiyorsanız, bunlar çalışmazsa lütfen kötü bir inceleme bırakmayın. :) WRITE_SECURE_SETTINGS izni gerektirir Bu seçenekler yalnızca Key Mapper WRITE_SECURE_SETTINGS iznine sahipse etkinleşir. İzni nasıl vereceğinizi öğrenmek için aşağıdaki düğmeye tıklayın. Shizuku desteği Shizuku, Key Mapper’ın yalnızca sistem uygulamalarının yapabileceği şeyleri yapmasını sağlayan bir uygulamadır. Örneğin, Key Mapper klavyesini kullanmanız gerekmez. Bunu nasıl kuracağınızı öğrenmek için dokunun. - Shizuku’yu kurmak için bu adımları izleyin. + Shizuku\'yu kurmak için bu adımları izleyin Klavyeyi otomatik olarak değiştir - Bunlar gerçekten kullanışlı ayarlar ve kontrol etmeniz önerilir! + Gerektiğinde değiştir, sonra geri dön + Cihazları seç + Hangi cihazların otomatik klavye değiştirmeyi tetikleyeceğini seçin Günlük kaydı Bu, tuş eşlemelerinize gecikme ekleyebilir, bu yüzden yalnızca uygulamayı hata ayıklamaya çalışıyorsanız veya geliştirici tarafından istenmişse açın. + PRO modunu kullan + Gelişmiş tuş olayı algılama ve daha fazlası + Açık + Koyu + Sistem - - Ses diyaloğunu göster Titre Ekranda mesaj göster Uzun basışta tekrar titre - Ekran kapalıyken tetikleyiciyi algıla Tekrarla %dx %dms sonra @@ -714,6 +704,7 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. 0\'dan büyük olmalı! %d veya daha az olmalı! UI öğesi bulunamadı! + PRO Modunun başlatılması gerekiyor WiFi\'yi aç/kapat @@ -830,7 +821,6 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Cihazı güvenli bir şekilde kilitle Tekrar giriş yapmak için yalnızca PIN\'inizi kullanabilirsiniz. Parmak izi tarayıcı ve yüz tanıma devre dışı bırakılacaktır. Bu, Android Pie 9.0 öncesi root olmayan cihazları kilitlemenin bulduğum tek güvenilir yoludur. Cihazı uyut/uyandır - Ekran kapalıyken tetikleyiciyi algılama seçeneğini açmanız gerekiyor! Hiçbir şey yapma İmleci taşı Önceki karaktere git @@ -986,10 +976,6 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Öğreticiyi geç İlk anahtar haritanı oluştur! Bir anahtar haritası, bir düğmeye basıldığında cihazınızın ne yapacağını belirten bir kuraldır. - Tetikleyiciyi kaydet - Kaydet\'e dokun ve ardından değiştirmek istediğin fiziksel düğmelere bas. - Key Mapper\'ı seviyor musunuz? ❤️ - Uygun bir fiyata, ekran üstü yüzen düğmelerle güçlü anahtar haritaları oluşturun ve daha harika özelliklerin geliştirilmesini destekleyin 👨‍💻. Bir eylem seçin Bir eylem, tetikleyiciye bastığınızda olması gereken şeydir. Bir kısıtlama seç (isteğe bağlı) @@ -1029,15 +1015,10 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Kayan düğmeleri satın almanız gerekiyor. Düğme silindi. Kayan düğme - Yan tuş tetikleyicisini ayarla - Dikkat! - Bu tetikleyiciyi nasıl ayarlayacağınızı açıklayan web sitemizdeki talimatları okumanız gerekiyor. Key Mapper size rehberlik etmeyecek. - Talimatları oku - Tetikleyici türünü seç Satın alma doğrulanamıyor. İnternet bağlantınız var mı? Kilidi aç (%s) - Kullan + Kullan Yükleniyor… Satın alındı! Fiyatı tekrar almayı dene @@ -1055,7 +1036,7 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Yan tuş tetikleyici özelliğini satın almanız gerekiyor! Tuş eşlemesine dokunun ve ardından \'Gelişmiş tetikleyiciler\'e tıklayarak satın alın. Kayan düğmeler özelliğini satın almanız gerekiyor! Tuş eşlemesine dokunun ve ardından \'Gelişmiş tetikleyiciler\'e tıklayarak satın alın. Uygulamayı desteklediğiniz için teşekkürler ❤️! - Satın alma işleminiz başarılı oldu. Key Mapper\'ın ücretli bir kullanıcısı olarak uygulamayı kullanmanıza yardımcı olmak için öncelikli destek alacaksınız. Artık bu sayfada geliştiriciyle iletişime geçmek için bir düğme var. + Satın alımınız başarılı oldu. Key Mapper\'ın ücretli bir kullanıcısı olarak uygulamayı kullanmanıza yardımcı olmak için öncelikli destek alacaksınız. Artık mağazada geliştiriciyle iletişime geçmek için bir düğme bulunmaktadır. Gelişmiş tetikleyiciler ücretli bir özelliktir ancak siz FOSS yapısını indirdiniz ve bu yapı Google Play faturalandırmasını içermiyor. Bu özelliğe erişmek için lütfen Key Mapper\'ı Google Play\'den indirin. Play sürümünü indir @@ -1233,6 +1214,16 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Parmak izi okuyucuda sola kaydır Parmak izi okuyucuda sağa kaydır Gelişmiş tetikleyiciler + Tuş kodu %d + Tarama kodu %d + Kodu tara + Tuşlar, bir \'key code\' (tuş kodu) ya da \'scan code\' (tarama kodu) ile tanımlanabilir. Bir tarama kodu, tuş kodundan daha benzersizdir ancak tetikleyiciniz başka cihazlarda çalışmayabilir. Tuş kodlarını kullanmanızı öneririz. + Bilinmeyen tuş kodu + Tuş kodu %d kullan + Tarama kodu %d kullan + Kaydedilmiş tarama kodu yok + PRO modu ile kaydet + Daha fazla ekle Kaldır Düzenle @@ -1286,4 +1277,158 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. +%d devralınan kısıtlamalar +%d devralınan kısıtlamalar + + PRO modu + Önemli! + PRO modu ile tuşları yeniden eşlemek tehlikelidir ve yanlış eşlerseniz çalışmalarını durdurabilir.\n\nBir hata yaparsanız, güç ve ses düğmelerini 30 saniye basılı tutarak cihazınızı zorla yeniden başlatmanız gerekebilir — bunun nasıl yapılacağı konusunda cihazınızın kılavuzuna veya internete başvurun. + %d… + Anlıyorum + Anladım + Kurulum + Root algılandı + Key Mapper\'a root izni vererek kurulum sürecini atlayabilirsiniz. Bu, Key Mapper\'ın açılışta PRO modunu otomatik olarak başlatmasını da sağlayacaktır. + PRO modunu başlat + Shizuku algılandı + Key Mapper\'a Shizuku izni vererek kurulum sürecini atlayabilirsiniz. + Shizuku\'yu başlat + İzin iste + PRO modunu başlat + Key Mapper ile kur + Devam et + Devam et (Android 11+) + Seçenekler + Tüm tuş eşlemeleri için PRO modunu etkinleştir + Key Mapper yeniden eşleme için ADB Shell\'i kullanacak + Bu ayarlar, uyarıyı kabul edene kadar kullanılamaz. + PRO modu hizmeti çalışıyor + Durdur + Açılışta otomatik olarak başlat + Cihazınızı her açtığınızda veya yeniden başlattığınızda PRO Modu kendini başlatacaktır + Acil durum ipucu + Güç düğmeniz çalışmazsa, PRO Modu\'nu devre dışı bırakmak için güç düğmesini 10 saniye basılı tutun ve bırakın. + Kurulum sihirbazı + Adım %d / %d + Etkileşimli kurulum asistanını kullan + Ayarlarla otomatik olarak etkileşime gir + Önce erişilebilirlik servisini etkinleştir + Eğitimi izle + Servisi başlat + Ayarlara git + Servisi başlat + Erişilebilirlik servisini etkinleştir + Key Mapper, PRO modunu kurmanıza yardımcı olmak için bir servis kullanır. Sıradan tuş eşlemeleri için de kullanışlıdır. + Geliştirici seçeneklerini etkinleştir + Key Mapper\'ın PRO modunu başlatmak için Android Debug Bridge\'i kullanması gerekir ve bunun için geliştirici seçeneklerini etkinleştirmeniz gerekir. + Bir WiFi ağına bağlan + Key Mapper\'ın ADB\'yi etkinleştirmek için bir WiFi ağına ihtiyacı var. İnternet bağlantısına ihtiyacınız yok.\n\nWiFi ağı yok mu? Başka birinin telefonundan bir hotspot kullanın. + Kablosuz hata ayıklamayı etkinleştir + Key Mapper, yeniden eşleme ve giriş hizmetini başlatmak için kablosuz hata ayıklamayı kullanır. + Kablosuz hata ayıklamayı eşleştir + Key Mapper\'ın yeniden eşleme ve giriş hizmetini başlatabilmesi için kablosuz hata ayıklama ile eşleşmesi gerekir. + Servisi başlat + Key Mapper\'ın PRO modu hizmetini başlatmak için Android Debug Bridge\'e bağlanması gerekir. + Bildirimlere izin ver + Key Mapper\'ın kurulum sürecinde herhangi bir sorun olması durumunda sizi bilgilendirmek için izne ihtiyacı var. + İzin ver + Kurulum asistanı + PRO modu çalışıyor + Artık ekran kapalıyken tuşları yeniden eşleyebilir ve daha fazla eylem kullanabilirsiniz. + Bitir + Geliştirici seçeneklerini etkinleştir + Yapı numarasına tekrar tekrar dokun + Otomatik olarak eşleştiriliyor + Eşleştirme kodu ve portu aranıyor… + Eşleştirme portu ve kodu bulunamadı + Eşleştirme koduyla eşleştirmek için düğmeye dokunun ve kodu buraya yazın + PRO Modu başlatılamadı + Tekrar kurmak için dokunun. Tekrar tekrar başarısız olursa ADB eşleştirmeyi ve telefonunuzu yeniden başlatmayı deneyin. + PRO modu otomatik başlatılıyor + Root kullanılıyor + Shizuku kullanılıyor + WiFi üzerinden ADB kullanılıyor + PRO modu başlatıldı + Yeniden eşlemede iyi eğlenceler! ❤️ + Eşleştirme başarısız + Eşleştirme kodunu gönderirken eşleştirme kodu açılır penceresini ekranda tutun + Eşleştirme kodunu gir + PRO modu ile ne yapabilirim? + 📲 Güç düğmeniz gibi daha fazla tuşu yeniden eşleyebilirsiniz.\n⌨️ Tuş kodu eylemleriyle herhangi bir klavyeyi kullanın.\n⭐️ Aşağıdaki eylemlerin kilidi açılır: WiFi, Bluetooth, mobil veri, NFC ve uçak modu, durum çubuğunu daralt ve cihazı uyut/uyandır. + PRO modu bilgilerini göster + Kapat + PRO modu beklenmedik şekilde durdu + Otomatik olarak yeniden başlatılıyor… + Otomatik olarak yeniden başlatılmıyor. Servisi siz durdurmuyorsanız, sorunu geliştiriciye bildirin. + + Keşfet + Neyi yeniden eşlemek istersiniz? + Cihaz üzerindeki düğmeler + Çevre birimleri ve oyun + Kayan düğmeler + Ses + Asistan + Güç + Parmak izi hareketi + Klavye + Fare + Oyun kumandası + Diğer + Özel + Çentik + Kilit ekranı + Telefonunuzu bir üst seviyeye taşıyın + Kayan düğmeler, herhangi bir uygulama, oyun veya kilit ekranınızdaki anlık kısayollardır. Desteğiniz Key Mapper\'ı hayatta tutar ❤️ + Çalışırken görün + Kapat + Sadece %s karşılığında kilidi açın + + + Ses düğmesi + Klavye + Güç düğmesi + Asistan tetikleyicisi + Asistan tetikleyici eklentisi + Fare düğmesi + Oyun kumandası düğmesi + Diğer düğme + Parmak izi okuyucu hareketi + Tetikleyici algılanmadı + Tetikleyici eklemek için dokunun + Gereksinimler karşılanmadı + Yardım al + Bu düğmeyi yeniden eşleyemezsiniz + Bu özelliği kullanamazsınız + Bu düğmeyi yeniden eşleyebilirsiniz + Bu cihazı yeniden eşleyebilirsiniz + Bu özelliği kullanabilirsiniz + Bu düğmeyi yeniden eşleyebilirsiniz + Bu cihazı yeniden eşleyebilirsiniz + Bu özelliği kullanabilirsiniz + Ekran kapalıyken yeniden eşle + Seçenekler + Gereksinimler + Düğmeleriniz hala algılanamıyorsa, lütfen Discord sunucumuza katılın ve bize bildirin. Düğmelerinizi yeniden eşlemenize yardımcı olmak için elimizden gelenin en iyisini yapacağız. + Düğmeleriniz algılanamıyorsa lütfen Discord sunucumuza katılın ve bize bildirin. Düğmelerinizi yeniden eşlemenize yardımcı olmak için elimizden gelenin en iyisini yapacağız. + Bilgi + Erişilebilirlik servisi + Etkinleştir + Çalışıyor + Satın alındı + PRO modu + Ücretsiz etkinleştir + Çalışıyor + Bu Android sürümünde mevcut değil + Cihaz asistanınız bir donanım düğmesiyle tetikleniyorsa, PRO moduyla ücretsiz olarak yeniden eşlemek mümkün olabilir. Tetikleyici sayfasından \'Diğer\'i seçmeyi deneyin ve talimatları izleyin. + Bu tetikleyici, cihaza göre değişen bazı kurulumlar gerektirir. Lütfen\ + talimatları okuyun + Talimatları oku + Tetikleyici türünü seç + Cihaz asistanınız güç düğmesiyle tetikleniyorsa, asistanı yeniden eşlemek mümkün olabilir, bu da PRO modunu kullanmanıza gerek olmadığı anlamına gelir. Tetikleyici sayfasından \'Asistan\'ı seçmeyi deneyin ve talimatları izleyin. + Yön tuşu (D-Pad) düğmesi + Diğer basit düğmeler + Yön tuşu (DPAD) düğmelerini yeniden eşlerken normal ekran klavyenizi kullanamayacaksınız. + Key Mapper Klavyesi + Klavyeyi etkinleştir + Klavye seç + Çalışıyor + diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 032e8d07ce..188a2d3f59 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -4,13 +4,11 @@ Key Mapper requires the use of an accessibility service so that it can detect and change what your button presses do while you are outside of the app. Your key maps will only work once you have enabled the accessibility service. It must also be turned on to create a trigger and test actions. selected - Key Mapper Basic Input Method + Key Mapper Input Method Enable ¯\\_(ツ)_/¯ ¯\\_(ツ)_/¯\n\nNothing here! - The first step is to add some buttons that will trigger the key map.\n\nFirst tap ‘Record trigger’ and then press the buttons that you want to remap. They will appear here.\n\nAlternatively, you can trigger a key map using an ‘advanced trigger’.\n\nYou can mix and match any keys! Requires root - Press… No actions No trigger @@ -72,15 +70,16 @@ Send broadcast: %s Key map id - Use shell (ROOT only) Permission required to work properly in Do Not Disturb mode! - The option to trigger when the screen is off needs root permission to work! This trigger won\'t work while ringing or in a phone call! - Android doesn\'t let accessibility services detect volume button presses while your phone is ringing or it is in a phone call but it does let input method services detect them. Therefore, you must use one of the Key Mapper keyboards if you want this trigger to work. + Android doesn\'t let accessibility services detect volume button presses while your phone is ringing or it is in a phone call but it does let input method services detect them. Therefore, you must use the Key Mapper Input Method if you want this trigger to work. Too many fingers to perform gesture due to android limitations. Gesture duration is too high due to android limitations. - You must be using a Key Mapper keyboard for DPAD triggers to work! + You must be using the Key Mapper Input Method for DPAD triggers to work! + PRO mode is unsupported on this Android version + PRO mode not started! + Trigger device not connected! Your key maps will stop working randomly! Your key maps are paused! @@ -97,7 +96,6 @@ Open %s Type \'%s\' Input %s%s - Input %s through shell Input %s%s from %s Open %s Tap screen (%d, %d) @@ -141,6 +139,8 @@ Text to input Url to open Phone number to call + Phone number to send SMS + Message to send Action Categories @@ -156,13 +156,16 @@ - At the same time - In sequence + Press together + Press in sequence AND OR Short press Long press Double press + Short + Long + Double True False Activity @@ -198,6 +201,8 @@ You have saved no sound files! Key Mapper has used Shizuku to grant itself WRITE_SECURE_SETTINGS permission Key Mapper has used Root to grant itself WRITE_SECURE_SETTINGS permission + Microphone muted + Microphone unmuted @@ -261,10 +266,7 @@ WiFi is off Connected to a WiFi network Disconnected from a WiFi network - You will have to type the SSID manually because apps aren\'t allowed to query the list of known WiFi networks on Android 10 and newer. - - Leave it empty if any WiFi network should be matched. - Any + Leave it empty if any WiFi network should be matched. Connected to %s WiFi Disconnected from %s WiFi Connected to any WiFi @@ -286,6 +288,10 @@ Charging Discharging + Hinge closed + Hinge open + Hinge is closed + Hinge is open Portrait (0°) Landscape (90°) @@ -309,7 +315,7 @@ https://play.google.com/store/apps/details?id=io.github.sds100.keymapper https://github.com/keymapperorg/KeyMapper/blob/master/LICENSE.md - https://github.com/keymapperorg/KeyMapper/blob/master/CHANGELOG.md + https://github.com/keymapperorg/KeyMapper/blob/develop/CHANGELOG.md https://docs.keymapper.club/contributing/#translating?utm_source=in_app https://github.com/keymapperorg/KeyMapper/blob/master/PRIVACY_POLICY.md https://discord.gg/Suj6nyw @@ -327,6 +333,7 @@ https://docs.keymapper.club/redirects/trigger-key-options https://docs.keymapper.club/redirects/android-11-device-id-bug-work-around https://docs.keymapper.club/redirects/cant-find-accessibility-settings + https://docs.keymapper.club/redirects/restricted-settings https://docs.keymapper.club/redirects/shizuku-benefits https://docs.keymapper.club/redirects/settings https://developer.android.com/reference/android/content/Intent#setFlags(int) @@ -339,13 +346,8 @@ https://docs.keymapper.club/redirects/floating-layouts https://docs.keymapper.club/redirects/floating-button-config - https://play.google.com/store/apps/details?id=io.github.sds100.keymapper.inputmethod.latin - https://f-droid.org/en/packages/io.github.sds100.keymapper.inputmethod.latin - https://github.com/keymapperorg/KeyMapperKeyboard/releases - - https://github.com/keymapperorg/KeyMapperLeanbackKeyboard/releases - https://github.com/keymapperorg/KeyMapper/issues/new/choose + https://youtube.com/shorts/v7l2JYP14L0?feature=share @@ -393,12 +395,12 @@ Add action - Record trigger + Tap to record trigger Advanced triggers NEW! Done Fix - Recording (%d…) + Press your keys Add constraint Choose Key code Add extra @@ -421,59 +423,28 @@ Can\'t find the accessibility settings page Unsaved changes You have unsaved changes. If you discard them, your edits will be lost. - If you know your phone isn\'t rooted or you don\'t know what root is, you can\'t use features which only work on rooted devices. When you tap \'OK\', you will be taken to the settings. - In the settings, scroll to the bottom and tap \'Key Mapper has root permission\' so you can use root features/actions. + Please grant Key Mapper root permission in your root management app, such as Magisk. Grant WRITE_SECURE_SETTINGS permission A PC/Mac is required to grant this permission. Read the online guide. Your device doesn\'t seem to have an accessibility services settings page. Tap \"guide\" to read the online guide that explains how to fix this. - The keys need to be listed from top to bottom in the order that they will be held down. - A \"sequence\" trigger has a timeout unlike parallel triggers. This means after you press the first key, you will have a set amount of time to input the rest of the keys in the trigger. All the keys that you have added to the trigger won\'t do their usual action until the timeout has been reached. You can change this timeout in the "Options" tab. + You must hold down the keys in the order that they are listed. + There is a timeout to input this trigger. You can change this timeout in the "Options" tab. + How to use this trigger + Sequence triggers Android doesn\'t allow apps to get a list of connected (not paired) Bluetooth devices. Apps can only detect when they are connected and disconnected. So if your Bluetooth device is already connected to your device when the accessibility service starts, you will have to reconnect it for the app to know it is connected. + Automatic backup Change location or turn off automatic back up? - Screen on/off constraints will only work if you have turned on the \"detect trigger when screen is off\" key map option. This option will only show for some keys (e.g volume buttons) and if you are rooted. See a list of supported keys on the Help page. - If you have any other screen lock chosen, such as PIN or Pattern then you don\'t have to worry. But if you have a Password screen lock you will *NOT* be able to unlock your phone if you use the Key Mapper Basic Input Method because it doesn\'t have a GUI. You can grant Key Mapper WRITE_SECURE_SETTINGS permission so it can show a notification to switch to and from the keyboard. There is a guide on how to do this if you tap the question mark at the bottom of the screen. + If you have any other screen lock chosen, such as PIN or Pattern then you don\'t have to worry. But if you have a Password screen lock you will *NOT* be able to unlock your phone if you use the Key Mapper Input Method because it doesn\'t have a GUI. You can grant Key Mapper WRITE_SECURE_SETTINGS permission so it can show a notification to switch to and from the keyboard. There is a guide on how to do this if you tap the question mark at the bottom of the screen. Select the input method for actions that require one. You can change this later by tapping \"Select keyboard for actions\" in the bottom menu of the home screen. - You need to choose the \"Caps Lock to camera\" keyboard layout for your keyboard otherwise the Caps Lock key will still lock caps. You can find this setting in your device settings -> Languages and Input -> Physical Keyboard -> Tap on your keyboard -> Set Up Keyboard Layouts. This will remap the Caps Lock key to KEYCODE_CAMERA so Key Mapper can remap it properly.\n\nAfter you\'ve done this you must remove the Caps Lock trigger key and record the Caps Lock key again. It should say \"Camera\" instead of \"Caps Lock\" if you did the steps correctly. No external devices connected. - Install the Key Mapper GUI Keyboard - This is highly recommended! This is a proper keyboard that you can use with Key Mapper. The one built-in to Key Mapper (the Basic Input Method) has no on-screen keyboard. Choose where you want to install it from. - - Install the Key Mapper Leanback Keyboard - This is highly recommended! This is a proper keyboard for Android TV that you can use with Key Mapper. The one built-in to Key Mapper (the Basic Input Method) has no on-screen keyboard. Choose where you want to install it from. - - Install the Key Mapper GUI Keyboard - Choose where you want to download it from. - - Install the Key Mapper Leanback Keyboard - Choose where you want to download it from. - - This action needs some extra setting up - There are 3 ways that you can set up your device to use this action. Here are the advantages and disadvantages of each. - - \n\n1. Download Shizuku (recommended). You do not have to use a different on-screen keyboard to the one that you are already using but it will require a minute of setting up every time you reboot your device. - - \n\n2. Download the Key Mapper GUI Keyboard. This is an on-screen keyboard that you can use with Key Mapper but you will not be able to use the keyboard that you are currently using, such as Gboard. - - \n\n3. Do nothing and use the built-in Key Mapper keyboard. This is not recommended since you will have no on-screen keyboard when you use Key Mapper! There are no advantages. - - This action needs some extra setting up - There are 3 ways that you can set up your device to use this action. Here are the advantages and disadvantages of each. - - \n\n1. Download Shizuku (recommended). You do not have to use a different on-screen keyboard to the one that you are already using but it will require a minute of setting up every time you reboot your device. - - \n\n2. Download the Key Mapper Leanback Keyboard. This is an on-screen keyboard optimised for Android TV that you can use with Key Mapper but you will not be able to use the keyboard that you are currently using, such as Gboard. - - \n\n3. Do nothing and use the built-in Key Mapper keyboard. This is not recommended since you will have no on-screen keyboard when you use Key Mapper! There are no advantages. - Disable battery optimisation You MUST read this all otherwise you will get frustrated in the future!\n\nTapping \"fix partially\" might prevent Android from stopping the app while it is in the background.\n\nThis is NOT ENOUGH. Your OEM\'s skin such as MIUI or Samsung Experience may have other app killing features so you MUST turn them off for Key Mapper as well by following the online guide at dontkillmyapp.com. Restart the accessibility service by turning it off and on. - Using this trigger may cause a black screen when you unlock your device after using the screen pinning setting in your device\'s settings. This can be fixed with a reboot. This doesn\'t happen on all devices so beware and turn off the setting if it does! 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. @@ -490,30 +461,23 @@ Accessibility service must be enabled @string/accessibility_service_explanation + You may need to allow Restricted Settings. + Tap to read instructions. + Grant Do Not Disturb access You will be taken to your device\'s settings page to manage which apps can modify the Do Not Disturb state. This is not present on some devices so tap don\'t show again if you do not see Key Mapper in the list. - Good to know! - If you see this symbol (⌨) next to a trigger key then you MUST use a Key Mapper keyboard for it to be detected. This is a restriction in Android and is only required for some buttons. - - Important! - You must update the Key Mapper GUI Keyboard so it is compatible with this version of Key Mapper. Some key maps may not work until you update! - Update now - Ignore - Sort by Drag the handles to adjust priorities. The item at the top is the most important. You can also tap any item to reverse its sort order. Example: To sort key maps primarily by their Actions in ascending order and secondarily by their Triggers in descending order, move Actions to the first position and Triggers to the second. Drag handle for %1$s Show example - Unrecognized key code - The pressed button was not recognized by the input system. In the past Key Mapper detected such buttons as one and the same. Currently the app tries to distinguish the button based on the scan code, which should be more unique. However, this is a makeshift incomplete solution, which doesn\'t guarantee uniqueness. - Turn on notifications Some actions and options need this permission to work. You can also get notified when there is important news about the app. Done + Kill Guide Guide Change @@ -539,25 +503,16 @@ Docs Changelog - Shizuku - Key Mapper GUI Keyboard - Key Mapper Leanback Keyboard - Do nothing - Fix - Keyboard picker Pause/Resume key maps Keyboard is hidden warning - Toggle Key Mapper keyboard + Toggle Key Mapper Input Method New features - Tap to change your keyboard. - Keyboard picker - Running Tap to open Key Mapper. Pause @@ -582,96 +537,79 @@ Keyboard is hidden! Tap \'show keyboard\' to start showing the keyboard again. - Toggle Key Mapper keyboard - Tap \'toggle\' to switch to and from the Key Mapper keyboard. + Toggle Key Mapper Input Method + Tap \'toggle\' to switch to and from the Key Mapper Input Method. Toggle - Default long press delay (ms) + Default long press delay How long a button should be pressed for it to be detected as a long press. Default is 500ms. Can be overridden in a key map\'s options. - Default double press duration (ms) + Default double press duration How fast does a button have to be double pressed for it to be detected as a double press. Default is 300ms. Can be overridden in a key map\'s options. How long to vibrate if vibrating is enabled for a key map. Default is 200ms. Can be overridden in a key map\'s options. - Default vibrate duration (ms) + Default vibrate duration How long the trigger needs to be held down for the action to start repeating. Default is 400ms. Can be overridden in a key map\'s options. - Default delay until repeat (ms) + Default delay until repeat The delay between every time an action is repeated. Default is 50ms. Can be overridden in a key map\'s options. - Default delay between repeats (ms) + Default delay between repeats The time allowed to complete a sequence trigger. Default is 1000ms. Can be overridden in a key map\'s options. - Default sequence trigger timeout (ms) + Default sequence trigger timeout Reset - Force all key maps to vibrate. - Force vibrate + Make all key maps vibrate + Every time a key map is triggered - Keyboard picker notification - Show a persistent notification to allow you to pick a keyboard. + Show pause/resume notification + Toggle your key maps on and off - Pause/resume key maps notification - Show a persistent notification which starts/pauses your key maps. - - Automatically back up key maps to a specified location - No location chosen. - - Choose devices - - Automatically show keyboard picker - When a device that you have chosen connects or disconnects the keyboard picker will show automatically. Choose the devices below. + Change automatic backup location + Turn on automatic backup + Periodically back up your key maps Automatically change the on-screen keyboard when a device (e.g a keyboard) connects/disconnects - The last used Key Mapper keyboard will be automatically selected when a chosen device is connected. Your normal keyboard will be automatically selected when the device disconnects. + The Key Mapper Input Method will be automatically selected when a chosen device is connected. Your normal keyboard will be automatically selected when the device disconnects. Automatically change the on-screen keyboard when you start inputting text - The last used non Key Mapper keyboard will be automatically selected when you try to open the keyboard. Your Key Mapper keyboard will be automatically selected once you stop using the keyboard. + The last used normal on-screen keyboard will be automatically selected when you try to open the keyboard. The Key Mapper Input Method will be automatically selected once you stop using the keyboard. - Show an on-screen message when automatically changing the keyboard + On-screen message + Show when automatically changing the keyboard - Key Mapper has root permission - Enable this if you want to use features/actions which only work on rooted devices. Key Mapper must have root permission from your root-access-management app (e.g Magisk, SuperSU) for these features to work. - Only turn this on if you know your device is rooted and you have given Key Mapper root permission. + Request root permission + If your device is rooted then this will show the root permission pop up from Magisk or your root app. Choose theme Light and dark themes available - Switch between the Key Mapper keyboard and your default keyboard when you tap the notification. - Toggle Key Mapper keyboard notification + Switch between the Key Mapper Input Method and your normal keyboard when you tap the notification. + Toggle Key Mapper Input Method notification Automatically change the keyboard when toggling key maps - Automatically select the Key Mapper keyboard when you resume your key maps and select your default keyboard when pausing them. + Automatically select the Key Mapper Input Method when you resume your key maps and select your default keyboard when pausing them. Hide home screen alerts - Hide the alerts at the top of the home screen. - - Show the first 5 characters of the device id for device specific triggers - This is useful to differentiate between devices that have the same name. - - Fix keyboards that are set to US English - This fixes keyboards that don\'t have the correct the keyboard layout when an accessibility service is enabled. Tap to read more and configure. - - Fix keyboards that are set to US English - There is a bug in Android 11 that turning on an accessibility service makes Android think all external devices are the same internal virtual device. Because it can\'t identify these devices correctly, it doesn\'t know which keyboard layout to use with them so it defaults to US English even if it is a German keyboard for example. You can use Key Mapper to work around this problem by following the steps below. + Hide the alerts at the top of the home screen - 4. Choose devices + Show device IDs + Differentiate devices with the same name - 1. Install the Key Mapper GUI Keyboard (optional) - 1. Install the Key Mapper Leanback Keyboard (optional) - - 2. Enable the Key Mapper GUI Keyboard or the Key Mapper Basic Input Method - 2. Enable the Key Mapper Leanback Keyboard or the Key Mapper Basic Input Method + Enable extra logging + Record more detailed logs + View Key Mapper log + Share this with the developer if you\'re having issues - 3. Use the keyboard that you just enabled - (Recommended) Read user guide for this setting. + Share logcat + Share the entire system log + Sharing logcat failed - Enable extra logging - View and share log Report issue Delete sound files @@ -689,54 +627,53 @@ 3. Key Mapper does not have permission to use Shizuku. Tap to grant this permission. 3. Key Mapper will automatically use Shizuku. Tap to read which Key Mapper features use Shizuku. - Default mapping options - Change the default options for your key maps. + Change default options + For triggers and actions - Reset all settings - DANGER! Reset all settings in the app to the default. Your key maps will NOT be reset. + Reset all DANGER! - Are you sure you want to reset all settings in the app to the default? Your key maps will NOT be reset. The introduction screen and all warning pop ups will show again. + Are you sure you want to reset all settings in the app to the default? Your existing key maps will not be affected. The introductions and warning pop ups will show again. Yes, reset + Reset all + Customize your experience + Key maps + Data management + Power user options + Debugging + Notifications - Automatically show the keyboard picker - Tap to see the settings that allow you to automatically show the keyboard picker. - Root settings These options will only work on root devices! If you don\'t know what root is or whether your device is rooted, please don\'t leave a poor review if they don\'t work. :) Require WRITE_SECURE_SETTINGS permission These options are only enabled if Key Mapper has WRITE_SECURE_SETTINGS permission. Click the button below to learn how to grant the permission. - Shizuku support - Shizuku is an app that allows Key Mapper to do things that only system apps can do. You don\'t need to use the Key Mapper keyboard for example. Tap to learn how to set this up. - Follow these steps to set up Shizuku. + Automatically switch keyboard + Switch when needed then switch back - Automatically change the keyboard - These are really useful settings and you are recommended to check them out! + Choose devices + Choose which devices trigger automatic keyboard switching Logging This may add latency to your key maps so only turn this on if you are trying to debug the app or have been asked to by the developer. - - - + Use PRO mode + Advanced detection of key events and more - - Light - Dark - Follow system - + Light + Dark + System + Show volume dialog Vibrate Show an on-screen message Vibrate again on long press - Detect trigger when the screen is off Repeat %dx @@ -750,7 +687,7 @@ Hold down Hold down until pressed again Do not remap - Allow other apps to trigger this key map + Allow other apps to control this key map with intents or shortcuts Copy key map id @@ -784,7 +721,7 @@ Running Service Disabled Key Mapper accessibility service is disabled - Toggle Key Mapper keyboard + Toggle Key Mapper Input Method @@ -812,7 +749,7 @@ - You must be using one of the Key Mapper keyboards for this action to work! + You must be using the Key Mapper Input Method for this action to work! The app with package name %s isn\'t installed! @@ -829,7 +766,7 @@ Your device doesn\'t support Bluetooth. Your device doesn\'t support device policy enforcement. Your device doesn\'t have a camera flash. - Your device doesn\'t have any telephony features. + Your device can not use telephony features. Is a SIM card inserted? Can\'t find the keyboard settings page! Key Mapper needs to be a device administrator! Key Mapper doesn\'t have permission to use that shortcut @@ -840,6 +777,7 @@ Can\'t find the Do Not Disturb access permission settings! Key Mapper needs WRITE_SECURE_SETTINGS permission. There is no app that can start this phone call + There is no app that can send this SMS Camera is in use! Camera disconnected! @@ -854,11 +792,12 @@ The accessibility service needs to be restarted! Your launcher doesn\'t support shortcuts. - A Key Mapper keyboard needs to be enabled! + The Key Mapper Input Method needs to be enabled! Can\'t find the %s input method The input method picker can\'t be shown! Failed to find accessibility node! Failed perform global action %s! + This action needs setting up Battery optimisation settings not found! If it exists, open it manually. @@ -877,17 +816,29 @@ Denied notification access permission! Invalid! Denied permission to start phone calls! + Denied permission to send SMS messages! + Failed to send SMS. Is the number correct? + Can\'t send SMS. Turn off airplane mode. + Can\'t send SMS. No cellular service. + SMS sending limit exceeded. Try again later. + Network rejected the SMS. + Can\'t send SMS. Device is out of memory. + Invalid SMS message format. + Can\'t send SMS. Network error. + Can\'t send SMS during emergency call. + Can\'t send SMS. No SIM card detected. You will need to update Key Mapper to the latest version to use this backup. There is no voice assistant installed! Insufficient permissions - You only have Key Mapper keyboards installed! + You have no on-screen keyboards installed! There are no apps playing media! Source file not found! %s Target file not found! %s Failed to input gesture! Failed to modify system setting %s! You need to enable %s! - Failed to change ime! + Failed to change input method! + Failed to enable input method! Your device has no camera app! Your device has no assistant! Your device has no settings app! @@ -919,6 +870,9 @@ Must be %d or less! UI element not found! + Command timed out after %1$d seconds + PRO Mode needs starting + Rate limit reached. You can only send once per second. @@ -935,6 +889,9 @@ Mute volume Toggle mute Unmute volume + Mute microphone + Unmute microphone + Toggle mute microphone Show volume dialog Increase stream Increase %s stream @@ -1061,7 +1018,6 @@ You will only be able to log back in with your PIN. The fingerprint scanner and face unlock will be disabled. This is the only reliable way I have found to lock non-rooted devices before Android Pie 9.0. Sleep/wake device - You must turn on the option to detect the trigger when the screen is off! Do nothing @@ -1119,6 +1075,10 @@ Start phone call Answer phone call End phone call + Send SMS + Send SMS: "%s"" to %s + Compose SMS + Compose SMS: "%s" to %s Play sound Dismiss most recent notification Dismiss all notifications @@ -1134,6 +1094,31 @@ Authorization header (optional) You must prepend \'Bearer\' if necessary + Shell command + Shell command action + Script + Script cannot be empty! + Run as root + Standard + Root + ADB + Execution Mode + ADB mode does not support streaming output + Setup PRO Mode + Setup PRO Mode (Unsupported) + Configuration + Output + No output yet. Click Test to run the command. + Test + Output + Executing… + Success + Failed + Exit code: %1$d + Execute with root: %s + Execute with ADB: %s + Execute: %s + Interact with app element Key Mapper can detect and interact with app elements like menus, tabs, buttons and checkboxes. You need to record yourself interacting with the app so that Key Mapper knows what you want to do. Start recording @@ -1172,6 +1157,9 @@ View resource ID Unique ID Interaction types + + Force stop app + Close and clear app from recents @@ -1233,21 +1221,6 @@ The flags for an Intent are stored as bit flags. These flags alter how the Intent is handled. If this is blank for an Activity Intent then Key Mapper will use FLAG_ACTIVITY_NEW_TASK by default. For much more information tap \'docs\' to see the Android developers documentation. - - Skip tutorial - Create your first key map! - A key map is a rule to tell your device what to do when a button is pressed. - Record a trigger - Tap record, and then press the physical buttons you want to change. - Love Key Mapper? ❤️ - For an affordable price, make powerful key maps with on-screen floating buttons and support the development of more great features 👨‍💻. - - Choose an action - An action is what should happen when you press the trigger. - Choose a constraint (optional) - If you want the key map to only work in certain situations, for example, when an app is open. - - GitHub Website @@ -1287,25 +1260,41 @@ Translator (Spanish) + + Support Key Mapper ❤️ + Choose add-ons to upgrade your experience + Your support keeps Key Mapper alive! + "Floating button feature is a game changer" + Google Play reviewer + Floating buttons is a game changer! + I\'ve been enjoying the floating buttons feature. + Floating buttons is a brilliant addition. + An essential part of my phone experience. + Pay once, unlock forever + Watch video + Buy now (%s) + Learn more + + On-screen floating buttons + Not enough buttons? Make instant shortcuts and macros in any app, game or on your lock screen! + Use in games + Use on lock screen + + Side key & Assistant trigger + Useless side button? Remap the assistant button or your device\'s side key shortcut to whatever you want! + Screen off + Bixby + Gemini + Key Mapper: Side Key Any assistant Side key/power button Voice assistant - Advanced triggers - The developer doesn\'t believe ads are a sustainable or user-friendly form of monetization so these paid triggers help support development\u00A0❤️. You will be given priority support as well. - Side\u00A0key & Assistant trigger - Did you know you can remap your side key, power button, or device assistant? Instead of launching the assistant or the power menu, your device can perform an action of your choice. It works even when the screen is off! You must purchase the side key trigger feature. - Learn more You must purchase floating buttons. Button was deleted. Floating button - Set up side key trigger - Attention! - You must read the instructions on our website that describe how to set up this trigger. Key Mapper will not guide you. - Read instructions - Select trigger type Purchase can not be verified. Do you have an internet connection? @@ -1330,25 +1319,11 @@ contact@keymapper.club Key Mapper Pro query Please fill the following information so I can help you.\n\n1. Device model:\n2. Android version:\n3. Key maps (make a back up in the home screen menu):\n4. Screenshot of Key Mapper home screen:\n5. Describe the problem you are having: - Thank you for supporting the app\u00A0❤️! - Your purchase was successful. As a paying user of Key\u00A0Mapper you will receive priority support to help you use the app. There is now a button on this page to contact the developer. - The advanced triggers are paid feature but you downloaded the FOSS build of Key Mapper that does not include this module or Google Play billing. Please download Key Mapper from Google Play to get access to this feature. + Thank you for supporting the app! + Your purchase was successful. As a paying user of Key\u00A0Mapper you will receive priority support to help you use the app. There is now a button in the shop to contact the developer. + The advanced triggers are paid feature but you downloaded the FOSS build of Key Mapper that does not include this closed source module or Google Play billing. Please download Key Mapper from Google Play to get access to this feature. Download Play build - - Want to remap DPAD buttons? - You must set up the Key Mapper GUI Keyboard following the steps below. - 1. Install the keyboard app - Install - Installed - 2. Enable the keyboard - Enable - Enabled - 3. Use the keyboard - Change keyboard - Keyboard selected - Setup complete! Tap \'Done\' and your DPAD trigger should work. - Button not detected? You can try using the Key Mapper GUI Keyboard app to record your trigger instead of the accessibility service. @@ -1376,12 +1351,27 @@ Cancel Done The button must have text! + Show over notification panel + Show over keyboard Floating buttons Floating buttons display over the apps you want. They work just like real buttons, and you can place, style, and map them however you like. Floating button %s (%s) Deleted floating button - Constraints - Do you want this button to only be on screen in some apps? Add an “App in foreground” constraint for this key map in the “Constraints” tab. + Better Caps Lock compatibility + PRO mode provides better compatibility for Caps Lock remapping. Tap \'Use PRO mode\' and record it again. + Use PRO mode + Screen off remapping? + Use PRO mode if you want your trigger to work when the screen is off. Tap \'Use PRO mode\' and record it again. + Use PRO mode + App pinning warning + Using the back button as a trigger may conflict with app pinning, causing a black screen on unlock. A reboot will fix it. + Keyboard icon + The ⌨ symbol means you must use the Key Mapper input method for this trigger to work due to an Android restriction. + Ringer mode actions + Consider using PRO mode for ringer mode actions to avoid conflicts with Do Not Disturb settings. + Use PRO mode + Limit to specific apps? + Add constraints in the Constraints tab. Choose a layout Back Help @@ -1407,7 +1397,7 @@ New key map New layout Tap a key map to configure it.\nLong press for more options. - Create a key map! + Create a key map!\n\nA key map is a rule to tell your device what to do when a button is pressed. Don’t have enough buttons? Make your own! Floating buttons display over the apps you want. They work just like real buttons, and you can place, style, and map them however you like. @@ -1516,6 +1506,16 @@ Swipe left fingerprint reader Swipe right fingerprint reader Advanced triggers + Key code %d + Scan code %d + Scan code + Keys can be identified by either a \'key code\' or a \'scan code\'. A scan code is more unique than a key code but your trigger may not work on other devices. We recommend using key codes. + Unknown key code + Use key code %d + Use scan code %d + No scan code saved + Use PRO mode + Add more Remove @@ -1545,6 +1545,10 @@ This device does not let you change the brightness. Brightness change + Volume stream + Default (system controlled) + Options + Unsupported @@ -1574,4 +1578,216 @@ +%d inherited constraint +%d inherited constraints + + + + PRO mode + Important! + Remapping buttons with PRO mode is dangerous and can cause them to stop working if you remap them incorrectly.\n\nIf you make a mistake, you may need to force restart your device by holding down the power and volume buttons for 30 seconds — consult your device\'s manual or the internet for how to do this. + %d… + I understand + Understood + Set up + Root detected + You can skip the set up process by giving Key Mapper root permission. This will let Key Mapper auto start PRO mode on boot as well. + Start PRO mode + Shizuku detected + You can skip the set up process by giving Key Mapper Shizuku permission. + Start Shizuku + Request permission + Start PRO mode + Set up with Key Mapper + Continue + Continue (Android 11+) + Options + Enable PRO mode for all key maps + Key Mapper will use the ADB Shell for remapping + These settings are unavailable until you acknowledge the warning. + PRO mode service is running + Stop + + Automatically start at boot + PRO Mode will start itself whenever you turn on or restart your device + + Key event actions + Select how key event actions are performed + + Emergency tip + If your power button stops working, hold down the power button for 10 seconds and release to disable PRO Mode. + + Setup wizard + Step %d of %d + Use interactive setup assistant + Automatically interact with settings + Enable accessibility service first + Watch tutorial + Start service + Go to settings + Start service + + Enable accessibility service + Key Mapper uses a service to help you set up PRO mode. It\'s also useful for ordinary key maps. + + Enable developer options + Key Mapper needs to use Android Debug Bridge to start PRO mode, and you need to enable developer options for that. + + Connect to a WiFi network + Key Mapper needs a WiFi network to enable ADB. You do not need an internet connection.\n\nNo WiFi network? Use a hotspot from someone else\'s phone. + + Enable wireless debugging + Key Mapper uses wireless debugging to launch its remapping and input service. + + Pair wireless debugging + Key Mapper needs to pair with wireless debugging before it can launch its remapping and input service. + + Start service + Key Mapper needs to connect to the Android Debug Bridge to start the PRO mode service. + + Allow notifications + Key Mapper needs permission to notify you if there are any issues with the set up process. + Give permission + + Setup assistant + + PRO mode is running + You can now remap buttons when the screen is off and use more actions. + + Finish + + Enable developer options + Tap build number repeatedly + + Pairing automatically + Searching for pairing code and port… + + Unable to find pairing port and code + Tap on the button to pair with pairing code and type the code in here + + Starting PRO Mode failed + Tap to set up again. Try ADB pairing and rebooting your phone if it repeatedly fails. + + Auto starting PRO mode + Using root + Using shizuku + Using ADB over WiFi + + PRO mode started + Have fun remapping! ❤️ + + Pairing failed + Keep the pairing code popup on-screen when submitting the pairing code + Input pairing code + + What can I do with PRO mode? + 📲 You can remap more buttons, such as your power button.\n⌨️ Use any keyboard with key code actions.\n⭐️ The following actions are unlocked: WiFi, Bluetooth, mobile data, NFC, and airplane mode, collapse status bar, and sleep/wake device. + Show PRO mode info + Dismiss + + PRO mode stopped unexpectedly + Automatically restarting… + Not auto restarting. If you\'re not killing the service report the issue to the developer. + + + Discover + What do you want to remap? + On-device buttons + Peripherals & gaming + Floating buttons + + Volume + Assistant + Power + Fingerprint gesture + Keyboard + Mouse + Gamepad + Other + Custom + Notch + Lock screen + + Upgrade your experience + Floating buttons are instant shortcuts in any app, game or on your lock screen. Your support keeps Key Mapper alive ❤️ + See it in action + Dismiss + Unlock for just %s + + + + Volume button + Keyboard + Power button + Assistant trigger + Assistant trigger add-on + Mouse button + Gamepad button + Other button + Fingerprint reader gesture + No trigger detected + Tap to add trigger + Requirements not met + Get help + You cannot remap this button + You cannot use this feature + You might be able to remap this button + You might be able to remap this device + You might be able to use this feature + You can remap this button + You can remap this device + You can use this feature + Remap when the screen is off + Remap when the screen is off + Options + Requirements + If your buttons still cannot be detected, please join our Discord server and let us know. We will do our best to help you remap your buttons. + If your buttons cannot be detected please join our Discord server and let us know. We will do our best to help you remap your buttons. + Information + Accessibility service + Enable + Running + Purchased + PRO mode + Enable for free + Running + Not available on this Android version + If your device assistant is triggered by a hardware button, it might be possible to remap it for free with PRO mode. Try choosing \'Other\' from the trigger page and follow the instructions. + This trigger requires some set up that varies per device. Please\ + read the instructions + Read instructions + Select trigger type + If your device assistant is triggered by the power button, it might be possible to remap the assistant instead, meaning you don\'t need to use PRO mode. Try choosing \'Assistant\' from the trigger page and follow the instructions. + D-Pad button + Other simple buttons + You will not be able to use your normal on-screen keyboard when remapping DPAD buttons. + Key Mapper input method + Enable + Choose + Running + + + + Fix key event action + There are extra steps to use this action. Select which method you want to use: + Key Mapper input method + No on-screen keyboard + Can use your normal on-screen keyboard at all times + Better compatibility with apps and games + Enable Key Mapper input method + Use Key Mapper input method + Automatically switch to normal on-screen keyboard when typing + You can always change this in Settings + Setup + Options + + Test SMS + Sent successfully! + Sending SMS may incur carrier or roaming charges. Key Mapper developers are not liable for any costs. + Description + Timeout + + + Are you enjoying floating buttons? + We would love to know! Please consider leaving a review on Google Play to help others discover this feature. ❤️ + Dismiss + diff --git a/base/src/main/res/xml/provider_paths.xml b/base/src/main/res/xml/provider_paths.xml index fef48129c4..81fcea4ee0 100644 --- a/base/src/main/res/xml/provider_paths.xml +++ b/base/src/main/res/xml/provider_paths.xml @@ -3,4 +3,8 @@ + + diff --git a/base/src/test/java/io/github/sds100/keymapper/base/BackupManagerTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/BackupManagerTest.kt index da13c340f5..697a8b337a 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/BackupManagerTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/BackupManagerTest.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base +import android.os.Build import com.github.salomonbrys.kotson.get import com.google.gson.Gson import com.google.gson.JsonParser @@ -28,6 +29,7 @@ import io.github.sds100.keymapper.data.repositories.GroupRepository import io.github.sds100.keymapper.data.repositories.KeyMapRepository import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.system.files.IFile +import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -59,7 +61,6 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import timber.log.Timber -import java.io.File @Suppress("BlockingMethodInNonBlockingContext") @ExperimentalCoroutinesApi @@ -96,7 +97,9 @@ class BackupManagerTest { mockKeyMapRepository = mock() mockGroupRepository = mock { on { getAllGroups() } doReturn MutableStateFlow(emptyList()) - on { getGroupsByParent(ArgumentMatchers.any()) }.thenReturn(MutableStateFlow(emptyList())) + on { + getGroupsByParent(ArgumentMatchers.any()) + }.thenReturn(MutableStateFlow(emptyList())) } fakeFileAdapter = FakeFileAdapter(temporaryFolder) @@ -118,7 +121,7 @@ class BackupManagerTest { on { layouts } doReturn MutableStateFlow(State.Data(emptyList())) }, groupRepository = mockGroupRepository, - buildConfigProvider = TestBuildConfigProvider(), + buildConfigProvider = TestBuildConfigProvider(sdkInt = Build.VERSION_CODES.TIRAMISU), ) parser = JsonParser() @@ -131,170 +134,213 @@ class BackupManagerTest { } /** - * Issue #1655. If the list of groups in the backup has a child before the parent then the - * parent must be restored first. Otherwise the SqliteConstraintException will be thrown. + * If the backup contains a group who's parent does not exist in the backup then + * set the parent to null. */ @Test - fun `restore groups breadth first so parents exist before children are restored with child first in the backup`() = runTest(testDispatcher) { - val parentGroup1 = GroupEntity( - uid = "parent_group_1_uid", - name = "parent_group_1_name", - parentUid = null, + fun `restore group with missing parent to root group`() = runTest(testDispatcher) { + val group = GroupEntity( + uid = "child_group", + name = "Child", + parentUid = "parent_group", lastOpenedDate = 0L, ) - GroupEntity( - uid = "parent_group_2_uid", - name = "parent_group_2_name", - parentUid = null, - lastOpenedDate = 0L, + val backupContent = BackupContent( + appVersion = BuildConfig.VERSION_CODE, + dbVersion = AppDatabase.DATABASE_VERSION, + groups = listOf(group), ) - val childGroup = GroupEntity( - uid = "child_group_uid", - name = "child_group_name", - parentUid = parentGroup1.uid, - lastOpenedDate = 0L, + backupManager.restore( + RestoreType.REPLACE, + backupContent, + emptyList(), + currentTime = 0L, ) - val grandChildGroup = GroupEntity( - uid = "grand_child_group_uid", - name = "grand_child_group_name", - parentUid = childGroup.uid, + val expectedGroup = GroupEntity( + uid = "child_group", + name = "Child", + // The parent is null because it did not exist. + parentUid = null, lastOpenedDate = 0L, ) - val backupContent = BackupContent( - appVersion = BuildConfig.VERSION_CODE, - dbVersion = AppDatabase.DATABASE_VERSION, - groups = listOf(childGroup, grandChildGroup, parentGroup1), - ) + verify(mockGroupRepository).insert(expectedGroup) + } + + /** + * Issue #1655. If the list of groups in the backup has a child before the parent then the + * parent must be restored first. Otherwise the SqliteConstraintException will be thrown. + */ + @Test + fun `restore groups breadth first so parents exist before children are restored with child first in the backup`() = + runTest(testDispatcher) { + val parentGroup1 = GroupEntity( + uid = "parent_group_1_uid", + name = "parent_group_1_name", + parentUid = null, + lastOpenedDate = 0L, + ) - inOrder(mockGroupRepository) { - backupManager.restore( - RestoreType.REPLACE, - backupContent, - emptyList(), - currentTime = 0L, + GroupEntity( + uid = "parent_group_2_uid", + name = "parent_group_2_name", + parentUid = null, + lastOpenedDate = 0L, ) - verify(mockGroupRepository).insert(parentGroup1) - verify(mockGroupRepository).insert(childGroup) - verify(mockGroupRepository).insert(grandChildGroup) - verify(mockGroupRepository, never()).update(any()) + val childGroup = GroupEntity( + uid = "child_group_uid", + name = "child_group_name", + parentUid = parentGroup1.uid, + lastOpenedDate = 0L, + ) + + val grandChildGroup = GroupEntity( + uid = "grand_child_group_uid", + name = "grand_child_group_name", + parentUid = childGroup.uid, + lastOpenedDate = 0L, + ) + + val backupContent = BackupContent( + appVersion = BuildConfig.VERSION_CODE, + dbVersion = AppDatabase.DATABASE_VERSION, + groups = listOf(childGroup, grandChildGroup, parentGroup1), + ) + + inOrder(mockGroupRepository) { + backupManager.restore( + RestoreType.REPLACE, + backupContent, + emptyList(), + currentTime = 0L, + ) + + verify(mockGroupRepository).insert(parentGroup1) + verify(mockGroupRepository).insert(childGroup) + verify(mockGroupRepository).insert(grandChildGroup) + verify(mockGroupRepository, never()).update(any()) + } } - } /** * Issue #1655. If the list of groups in the backup has a child before the parent then the * parent must be restored first. Otherwise the SqliteConstraintException will be thrown. */ @Test - fun `restore groups breadth first so parents exist before children are restored`() = runTest(testDispatcher) { - val parentGroup1 = GroupEntity( - uid = "parent_group_1_uid", - name = "parent_group_1_name", - parentUid = null, - lastOpenedDate = 0L, - ) - - val parentGroup2 = GroupEntity( - uid = "parent_group_2_uid", - name = "parent_group_2_name", - parentUid = null, - lastOpenedDate = 0L, - ) + fun `restore groups breadth first so parents exist before children are restored`() = + runTest(testDispatcher) { + val parentGroup1 = GroupEntity( + uid = "parent_group_1_uid", + name = "parent_group_1_name", + parentUid = null, + lastOpenedDate = 0L, + ) - val childGroup = GroupEntity( - uid = "child_group_uid", - name = "child_group_name", - parentUid = parentGroup1.uid, - lastOpenedDate = 0L, - ) + val parentGroup2 = GroupEntity( + uid = "parent_group_2_uid", + name = "parent_group_2_name", + parentUid = null, + lastOpenedDate = 0L, + ) - val grandChildGroup = GroupEntity( - uid = "grand_child_group_uid", - name = "grand_child_group_name", - parentUid = childGroup.uid, - lastOpenedDate = 0L, - ) + val childGroup = GroupEntity( + uid = "child_group_uid", + name = "child_group_name", + parentUid = parentGroup1.uid, + lastOpenedDate = 0L, + ) - val backupContent = BackupContent( - appVersion = BuildConfig.VERSION_CODE, - dbVersion = AppDatabase.DATABASE_VERSION, - groups = listOf(parentGroup2, grandChildGroup, childGroup, parentGroup1), - ) + val grandChildGroup = GroupEntity( + uid = "grand_child_group_uid", + name = "grand_child_group_name", + parentUid = childGroup.uid, + lastOpenedDate = 0L, + ) - inOrder(mockGroupRepository) { - backupManager.restore( - RestoreType.REPLACE, - backupContent, - emptyList(), - currentTime = 0L, + val backupContent = BackupContent( + appVersion = BuildConfig.VERSION_CODE, + dbVersion = AppDatabase.DATABASE_VERSION, + groups = listOf(parentGroup2, grandChildGroup, childGroup, parentGroup1), ) - verify(mockGroupRepository).insert(parentGroup2) - verify(mockGroupRepository).insert(parentGroup1) - verify(mockGroupRepository).insert(childGroup) - verify(mockGroupRepository).insert(grandChildGroup) - verify(mockGroupRepository, never()).update(any()) + inOrder(mockGroupRepository) { + backupManager.restore( + RestoreType.REPLACE, + backupContent, + emptyList(), + currentTime = 0L, + ) + + verify(mockGroupRepository).insert(parentGroup2) + verify(mockGroupRepository).insert(parentGroup1) + verify(mockGroupRepository).insert(childGroup) + verify(mockGroupRepository).insert(grandChildGroup) + verify(mockGroupRepository, never()).update(any()) + } } - } @Test - fun `when backing up everything include layouts that are not in the list of key maps`() = runTest(testDispatcher) { - val layoutWithButtons = FloatingLayoutEntityWithButtons( - layout = FloatingLayoutEntity( - uid = "layout_uid", - name = "layout_name", - ), - buttons = listOf( - FloatingButtonEntity( - uid = "button_uid", - layoutUid = "layout_uid", - text = "Button", - buttonSize = 10, - x = 0, - y = 0, - orientation = "orientation", - displayWidth = 100, - displayHeight = 100, - borderOpacity = null, - backgroundOpacity = null, + fun `when backing up everything include layouts that are not in the list of key maps`() = + runTest(testDispatcher) { + val layoutWithButtons = FloatingLayoutEntityWithButtons( + layout = FloatingLayoutEntity( + uid = "layout_uid", + name = "layout_name", ), - ), - ) + buttons = listOf( + FloatingButtonEntity( + uid = "button_uid", + layoutUid = "layout_uid", + text = "Button", + buttonSize = 10, + x = 0, + y = 0, + orientation = "orientation", + displayWidth = 100, + displayHeight = 100, + borderOpacity = null, + backgroundOpacity = null, + showOverStatusBar = false, + showOverInputMethod = false, + ), + ), + ) - val content = backupManager.createBackupContent( - keyMapList = emptyList(), - extraGroups = emptyList(), - extraLayouts = listOf(layoutWithButtons), - ) + val content = backupManager.createBackupContent( + keyMapList = emptyList(), + extraGroups = emptyList(), + extraLayouts = listOf(layoutWithButtons), + ) - assertThat(content.floatingLayouts, Matchers.contains(layoutWithButtons.layout)) - assertThat( - content.floatingButtons, - Matchers.contains(*layoutWithButtons.buttons.toTypedArray()), - ) - } + assertThat(content.floatingLayouts, Matchers.contains(layoutWithButtons.layout)) + assertThat( + content.floatingButtons, + Matchers.contains(*layoutWithButtons.buttons.toTypedArray()), + ) + } @Test - fun `when backing up everything include groups that are not in the list of key maps`() = runTest(testDispatcher) { - val group = GroupEntity( - uid = "group_uid", - name = "group_name", - parentUid = null, - lastOpenedDate = 0L, - ) + fun `when backing up everything include groups that are not in the list of key maps`() = + runTest(testDispatcher) { + val group = GroupEntity( + uid = "group_uid", + name = "group_name", + parentUid = null, + lastOpenedDate = 0L, + ) - val content = backupManager.createBackupContent( - keyMapList = emptyList(), - extraGroups = listOf(group), - extraLayouts = emptyList(), - ) + val content = backupManager.createBackupContent( + keyMapList = emptyList(), + extraGroups = listOf(group), + extraLayouts = emptyList(), + ) - assertThat(content.groups, Matchers.contains(group)) - } + assertThat(content.groups, Matchers.contains(group)) + } /** * #745 @@ -352,24 +398,25 @@ class BackupManagerTest { } @Test - fun `successfully restore zip folder with data json and sound files`() = runTest(testDispatcher) { - // GIVEN - val dataJsonFile = "restore-all.zip/data.json" - val soundFile = "restore-all.zip/sounds/sound.ogg" - val zipFile = fakeFileAdapter.getPrivateFile("backup.zip") + fun `successfully restore zip folder with data json and sound files`() = + runTest(testDispatcher) { + // GIVEN + val dataJsonFile = "restore-all.zip/data.json" + val soundFile = "restore-all.zip/sounds/sound.ogg" + val zipFile = fakeFileAdapter.getPrivateFile("backup.zip") - copyFileToPrivateFolder(dataJsonFile, destination = "backup.zip/data.json") - copyFileToPrivateFolder(soundFile, destination = "backup.zip/sounds/sound.ogg") + copyFileToPrivateFolder(dataJsonFile, destination = "backup.zip/data.json") + copyFileToPrivateFolder(soundFile, destination = "backup.zip/sounds/sound.ogg") - // WHEN - val result = backupManager.restore(zipFile, RestoreType.REPLACE) + // WHEN + val result = backupManager.restore(zipFile, RestoreType.REPLACE) - // THEN - assertThat(result, `is`(Success(Unit))) + // THEN + assertThat(result, `is`(Success(Unit))) - verify(mockKeyMapRepository, times(1)).insert(any(), any()) - verify(mockSoundsManager, times(1)).restoreSound(any()) - } + verify(mockKeyMapRepository, times(1)).insert(any(), any()) + verify(mockSoundsManager, times(1)).restoreSound(any()) + } @Test fun `backup sound file if there is a key map with a sound action`() = runTest(testDispatcher) { @@ -514,67 +561,73 @@ class BackupManagerTest { } @Test - fun `restore keymaps with no db version, assume version is 9 and don't show error message`() = runTest(testDispatcher) { - val fileName = "restore-keymaps-no-db-version.json" + fun `restore keymaps with no db version, assume version is 9 and don't show error message`() = + runTest(testDispatcher) { + val fileName = "restore-keymaps-no-db-version.json" - val result = - backupManager.restore(copyFileToPrivateFolder(fileName), RestoreType.REPLACE) + val result = + backupManager.restore(copyFileToPrivateFolder(fileName), RestoreType.REPLACE) - assertThat(result, `is`(Success(Unit))) - verify(mockKeyMapRepository, times(1)).insert(any(), any()) - } + assertThat(result, `is`(Success(Unit))) + verify(mockKeyMapRepository, times(1)).insert(any(), any()) + } @Test - fun `restore a single legacy fingerprint map, only restore a single fingerprint map and a success message`() = runTest(testDispatcher) { - val fileName = "restore-legacy-single-fingerprint-map.json" + fun `restore a single legacy fingerprint map, only restore a single fingerprint map and a success message`() = + runTest(testDispatcher) { + val fileName = "restore-legacy-single-fingerprint-map.json" - val result = - backupManager.restore(copyFileToPrivateFolder(fileName), RestoreType.REPLACE) + val result = + backupManager.restore(copyFileToPrivateFolder(fileName), RestoreType.REPLACE) - assertThat(result, `is`(Success(Unit))) - } + assertThat(result, `is`(Success(Unit))) + } @Test - fun `restore all legacy fingerprint maps, all fingerprint maps should be restored and a success message`() = runTest(testDispatcher) { - val fileName = "restore-all-legacy-fingerprint-maps.json" + fun `restore all legacy fingerprint maps, all fingerprint maps should be restored and a success message`() = + runTest(testDispatcher) { + val fileName = "restore-all-legacy-fingerprint-maps.json" - val result = - backupManager.restore(copyFileToPrivateFolder(fileName), RestoreType.REPLACE) + val result = + backupManager.restore(copyFileToPrivateFolder(fileName), RestoreType.REPLACE) - assertThat(result, `is`(Success(Unit))) - } + assertThat(result, `is`(Success(Unit))) + } @Test - fun `restore many key maps and device info, all key maps and device info should be restored and a success message`() = runTest(testDispatcher) { - val fileName = "restore-many-keymaps.json" + fun `restore many key maps and device info, all key maps and device info should be restored and a success message`() = + runTest(testDispatcher) { + val fileName = "restore-many-keymaps.json" - val result = - backupManager.restore(copyFileToPrivateFolder(fileName), RestoreType.REPLACE) + val result = + backupManager.restore(copyFileToPrivateFolder(fileName), RestoreType.REPLACE) - assertThat(result, `is`(Success(Unit))) - verify(mockKeyMapRepository, times(1)).insert(any(), any(), any(), any()) - } + assertThat(result, `is`(Success(Unit))) + verify(mockKeyMapRepository, times(1)).insert(any(), any(), any(), any()) + } @Test - fun `restore with key map db version greater than allowed version, send incompatible backup event`() = runTest(testDispatcher) { - val fileName = "restore-keymap-db-version-too-big.json" + fun `restore with key map db version greater than allowed version, send incompatible backup event`() = + runTest(testDispatcher) { + val fileName = "restore-keymap-db-version-too-big.json" - val result = - backupManager.restore(copyFileToPrivateFolder(fileName), RestoreType.REPLACE) + val result = + backupManager.restore(copyFileToPrivateFolder(fileName), RestoreType.REPLACE) - assertThat(result, `is`(KMError.BackupVersionTooNew)) - verify(mockKeyMapRepository, never()).insert(anyVararg()) - } + assertThat(result, `is`(KMError.BackupVersionTooNew)) + verify(mockKeyMapRepository, never()).insert(anyVararg()) + } @Test - fun `restore with legacy fingerprint gesture map db version greater than allowed version, send incompatible backup event`() = runTest(testDispatcher) { - val fileName = "restore-legacy-fingerprint-map-version-too-big.json" + fun `restore with legacy fingerprint gesture map db version greater than allowed version, send incompatible backup event`() = + runTest(testDispatcher) { + val fileName = "restore-legacy-fingerprint-map-version-too-big.json" - val result = - backupManager.restore(copyFileToPrivateFolder(fileName), RestoreType.REPLACE) + val result = + backupManager.restore(copyFileToPrivateFolder(fileName), RestoreType.REPLACE) - assertThat(result, `is`(KMError.BackupVersionTooNew)) - } + assertThat(result, `is`(KMError.BackupVersionTooNew)) + } @Test fun `restore empty file, show empty json error message`() = runTest(testDispatcher) { @@ -595,46 +648,49 @@ class BackupManagerTest { } @Test - fun `backup key maps, return list of default key maps, keymap db version should be current database version`() = runTest(testDispatcher) { - // GIVEN - val backupDirUuid = "backup_uuid" + fun `backup key maps, return list of default key maps, keymap db version should be current database version`() = + runTest(testDispatcher) { + // GIVEN + val backupDirUuid = "backup_uuid" - whenever(mockUuidGenerator.random()).then { - backupDirUuid - } + whenever(mockUuidGenerator.random()).then { + backupDirUuid + } - val keyMapList = listOf(KeyMapEntity(0), KeyMapEntity(1)) + val keyMapList = listOf(KeyMapEntity(0), KeyMapEntity(1)) - whenever(mockKeyMapRepository.keyMapList).then { MutableStateFlow(State.Data(keyMapList)) } + whenever(mockKeyMapRepository.keyMapList).then { + MutableStateFlow(State.Data(keyMapList)) + } - val backupZip = File(temporaryFolder.root, "backup.zip") - backupZip.mkdirs() + val backupZip = File(temporaryFolder.root, "backup.zip") + backupZip.mkdirs() - // WHEN - val result = backupManager.backupKeyMaps(JavaFile(backupZip), keyMapList.map { it.uid }) + // WHEN + val result = backupManager.backupKeyMaps(JavaFile(backupZip), keyMapList.map { it.uid }) - // THEN - assertThat(result, `is`(Success(Unit))) + // THEN + assertThat(result, `is`(Success(Unit))) - // only 1 file has been backed up - assertThat(backupZip.listFiles()?.size, `is`(1)) + // only 1 file has been backed up + assertThat(backupZip.listFiles()?.size, `is`(1)) - val dataJson = File(backupZip, "data.json") - val json = dataJson.inputStream().bufferedReader().use { it.readText() } - val rootElement = parser.parse(json) + val dataJson = File(backupZip, "data.json") + val json = dataJson.inputStream().bufferedReader().use { it.readText() } + val rootElement = parser.parse(json) - // the key maps have been backed up - assertThat( - gson.toJson(rootElement["keymap_list"]), - `is`(gson.toJson(keyMapList)), - ) + // the key maps have been backed up + assertThat( + gson.toJson(rootElement["keymap_list"]), + `is`(gson.toJson(keyMapList)), + ) - // the database version has been backed up - assertThat( - rootElement["keymap_db_version"].asInt, - `is`(AppDatabase.DATABASE_VERSION), - ) - } + // the database version has been backed up + assertThat( + rootElement["keymap_db_version"].asInt, + `is`(AppDatabase.DATABASE_VERSION), + ) + } /** * @return a path to the copied file diff --git a/base/src/test/java/io/github/sds100/keymapper/base/ConfigKeyMapUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/ConfigKeyMapUseCaseTest.kt deleted file mode 100644 index 3d57377e3d..0000000000 --- a/base/src/test/java/io/github/sds100/keymapper/base/ConfigKeyMapUseCaseTest.kt +++ /dev/null @@ -1,480 +0,0 @@ -package io.github.sds100.keymapper.base - -import android.view.KeyEvent -import io.github.sds100.keymapper.base.actions.Action -import io.github.sds100.keymapper.base.actions.ActionData -import io.github.sds100.keymapper.base.constraints.Constraint -import io.github.sds100.keymapper.base.keymaps.ClickType -import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapUseCaseController -import io.github.sds100.keymapper.base.keymaps.KeyMap -import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType -import io.github.sds100.keymapper.base.trigger.AssistantTriggerKey -import io.github.sds100.keymapper.base.trigger.AssistantTriggerType -import io.github.sds100.keymapper.base.trigger.KeyEventDetectionSource -import io.github.sds100.keymapper.base.trigger.Trigger -import io.github.sds100.keymapper.base.trigger.TriggerKeyDevice -import io.github.sds100.keymapper.base.trigger.TriggerMode -import io.github.sds100.keymapper.base.utils.singleKeyTrigger -import io.github.sds100.keymapper.base.utils.triggerKey -import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.common.utils.dataOrNull -import io.github.sds100.keymapper.system.inputevents.InputEventUtils -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.contains -import org.hamcrest.Matchers.hasSize -import org.hamcrest.Matchers.instanceOf -import org.hamcrest.Matchers.`is` -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.mock - -@ExperimentalCoroutinesApi -class ConfigKeyMapUseCaseTest { - - private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private lateinit var useCase: ConfigKeyMapUseCaseController - - @Before - fun init() { - useCase = ConfigKeyMapUseCaseController( - coroutineScope = testScope, - devicesAdapter = mock(), - keyMapRepository = mock(), - preferenceRepository = mock(), - floatingLayoutRepository = mock(), - floatingButtonRepository = mock(), - serviceAdapter = mock(), - ) - } - - @Test - fun `Do not allow setting double press for parallel trigger with side key`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - useCase.addAssistantTriggerKey(AssistantTriggerType.ANY) - - useCase.setTriggerDoublePress() - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) - assertThat(trigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) - assertThat(trigger.keys[1].clickType, `is`(ClickType.SHORT_PRESS)) - } - - @Test - fun `Do not allow setting long press for parallel trigger with side key`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - useCase.addAssistantTriggerKey(AssistantTriggerType.ANY) - - useCase.setTriggerLongPress() - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) - assertThat(trigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) - assertThat(trigger.keys[1].clickType, `is`(ClickType.SHORT_PRESS)) - } - - @Test - fun `Do not allow setting double press for side key`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addAssistantTriggerKey(AssistantTriggerType.ANY) - - useCase.setTriggerDoublePress() - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.mode, `is`(TriggerMode.Undefined)) - assertThat(trigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) - } - - @Test - fun `Do not allow setting long press for side key`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addAssistantTriggerKey(AssistantTriggerType.ANY) - - useCase.setTriggerLongPress() - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.mode, `is`(TriggerMode.Undefined)) - assertThat(trigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) - } - - @Test - fun `Set click type to short press if side key added to double press volume button`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - - useCase.setTriggerDoublePress() - - useCase.addAssistantTriggerKey(AssistantTriggerType.ANY) - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) - assertThat(trigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) - assertThat(trigger.keys[1].clickType, `is`(ClickType.SHORT_PRESS)) - } - - @Test - fun `Set click type to short press if fingerprint gestures added to double press volume button`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - - useCase.setTriggerDoublePress() - - useCase.addFingerprintGesture(FingerprintGestureType.SWIPE_UP) - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) - assertThat(trigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) - assertThat(trigger.keys[1].clickType, `is`(ClickType.SHORT_PRESS)) - } - - @Test - fun `Set click type to short press if side key added to long press volume button`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - - useCase.setTriggerLongPress() - - useCase.addAssistantTriggerKey(AssistantTriggerType.ANY) - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) - assertThat(trigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) - assertThat(trigger.keys[1].clickType, `is`(ClickType.SHORT_PRESS)) - } - - @Test - fun `Set click type to short press if fingerprint gestures added to long press volume button`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - - useCase.setTriggerLongPress() - - useCase.addFingerprintGesture(FingerprintGestureType.SWIPE_UP) - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) - assertThat(trigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) - assertThat(trigger.keys[1].clickType, `is`(ClickType.SHORT_PRESS)) - } - - @Test - fun `Enable hold down option for key event actions when the trigger is a DPAD button`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_DPAD_LEFT, - TriggerKeyDevice.Any, - KeyEventDetectionSource.INPUT_METHOD, - ) - - useCase.addAction(ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_W)) - - val actionList = useCase.keyMap.value.dataOrNull()!!.actionList - assertThat(actionList[0].holdDown, `is`(true)) - assertThat(actionList[0].repeat, `is`(false)) - } - - /** - * This ensures that it isn't possible to have two or more assistant triggers when the mode is parallel. - */ - @Test - fun `Remove device assistant trigger if setting mode to parallel and voice assistant already exists`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - useCase.addAssistantTriggerKey(AssistantTriggerType.VOICE) - useCase.addAssistantTriggerKey(AssistantTriggerType.DEVICE) - useCase.setParallelTriggerMode() - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.keys, hasSize(2)) - assertThat( - trigger.keys[0], - instanceOf(io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey::class.java), - ) - assertThat(trigger.keys[1], instanceOf(AssistantTriggerKey::class.java)) - } - - @Test - fun `Remove voice assistant trigger if setting mode to parallel and device assistant already exists`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - useCase.addAssistantTriggerKey(AssistantTriggerType.DEVICE) - useCase.addAssistantTriggerKey(AssistantTriggerType.VOICE) - useCase.setParallelTriggerMode() - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.keys, hasSize(2)) - assertThat( - trigger.keys[0], - instanceOf(io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey::class.java), - ) - assertThat(trigger.keys[1], instanceOf(AssistantTriggerKey::class.java)) - } - - @Test - fun `Set click type to short press when adding assistant key to multiple long press trigger keys`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - useCase.setTriggerLongPress() - - useCase.addAssistantTriggerKey(AssistantTriggerType.ANY) - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) - } - - @Test - fun `Set click type to short press when adding assistant key to double press trigger key`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - useCase.setTriggerDoublePress() - useCase.addAssistantTriggerKey(AssistantTriggerType.ANY) - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) - } - - @Test - fun `Set click type to short press when adding assistant key to long press trigger key`() = runTest(testDispatcher) { - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - useCase.setTriggerLongPress() - useCase.addAssistantTriggerKey(AssistantTriggerType.ANY) - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) - } - - @Test - fun `Do not allow long press for parallel trigger with assistant key`() = runTest(testDispatcher) { - val keyMap = KeyMap( - trigger = Trigger( - mode = TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS), - keys = listOf( - triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), - AssistantTriggerKey( - type = AssistantTriggerType.ANY, - clickType = ClickType.SHORT_PRESS, - ), - ), - ), - ) - - useCase.keyMap.value = State.Data(keyMap) - useCase.setTriggerLongPress() - - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) - } - - /** - * Issue #753. If a modifier key is used as a trigger then it the - * option to not override the default action must be chosen so that the modifier - * key can still be used normally. - */ - @Test - fun `when add modifier key trigger, enable do not remap option`() = runTest(testDispatcher) { - val modifierKeys = setOf( - KeyEvent.KEYCODE_SHIFT_LEFT, - KeyEvent.KEYCODE_SHIFT_RIGHT, - KeyEvent.KEYCODE_ALT_LEFT, - KeyEvent.KEYCODE_ALT_RIGHT, - KeyEvent.KEYCODE_CTRL_LEFT, - KeyEvent.KEYCODE_CTRL_RIGHT, - KeyEvent.KEYCODE_META_LEFT, - KeyEvent.KEYCODE_META_RIGHT, - KeyEvent.KEYCODE_SYM, - KeyEvent.KEYCODE_NUM, - KeyEvent.KEYCODE_FUNCTION, - ) - - for (modifierKeyCode in modifierKeys) { - // GIVEN - useCase.keyMap.value = State.Data(KeyMap()) - - // WHEN - useCase.addKeyCodeTriggerKey( - modifierKeyCode, - TriggerKeyDevice.Internal, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - - // THEN - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - - assertThat(trigger.keys[0].consumeEvent, `is`(false)) - } - } - - /** - * Issue #753. - */ - @Test - fun `when add non-modifier key trigger, do ont enable do not remap option`() = runTest(testDispatcher) { - // GIVEN - useCase.keyMap.value = State.Data(KeyMap()) - - // WHEN - useCase.addKeyCodeTriggerKey( - KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, - detectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, - ) - - // THEN - val trigger = useCase.keyMap.value.dataOrNull()!!.trigger - - assertThat(trigger.keys[0].consumeEvent, `is`(true)) - } - - /** - * Issue #852. Add a phone ringing constraint when you add an action - * to answer a phone call. - */ - @Test - fun `when add answer phone call action, then add phone ringing constraint`() = runTest(testDispatcher) { - // GIVEN - useCase.keyMap.value = State.Data(KeyMap()) - val action = ActionData.AnswerCall - - // WHEN - useCase.addAction(action) - - // THEN - val keyMap = useCase.keyMap.value.dataOrNull()!! - assertThat( - keyMap.constraintState.constraints, - contains(instanceOf(Constraint.PhoneRinging::class.java)), - ) - } - - /** - * Issue #852. Add a in phone call constraint when you add an action - * to end a phone call. - */ - @Test - fun `when add end phone call action, then add in phone call constraint`() = runTest(testDispatcher) { - // GIVEN - useCase.keyMap.value = State.Data(KeyMap()) - val action = ActionData.EndCall - - // WHEN - useCase.addAction(action) - - // THEN - val keyMap = useCase.keyMap.value.dataOrNull()!! - assertThat( - keyMap.constraintState.constraints, - contains(instanceOf(Constraint.InPhoneCall::class.java)), - ) - } - - /** - * issue #593 - */ - @Test - fun `key map with hold down action, load key map, hold down flag shouldn't disappear`() = runTest(testDispatcher) { - // given - val action = Action( - data = ActionData.TapScreen(100, 100, null), - holdDown = true, - ) - - val keyMap = KeyMap( - 0, - trigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_0)), - actionList = listOf(action), - ) - - // when - useCase.keyMap.value = State.Data(keyMap) - - // then - assertThat(useCase.keyMap.value.dataOrNull()!!.actionList, `is`(listOf(action))) - } - - @Test - fun `add modifier key event action, enable hold down option and disable repeat option`() = runTest(testDispatcher) { - InputEventUtils.MODIFIER_KEYCODES.forEach { keyCode -> - useCase.keyMap.value = State.Data(KeyMap()) - - useCase.addAction(ActionData.InputKeyEvent(keyCode)) - - useCase.keyMap.value.dataOrNull()!!.actionList - .single() - .let { - assertThat(it.holdDown, `is`(true)) - assertThat(it.repeat, `is`(false)) - } - } - } -} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/KeyMapJsonMigrationTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/KeyMapJsonMigrationTest.kt index b995839e32..06bd3f98ed 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/KeyMapJsonMigrationTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/KeyMapJsonMigrationTest.kt @@ -12,6 +12,7 @@ import io.github.sds100.keymapper.data.migration.Migration10To11 import io.github.sds100.keymapper.data.migration.Migration11To12 import io.github.sds100.keymapper.data.migration.Migration9To10 import io.github.sds100.keymapper.data.migration.MigrationUtils +import java.io.InputStream import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -19,7 +20,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test -import java.io.InputStream @ExperimentalCoroutinesApi class KeyMapJsonMigrationTest { @@ -122,7 +122,12 @@ class KeyMapJsonMigrationTest { val expectedKeyMap = expectedData[index] - JsonTestUtils.compareBothWays(expectedKeyMap, "expected", migratedKeyMap, "migrated") + JsonTestUtils.compareBothWays( + expectedKeyMap, + "expected", + migratedKeyMap, + "migrated", + ) } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/LegacyFingerprintMapMigrationTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/LegacyFingerprintMapMigrationTest.kt index c0b043b365..dc7bed8e9f 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/LegacyFingerprintMapMigrationTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/LegacyFingerprintMapMigrationTest.kt @@ -13,6 +13,7 @@ import io.github.sds100.keymapper.data.migration.JsonMigration import io.github.sds100.keymapper.data.migration.MigrationUtils import io.github.sds100.keymapper.data.migration.fingerprintmaps.FingerprintMapMigration0To1 import io.github.sds100.keymapper.data.migration.fingerprintmaps.FingerprintMapMigration1To2 +import java.io.InputStream import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -20,7 +21,6 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test -import java.io.InputStream @ExperimentalCoroutinesApi class LegacyFingerprintMapMigrationTest { @@ -56,7 +56,9 @@ class LegacyFingerprintMapMigrationTest { fun `migrate 1 to 2`() { test( listOf(getLegacySwipeDownJsonFromFile("migration-10-11-test-data.json")).toJsonArray(), - listOf(getLegacySwipeDownJsonFromFile("migration-10-11-expected-data.json")).toJsonArray(), + listOf( + getLegacySwipeDownJsonFromFile("migration-10-11-expected-data.json"), + ).toJsonArray(), 1, 2, ) @@ -108,7 +110,12 @@ class LegacyFingerprintMapMigrationTest { val expectedElement = expectedData[index] - JsonTestUtils.compareBothWays(expectedElement, "expected", migratedFingerprintMap, "migrated") + JsonTestUtils.compareBothWays( + expectedElement, + "expected", + migratedFingerprintMap, + "migrated", + ) } } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/TestDispatcherProvider.kt b/base/src/test/java/io/github/sds100/keymapper/base/TestDispatcherProvider.kt index 3afb5bf624..77efa5ba30 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/TestDispatcherProvider.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/TestDispatcherProvider.kt @@ -3,9 +3,7 @@ package io.github.sds100.keymapper.base import io.github.sds100.keymapper.common.utils.DispatcherProvider import kotlinx.coroutines.test.TestDispatcher -class TestDispatcherProvider( - private val testDispatcher: TestDispatcher, -) : DispatcherProvider { +class TestDispatcherProvider(private val testDispatcher: TestDispatcher) : DispatcherProvider { override fun main() = testDispatcher override fun default() = testDispatcher override fun io() = testDispatcher diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt new file mode 100644 index 0000000000..96309f4a02 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt @@ -0,0 +1,153 @@ +package io.github.sds100.keymapper.base.actions + +import android.view.KeyEvent +import io.github.sds100.keymapper.base.constraints.ConfigConstraintsUseCase +import io.github.sds100.keymapper.base.constraints.ConstraintData +import io.github.sds100.keymapper.base.keymaps.ClickType +import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapStateImpl +import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.base.trigger.KeyEventTriggerDevice +import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey +import io.github.sds100.keymapper.base.utils.singleKeyTrigger +import io.github.sds100.keymapper.base.utils.triggerKey +import io.github.sds100.keymapper.common.utils.dataOrNull +import io.github.sds100.keymapper.system.inputevents.KeyEventUtils +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +class ConfigActionsUseCaseTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var useCase: ConfigActionsUseCaseImpl + private lateinit var configKeyMapState: ConfigKeyMapStateImpl + private lateinit var mockConfigConstraintsUseCase: ConfigConstraintsUseCase + + @Before + fun before() { + configKeyMapState = ConfigKeyMapStateImpl( + testScope, + keyMapRepository = mock(), + floatingButtonRepository = mock(), + ) + + mockConfigConstraintsUseCase = mock() + + useCase = ConfigActionsUseCaseImpl( + state = configKeyMapState, + preferenceRepository = mock(), + configConstraints = mockConfigConstraintsUseCase, + defaultKeyMapOptionsUseCase = mock(), + ) + } + + @Test + fun `Enable hold down option for key event actions when the trigger is a DPAD button`() = + runTest(testDispatcher) { + configKeyMapState.setKeyMap( + KeyMap( + trigger = singleKeyTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_DPAD_LEFT, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + requiresIme = true, + ), + ), + ), + ) + + useCase.addAction(ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_W)) + + val actionList = useCase.keyMap.value.dataOrNull()!!.actionList + assertThat(actionList[0].holdDown, `is`(true)) + assertThat(actionList[0].repeat, `is`(false)) + } + + /** + * Issue #852. Add a phone ringing constraint when you add an action + * to answer a phone call. + */ + @Test + fun `when add answer phone call action, then add phone ringing constraint`() = + runTest(testDispatcher) { + // GIVEN + configKeyMapState.setKeyMap(KeyMap()) + val action = ActionData.AnswerCall + + // WHEN + useCase.addAction(action) + + // THEN + verify(mockConfigConstraintsUseCase).addConstraint(any()) + } + + /** + * Issue #852. Add a in phone call constraint when you add an action + * to end a phone call. + */ + @Test + fun `when add end phone call action, then add in phone call constraint`() = + runTest(testDispatcher) { + // GIVEN + configKeyMapState.setKeyMap(KeyMap()) + val action = ActionData.EndCall + + // WHEN + useCase.addAction(action) + + // THEN + verify(mockConfigConstraintsUseCase).addConstraint(any()) + } + + /** + * issue #593 + */ + @Test + fun `key map with hold down action, load key map, hold down flag shouldn't disappear`() = + runTest(testDispatcher) { + // given + val action = Action( + data = ActionData.TapScreen(100, 100, null), + holdDown = true, + ) + + val keyMap = KeyMap( + 0, + trigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_0)), + actionList = listOf(action), + ) + + // when + configKeyMapState.setKeyMap(keyMap) + + // then + assertThat(useCase.keyMap.value.dataOrNull()!!.actionList, `is`(listOf(action))) + } + + @Test + fun `add modifier key event action, enable hold down option and disable repeat option`() = + runTest(testDispatcher) { + KeyEventUtils.MODIFIER_KEYCODES.forEach { keyCode -> + configKeyMapState.setKeyMap(KeyMap()) + + useCase.addAction(ActionData.InputKeyEvent(keyCode)) + + useCase.keyMap.value.dataOrNull()!!.actionList + .single() + .let { + assertThat(it.holdDown, `is`(true)) + assertThat(it.repeat, `is`(false)) + } + } + } +} 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 b01a5f3724..71c4cd37f2 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 @@ -1,17 +1,21 @@ package io.github.sds100.keymapper.base.actions import android.view.KeyEvent +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.Constants import io.github.sds100.keymapper.common.utils.KMError +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.inputmethod.ImeInfo import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter -import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -52,34 +56,54 @@ class GetActionErrorUseCaseTest { private lateinit var useCase: GetActionErrorUseCaseImpl - private lateinit var mockShizukuAdapter: ShizukuAdapter private lateinit var fakeInputMethodAdapter: FakeInputMethodAdapter private lateinit var mockPermissionAdapter: PermissionAdapter + private lateinit var fakePreferenceRepository: FakePreferenceRepository + private lateinit var mockSystemBridgeConnectionManager: SystemBridgeConnectionManager @Before fun init() { - mockShizukuAdapter = mock() fakeInputMethodAdapter = FakeInputMethodAdapter() mockPermissionAdapter = mock() + fakePreferenceRepository = FakePreferenceRepository() + mockSystemBridgeConnectionManager = mock() useCase = GetActionErrorUseCaseImpl( packageManagerAdapter = mock(), inputMethodAdapter = fakeInputMethodAdapter, + switchImeInterface = mock(), permissionAdapter = mockPermissionAdapter, systemFeatureAdapter = mock(), cameraAdapter = mock(), soundsManager = mock(), - shizukuAdapter = mockShizukuAdapter, ringtoneAdapter = mock(), - buildConfigProvider = TestBuildConfigProvider(), + buildConfigProvider = TestBuildConfigProvider(sdkInt = Constants.SYSTEM_BRIDGE_MIN_API), + systemBridgeConnectionManager = mockSystemBridgeConnectionManager, + preferenceRepository = fakePreferenceRepository, ) } - private fun setupKeyEventActionTest(chosenIme: ImeInfo) { - whenever(mockShizukuAdapter.isInstalled).then { MutableStateFlow(false) } + private fun setupKeyEventActionTest( + chosenIme: ImeInfo, + isSystemBridgeUsed: Boolean = false, + isSystemBridgeConnected: Boolean = false, + ) { whenever(mockPermissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS)).then { true } fakeInputMethodAdapter.chosenIme.value = chosenIme fakeInputMethodAdapter.inputMethods.value = listOf(GBOARD_IME_INFO, GUI_KEYBOARD_IME_INFO) + fakePreferenceRepository.set(Keys.keyEventActionsUseSystemBridge, isSystemBridgeUsed) + + val connectionState = if (isSystemBridgeConnected) { + SystemBridgeConnectionState.Connected(time = 0L) + } else { + SystemBridgeConnectionState.Disconnected(time = 0L, isExpected = true) + } + + whenever(mockSystemBridgeConnectionManager.connectionState).then { + MutableStateFlow( + connectionState, + ) + } } /** @@ -165,7 +189,10 @@ class GetActionErrorUseCaseTest { val action = ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN) val errors = useCase.actionErrorSnapshot.first().getErrors(listOf(action)) - assertThat(errors[action], `is`(KMError.NoCompatibleImeChosen)) + assertThat( + errors[action], + `is`(KMError.KeyEventActionError(KMError.NoCompatibleImeChosen)), + ) } @Test @@ -181,7 +208,7 @@ class GetActionErrorUseCaseTest { val errors = useCase.actionErrorSnapshot.first().getErrors(actions).values.toList() assertThat(errors[0], nullValue()) - assertThat(errors[1], `is`(KMError.NoCompatibleImeChosen)) + assertThat(errors[1], `is`(KMError.KeyEventActionError(KMError.NoCompatibleImeChosen))) } @Test @@ -197,7 +224,7 @@ class GetActionErrorUseCaseTest { val errors = useCase.actionErrorSnapshot.first().getErrors(actions).values.toList() assertThat(errors[0], nullValue()) - assertThat(errors[1], `is`(KMError.NoCompatibleImeChosen)) + assertThat(errors[1], `is`(KMError.KeyEventActionError(KMError.NoCompatibleImeChosen))) } @Test @@ -218,44 +245,60 @@ class GetActionErrorUseCaseTest { assertThat(errors[0], nullValue()) assertThat(errors[1], nullValue()) - assertThat(errors[2], `is`(KMError.NoCompatibleImeChosen)) + assertThat(errors[2], `is`(KMError.KeyEventActionError(KMError.NoCompatibleImeChosen))) } - /** - * #776 - */ @Test - fun `don't show Shizuku errors if a compatible ime is selected`() = testScope.runTest { - // GIVEN - whenever(mockShizukuAdapter.isInstalled).then { MutableStateFlow(true) } - fakeInputMethodAdapter.chosenIme.update { GUI_KEYBOARD_IME_INFO } + fun `do not show an error for a key event action if system bridge is connected, pro mode is selected for key event actions, and no key mapper input method is chosen`() = + testScope.runTest { + setupKeyEventActionTest( + chosenIme = GBOARD_IME_INFO, + isSystemBridgeUsed = true, + isSystemBridgeConnected = true, + ) - val action = ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN) + val actions = listOf( + ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN), + ) - // WHEN - val errorMap = useCase.actionErrorSnapshot.first().getErrors(listOf(action)) + val errors = useCase.actionErrorSnapshot.first().getErrors(actions).values.toList() - // THEN - assertThat(errorMap[action], nullValue()) - } + assertThat(errors[0], nullValue()) + } - /** - * #776 - */ @Test - fun `show Shizuku errors if a compatible ime is not selected and Shizuku is installed`() = + fun `show an error for a key event action if system bridge is disconnected, pro mode is selected for key event actions, and a key mapper input method is chosen`() = testScope.runTest { - // GIVEN - whenever(mockShizukuAdapter.isInstalled).then { MutableStateFlow(true) } - whenever(mockShizukuAdapter.isStarted).then { MutableStateFlow(false) } - fakeInputMethodAdapter.chosenIme.update { GBOARD_IME_INFO } + setupKeyEventActionTest( + chosenIme = GUI_KEYBOARD_IME_INFO, + isSystemBridgeUsed = true, + isSystemBridgeConnected = false, + ) - val action = ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN) + val actions = listOf( + ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN), + ) + + val errors = useCase.actionErrorSnapshot.first().getErrors(actions).values.toList() + + assertThat(errors[0], `is`(KMError.KeyEventActionError(SystemBridgeError.Disconnected))) + } - // WHEN - val errorMap = useCase.actionErrorSnapshot.first().getErrors(listOf(action)) + @Test + fun `show an error for a key event action if system bridge is disconnected, pro mode is selected for key event actions, and a key mapper input method is not chosen`() = + testScope.runTest { + setupKeyEventActionTest( + chosenIme = GBOARD_IME_INFO, + isSystemBridgeUsed = true, + isSystemBridgeConnected = false, + ) + + val actions = listOf( + ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN), + ) + + val errors = useCase.actionErrorSnapshot.first().getErrors(actions).values.toList() - // THEN - assertThat(errorMap[action], `is`(KMError.ShizukuNotStarted)) + assertThat(errors[0], `is`(KMError.KeyEventActionError(SystemBridgeError.Disconnected))) } } 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 5dee3c53b7..e8caecddaf 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 @@ -2,17 +2,18 @@ package io.github.sds100.keymapper.base.actions import android.view.InputDevice import android.view.KeyEvent +import io.github.sds100.keymapper.base.input.InjectKeyEventModel +import io.github.sds100.keymapper.base.input.InputEventHub import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityService import io.github.sds100.keymapper.base.system.devices.FakeDevicesAdapter -import io.github.sds100.keymapper.base.system.inputmethod.ImeInputEventInjector -import io.github.sds100.keymapper.common.utils.InputEventType +import io.github.sds100.keymapper.common.utils.InputDeviceInfo +import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import io.github.sds100.keymapper.system.inputmethod.InputKeyModel +import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.system.popup.ToastAdapter import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -34,33 +35,33 @@ import org.mockito.kotlin.whenever class PerformActionsUseCaseTest { private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = TestScope(testDispatcher) + private val testCoroutineScope = TestScope(testDispatcher) private lateinit var useCase: PerformActionsUseCaseImpl - private lateinit var mockImeInputEventInjector: ImeInputEventInjector private lateinit var fakeDevicesAdapter: FakeDevicesAdapter private lateinit var mockAccessibilityService: IAccessibilityService private lateinit var mockToastAdapter: ToastAdapter + private lateinit var mockInputEventHub: InputEventHub @Before fun init() { - mockImeInputEventInjector = mock() fakeDevicesAdapter = FakeDevicesAdapter() mockAccessibilityService = mock() mockToastAdapter = mock() + mockInputEventHub = mock { + on { runBlocking { injectKeyEvent(any(), any()) } }.then { Success(Unit) } + } useCase = PerformActionsUseCaseImpl( - testScope, service = mockAccessibilityService, inputMethodAdapter = mock(), + switchImeInterface = mock(), fileAdapter = mock(), - suAdapter = mock { - on { isGranted }.then { MutableStateFlow(false) } - }, + suAdapter = mock {}, shell = mock(), intentAdapter = mock(), getActionErrorUseCase = mock(), - keyMapperImeMessenger = mockImeInputEventInjector, + keyMapperImeMessenger = mock(), packageManagerAdapter = mock(), appShortcutAdapter = mock(), toastAdapter = mockToastAdapter, @@ -79,9 +80,12 @@ class PerformActionsUseCaseTest { resourceProvider = mock(), settingsRepository = mock(), soundsManager = mock(), - permissionAdapter = mock(), notificationReceiverAdapter = mock(), ringtoneAdapter = mock(), + inputEventHub = mockInputEventHub, + systemBridgeConnectionManager = mock(), + executeShellCommandUseCase = mock(), + coroutineScope = testCoroutineScope, ) } @@ -89,238 +93,267 @@ class PerformActionsUseCaseTest { * issue #771 */ @Test - fun `dont show accessibility service not found error for open menu action`() = runTest(testDispatcher) { - // GIVEN - val action = ActionData.OpenMenu - - whenever( - mockAccessibilityService.performActionOnNode( - any(), - any(), - ), - ).doReturn(KMError.FailedToFindAccessibilityNode) - - // WHEN - useCase.perform(action) - - // THEN - verify(mockToastAdapter, never()).show(anyOrNull()) - } + fun `dont show accessibility service not found error for open menu action`() = + runTest(testDispatcher) { + // GIVEN + val action = ActionData.OpenMenu + + whenever( + mockAccessibilityService.performActionOnNode( + any(), + any(), + ), + ).doReturn(KMError.FailedToFindAccessibilityNode) + + // WHEN + useCase.perform(action) + + // THEN + verify(mockToastAdapter, never()).show(anyOrNull()) + } /** * issue #772 */ @Test - fun `set the device id of key event actions to a connected game controller if is a game pad key code`() = runTest(testDispatcher) { - // GIVEN - val fakeGamePad = InputDeviceInfo( - descriptor = "game_pad", - name = "Game pad", - id = 1, - isExternal = true, - isGameController = true, - ) - - fakeDevicesAdapter.connectedInputDevices.value = State.Data(listOf(fakeGamePad)) - - val action = ActionData.InputKeyEvent( - keyCode = KeyEvent.KEYCODE_BUTTON_A, - device = null, - ) + fun `set the device id of key event actions to a connected game controller if is a game pad key code`() = + runTest(testDispatcher) { + // GIVEN + val fakeGamePad = InputDeviceInfo( + descriptor = "game_pad", + name = "Game pad", + id = 1, + isExternal = true, + isGameController = true, + sources = InputDevice.SOURCE_GAMEPAD, + ) + + fakeDevicesAdapter.connectedInputDevices.value = State.Data(listOf(fakeGamePad)) + + val action = ActionData.InputKeyEvent( + keyCode = KeyEvent.KEYCODE_BUTTON_A, + device = null, + ) + + // WHEN + useCase.perform(action) + + // THEN + val expectedDownEvent = InjectKeyEventModel( + keyCode = KeyEvent.KEYCODE_BUTTON_A, + action = KeyEvent.ACTION_DOWN, + metaState = 0, + deviceId = fakeGamePad.id, + scanCode = 0, + repeatCount = 0, + source = InputDevice.SOURCE_GAMEPAD, + ) - // WHEN - useCase.perform(action) - - // THEN - val expectedInputKeyModel = InputKeyModel( - keyCode = KeyEvent.KEYCODE_BUTTON_A, - inputType = InputEventType.DOWN_UP, - metaState = 0, - deviceId = fakeGamePad.id, - scanCode = 0, - repeat = 0, - source = InputDevice.SOURCE_GAMEPAD, - ) + val expectedUpEvent = expectedDownEvent.copy(action = KeyEvent.ACTION_UP) - verify(mockImeInputEventInjector, times(1)).inputKeyEvent(expectedInputKeyModel) - } + verify(mockInputEventHub, times(1)).injectKeyEvent(expectedDownEvent, false) + verify(mockInputEventHub, times(1)).injectKeyEvent(expectedUpEvent, false) + } /** * issue #772 */ @Test - fun `don't set the device id of key event actions to a connected game controller if there are no connected game controllers`() = runTest(testDispatcher) { - // GIVEN - fakeDevicesAdapter.connectedInputDevices.value = State.Data(emptyList()) + fun `don't set the device id of key event actions to a connected game controller if there are no connected game controllers`() = + runTest(testDispatcher) { + // GIVEN + fakeDevicesAdapter.connectedInputDevices.value = State.Data(emptyList()) - val action = ActionData.InputKeyEvent( - keyCode = KeyEvent.KEYCODE_BUTTON_A, - device = null, - ) + val action = ActionData.InputKeyEvent( + keyCode = KeyEvent.KEYCODE_BUTTON_A, + device = null, + ) - // WHEN - useCase.perform(action) - - // THEN - val expectedInputKeyModel = InputKeyModel( - keyCode = KeyEvent.KEYCODE_BUTTON_A, - inputType = InputEventType.DOWN_UP, - metaState = 0, - deviceId = 0, - scanCode = 0, - repeat = 0, - source = InputDevice.SOURCE_GAMEPAD, - ) + // WHEN + useCase.perform(action) - verify(mockImeInputEventInjector, times(1)).inputKeyEvent(expectedInputKeyModel) - } + // THEN + val expectedDownEvent = InjectKeyEventModel( + + keyCode = KeyEvent.KEYCODE_BUTTON_A, + action = KeyEvent.ACTION_DOWN, + metaState = 0, + deviceId = 0, + scanCode = 0, + repeatCount = 0, + source = InputDevice.SOURCE_GAMEPAD, + ) + + val expectedUpEvent = expectedDownEvent.copy(action = KeyEvent.ACTION_UP) + + verify(mockInputEventHub, times(1)).injectKeyEvent(expectedDownEvent, false) + verify(mockInputEventHub, times(1)).injectKeyEvent(expectedUpEvent, false) + } /** * issue #772 */ @Test - fun `don't set the device id of key event actions to a connected game controller if the action has a custom device set`() = runTest(testDispatcher) { - // GIVEN - val fakeGamePad = InputDeviceInfo( - descriptor = "game_pad", - name = "Game pad", - id = 1, - isExternal = true, - isGameController = true, - ) + fun `don't set the device id of key event actions to a connected game controller if the action has a custom device set`() = + runTest(testDispatcher) { + // GIVEN + val fakeGamePad = InputDeviceInfo( + descriptor = "game_pad", + name = "Game pad", + id = 1, + isExternal = true, + isGameController = true, + sources = InputDevice.SOURCE_GAMEPAD, + ) + + val fakeKeyboard = InputDeviceInfo( + descriptor = "keyboard", + name = "Keyboard", + id = 2, + isExternal = true, + isGameController = false, + sources = InputDevice.SOURCE_GAMEPAD, + ) + + fakeDevicesAdapter.connectedInputDevices.value = + State.Data(listOf(fakeGamePad, fakeKeyboard)) + + val action = ActionData.InputKeyEvent( + keyCode = KeyEvent.KEYCODE_BUTTON_A, + device = ActionData.InputKeyEvent.Device( + descriptor = "keyboard", + name = "Keyboard", + ), + ) - val fakeKeyboard = InputDeviceInfo( - descriptor = "keyboard", - name = "Keyboard", - id = 2, - isExternal = true, - isGameController = false, - ) + // WHEN + useCase.perform(action) - fakeDevicesAdapter.connectedInputDevices.value = - State.Data(listOf(fakeGamePad, fakeKeyboard)) + // THEN + val expectedDownEvent = InjectKeyEventModel( - val action = ActionData.InputKeyEvent( - keyCode = KeyEvent.KEYCODE_BUTTON_A, - device = ActionData.InputKeyEvent.Device( - descriptor = "keyboard", - name = "Keyboard", - ), - ) + keyCode = KeyEvent.KEYCODE_BUTTON_A, + action = KeyEvent.ACTION_DOWN, + metaState = 0, + deviceId = fakeKeyboard.id, + scanCode = 0, + repeatCount = 0, + source = InputDevice.SOURCE_GAMEPAD, + ) - // WHEN - useCase.perform(action) - - // THEN - val expectedInputKeyModel = InputKeyModel( - keyCode = KeyEvent.KEYCODE_BUTTON_A, - inputType = InputEventType.DOWN_UP, - metaState = 0, - deviceId = fakeKeyboard.id, - scanCode = 0, - repeat = 0, - source = InputDevice.SOURCE_GAMEPAD, - ) + val expectedUpEvent = expectedDownEvent.copy(action = KeyEvent.ACTION_UP) - verify(mockImeInputEventInjector, times(1)).inputKeyEvent(expectedInputKeyModel) - } + verify(mockInputEventHub, times(1)).injectKeyEvent(expectedDownEvent, false) + verify(mockInputEventHub, times(1)).injectKeyEvent(expectedUpEvent, false) + } /** * issue #637 */ @Test - fun `perform key event action with device name and multiple devices connected with same descriptor and none support the key code, ensure action is still performed`() = runTest(testDispatcher) { - // GIVEN - val descriptor = "fake_device_descriptor" - - val action = ActionData.InputKeyEvent( - keyCode = 1, - metaState = 0, - useShell = false, - device = ActionData.InputKeyEvent.Device( - descriptor = descriptor, - name = "fake_name_2", - ), - ) - - fakeDevicesAdapter.connectedInputDevices.value = State.Data( - listOf( - InputDeviceInfo( - descriptor = descriptor, - name = "fake_name_1", - id = 10, - isExternal = true, - isGameController = false, - ), + fun `perform key event action with device name and multiple devices connected with same descriptor and none support the key code, ensure action is still performed`() = + runTest(testDispatcher) { + // GIVEN + val descriptor = "fake_device_descriptor" - InputDeviceInfo( + val action = ActionData.InputKeyEvent( + keyCode = 1, + metaState = 0, + device = ActionData.InputKeyEvent.Device( descriptor = descriptor, name = "fake_name_2", - id = 11, - isExternal = true, - isGameController = false, ), - ), - ) + ) + + fakeDevicesAdapter.connectedInputDevices.value = State.Data( + listOf( + InputDeviceInfo( + descriptor = descriptor, + name = "fake_name_1", + id = 10, + isExternal = true, + isGameController = false, + sources = InputDevice.SOURCE_GAMEPAD, + ), + + InputDeviceInfo( + descriptor = descriptor, + name = "fake_name_2", + id = 11, + isExternal = true, + isGameController = false, + sources = InputDevice.SOURCE_GAMEPAD, + ), + ), + ) + + // none of the devices support the key code + fakeDevicesAdapter.deviceHasKey = { id, keyCode -> false } - // none of the devices support the key code - fakeDevicesAdapter.deviceHasKey = { id, keyCode -> false } + // WHEN + useCase.perform(action, inputEventAction = InputEventAction.DOWN_UP, keyMetaState = 0) - // WHEN - useCase.perform(action, inputEventType = InputEventType.DOWN_UP, keyMetaState = 0) + // THEN + val expectedDownEvent = InjectKeyEventModel( - // THEN - verify(mockImeInputEventInjector, times(1)).inputKeyEvent( - InputKeyModel( keyCode = 1, - inputType = InputEventType.DOWN_UP, + action = KeyEvent.ACTION_DOWN, metaState = 0, deviceId = 11, scanCode = 0, - repeat = 0, + repeatCount = 0, source = InputDevice.SOURCE_KEYBOARD, - ), - ) - } + ) + + val expectedUpEvent = expectedDownEvent.copy(action = KeyEvent.ACTION_UP) + + verify(mockInputEventHub, times(1)).injectKeyEvent(expectedDownEvent, false) + verify(mockInputEventHub, times(1)).injectKeyEvent(expectedUpEvent, false) + } @Test - fun `perform key event action with no device name, ensure action is still performed with correct device id`() = runTest(testDispatcher) { - // GIVEN - val descriptor = "fake_device_descriptor" - - val action = ActionData.InputKeyEvent( - keyCode = 1, - metaState = 0, - useShell = false, - device = ActionData.InputKeyEvent.Device(descriptor = descriptor, name = ""), - ) + fun `perform key event action with no device name, ensure action is still performed with correct device id`() = + runTest(testDispatcher) { + // GIVEN + val descriptor = "fake_device_descriptor" - fakeDevicesAdapter.connectedInputDevices.value = State.Data( - listOf( - InputDeviceInfo( - descriptor = descriptor, - name = "fake_name", - id = 10, - isExternal = true, - isGameController = false, + val action = ActionData.InputKeyEvent( + keyCode = 1, + metaState = 0, + device = ActionData.InputKeyEvent.Device(descriptor = descriptor, name = ""), + ) + + fakeDevicesAdapter.connectedInputDevices.value = State.Data( + listOf( + InputDeviceInfo( + descriptor = descriptor, + name = "fake_name", + id = 10, + isExternal = true, + isGameController = false, + sources = InputDevice.SOURCE_GAMEPAD, + ), ), - ), - ) + ) + + // WHEN + useCase.perform(action, inputEventAction = InputEventAction.DOWN_UP, keyMetaState = 0) - // WHEN - useCase.perform(action, inputEventType = InputEventType.DOWN_UP, keyMetaState = 0) + // THEN + val expectedDownEvent = InjectKeyEventModel( - // THEN - verify(mockImeInputEventInjector, times(1)).inputKeyEvent( - InputKeyModel( keyCode = 1, - inputType = InputEventType.DOWN_UP, + action = KeyEvent.ACTION_DOWN, metaState = 0, deviceId = 10, scanCode = 0, - repeat = 0, + repeatCount = 0, source = InputDevice.SOURCE_KEYBOARD, - ), - ) - } + ) + + val expectedUpEvent = expectedDownEvent.copy(action = KeyEvent.ACTION_UP) + + verify(mockInputEventHub, times(1)).injectKeyEvent(expectedDownEvent, false) + verify(mockInputEventHub, times(1)).injectKeyEvent(expectedUpEvent, false) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/keyevents/ConfigKeyServiceEventActionViewModelTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/keyevents/ConfigKeyServiceEventActionViewModelTest.kt index 03b1da73f4..06c15a8e01 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/keyevents/ConfigKeyServiceEventActionViewModelTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/keyevents/ConfigKeyServiceEventActionViewModelTest.kt @@ -1,9 +1,10 @@ package io.github.sds100.keymapper.base.actions.keyevents +import android.view.InputDevice import androidx.arch.core.executor.testing.InstantTaskExecutorRule import io.github.sds100.keymapper.base.actions.keyevent.ConfigKeyEventActionViewModel import io.github.sds100.keymapper.base.actions.keyevent.ConfigKeyEventUseCase -import io.github.sds100.keymapper.system.devices.InputDeviceInfo +import io.github.sds100.keymapper.common.utils.InputDeviceInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -71,6 +72,7 @@ class ConfigKeyServiceEventActionViewModelTest { id = 0, isExternal = false, isGameController = false, + sources = InputDevice.SOURCE_KEYBOARD, ) val fakeDevice2 = InputDeviceInfo( @@ -79,6 +81,7 @@ class ConfigKeyServiceEventActionViewModelTest { id = 1, isExternal = false, isGameController = false, + sources = InputDevice.SOURCE_KEYBOARD, ) // WHEN diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt index 9769a7386e..d0030f4883 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt @@ -18,8 +18,8 @@ class ConstraintSnapshotTest { val state1 = ConstraintState( constraints = setOf( - Constraint.AppInForeground(packageName = "key_mapper"), - Constraint.Discharging(), + Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")), + Constraint(data = ConstraintData.Discharging), ), mode = ConstraintMode.AND, ) @@ -28,8 +28,8 @@ class ConstraintSnapshotTest { ConstraintState( constraints = setOf( - Constraint.LockScreenNotShowing(), - Constraint.DeviceIsUnlocked(), + Constraint(data = ConstraintData.LockScreenNotShowing), + Constraint(data = ConstraintData.DeviceIsUnlocked), ), mode = ConstraintMode.OR, ) @@ -38,8 +38,8 @@ class ConstraintSnapshotTest { ConstraintState( constraints = setOf( - Constraint.LockScreenShowing(), - Constraint.DeviceIsUnlocked(), + Constraint(data = ConstraintData.LockScreenShowing), + Constraint(data = ConstraintData.DeviceIsUnlocked), ), mode = ConstraintMode.AND, ) @@ -58,8 +58,8 @@ class ConstraintSnapshotTest { val state1 = ConstraintState( constraints = setOf( - Constraint.AppInForeground(packageName = "key_mapper"), - Constraint.Discharging(), + Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")), + Constraint(data = ConstraintData.Discharging), ), mode = ConstraintMode.AND, ) @@ -68,8 +68,8 @@ class ConstraintSnapshotTest { ConstraintState( constraints = setOf( - Constraint.Charging(), - Constraint.DeviceIsUnlocked(), + Constraint(data = ConstraintData.Charging), + Constraint(data = ConstraintData.DeviceIsUnlocked), ), mode = ConstraintMode.OR, ) @@ -88,8 +88,8 @@ class ConstraintSnapshotTest { val state1 = ConstraintState( constraints = setOf( - Constraint.AppInForeground(packageName = "key_mapper"), - Constraint.Charging(), + Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")), + Constraint(data = ConstraintData.Charging), ), mode = ConstraintMode.AND, ) @@ -98,8 +98,8 @@ class ConstraintSnapshotTest { ConstraintState( constraints = setOf( - Constraint.Charging(), - Constraint.DeviceIsUnlocked(), + Constraint(data = ConstraintData.Charging), + Constraint(data = ConstraintData.DeviceIsUnlocked), ), mode = ConstraintMode.OR, ) @@ -113,13 +113,13 @@ class ConstraintSnapshotTest { val state1 = ConstraintState( constraints = - setOf(Constraint.AppInForeground(packageName = "key_mapper")), + setOf(Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper"))), ) val state2 = ConstraintState( constraints = - setOf(Constraint.Charging()), + setOf(Constraint(data = ConstraintData.Charging)), ) assertThat(snapshot.isSatisfied(state1, state2), `is`(true)) @@ -131,13 +131,13 @@ class ConstraintSnapshotTest { val state1 = ConstraintState( constraints = - setOf(Constraint.AppInForeground(packageName = "google")), + setOf(Constraint(data = ConstraintData.AppInForeground(packageName = "google"))), ) val state2 = ConstraintState( constraints = - setOf(Constraint.AppInForeground(packageName = "google1")), + setOf(Constraint(data = ConstraintData.AppInForeground(packageName = "google1"))), ) assertThat(snapshot.isSatisfied(state1, state2), `is`(false)) @@ -149,13 +149,15 @@ class ConstraintSnapshotTest { val state1 = ConstraintState( constraints = - setOf(Constraint.AppInForeground(packageName = "google")), + setOf(Constraint(data = ConstraintData.AppInForeground(packageName = "google"))), ) val state2 = ConstraintState( constraints = - setOf(Constraint.AppInForeground(packageName = "key_mapper")), + setOf( + Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")), + ), ) assertThat(snapshot.isSatisfied(state1, state2), `is`(false)) @@ -184,7 +186,7 @@ class ConstraintSnapshotTest { @Test fun `When one constraint and unsatisfied return false`() { val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper") - val constraint = Constraint.AppInForeground(packageName = "google") + val constraint = Constraint(data = ConstraintData.AppInForeground(packageName = "google")) val state = ConstraintState(constraints = setOf(constraint)) assertThat(snapshot.isSatisfied(state), `is`(false)) } @@ -192,7 +194,8 @@ class ConstraintSnapshotTest { @Test fun `When one constraint and satisfied return true`() { val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper") - val constraint = Constraint.AppInForeground(packageName = "key_mapper") + val constraint = + Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")) val state = ConstraintState(constraints = setOf(constraint)) assertThat(snapshot.isSatisfied(state), `is`(true)) } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DpadMotionEventTrackerTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DpadMotionEventTrackerTest.kt index 23ce84f14b..0cf0ab5c52 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DpadMotionEventTrackerTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DpadMotionEventTrackerTest.kt @@ -2,10 +2,10 @@ package io.github.sds100.keymapper.base.keymaps import android.view.InputDevice import android.view.KeyEvent -import io.github.sds100.keymapper.base.keymaps.detection.DpadMotionEventTracker -import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import io.github.sds100.keymapper.system.inputevents.MyKeyEvent -import io.github.sds100.keymapper.system.inputevents.MyMotionEvent +import io.github.sds100.keymapper.base.detection.DpadMotionEventTracker +import io.github.sds100.keymapper.common.utils.InputDeviceInfo +import io.github.sds100.keymapper.system.inputevents.KMGamePadEvent +import io.github.sds100.keymapper.system.inputevents.KMKeyEvent import junitparams.JUnitParamsRunner import kotlinx.coroutines.ExperimentalCoroutinesApi import org.hamcrest.MatcherAssert.assertThat @@ -27,6 +27,7 @@ class DpadMotionEventTrackerTest { name = "Controller 1", isExternal = true, isGameController = true, + sources = InputDevice.SOURCE_GAMEPAD, ) private val CONTROLLER_2_DEVICE = InputDeviceInfo( @@ -35,6 +36,7 @@ class DpadMotionEventTrackerTest { name = "Controller 2", isExternal = true, isGameController = true, + sources = InputDevice.SOURCE_GAMEPAD, ) } @@ -60,7 +62,7 @@ class DpadMotionEventTrackerTest { assertThat( keyEvents, hasItem( - MyKeyEvent( + KMKeyEvent( KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.ACTION_UP, metaState = 0, @@ -68,13 +70,14 @@ class DpadMotionEventTrackerTest { device = CONTROLLER_1_DEVICE, repeatCount = 0, source = InputDevice.SOURCE_DPAD, + eventTime = motionEvent.eventTime, ), ), ) assertThat( keyEvents, hasItem( - MyKeyEvent( + KMKeyEvent( KeyEvent.KEYCODE_DPAD_UP, KeyEvent.ACTION_UP, metaState = 0, @@ -82,6 +85,7 @@ class DpadMotionEventTrackerTest { device = CONTROLLER_1_DEVICE, repeatCount = 0, source = InputDevice.SOURCE_DPAD, + eventTime = motionEvent.eventTime, ), ), ) @@ -257,19 +261,18 @@ class DpadMotionEventTrackerTest { axisHatX: Float = 0.0f, axisHatY: Float = 0.0f, device: InputDeviceInfo = CONTROLLER_1_DEVICE, - isDpad: Boolean = true, - ): MyMotionEvent { - return MyMotionEvent( + ): KMGamePadEvent { + return KMGamePadEvent( metaState = 0, device = device, axisHatX = axisHatX, axisHatY = axisHatY, - isDpad = isDpad, + eventTime = System.currentTimeMillis(), ) } - private fun createDownKeyEvent(keyCode: Int, device: InputDeviceInfo): MyKeyEvent { - return MyKeyEvent( + private fun createDownKeyEvent(keyCode: Int, device: InputDeviceInfo): KMKeyEvent { + return KMKeyEvent( keyCode = keyCode, action = KeyEvent.ACTION_DOWN, metaState = 0, @@ -277,11 +280,12 @@ class DpadMotionEventTrackerTest { device = device, repeatCount = 0, source = 0, + eventTime = System.currentTimeMillis(), ) } - private fun createUpKeyEvent(keyCode: Int, device: InputDeviceInfo): MyKeyEvent { - return MyKeyEvent( + private fun createUpKeyEvent(keyCode: Int, device: InputDeviceInfo): KMKeyEvent { + return KMKeyEvent( keyCode = keyCode, action = KeyEvent.ACTION_UP, metaState = 0, @@ -289,6 +293,8 @@ class DpadMotionEventTrackerTest { device = device, repeatCount = 0, source = 0, + eventTime = System.currentTimeMillis(), + ) } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapControllerTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt similarity index 77% rename from base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapControllerTest.kt rename to base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt index 4224d0c656..11a00e269a 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapControllerTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.keymaps +import android.view.InputDevice import android.view.KeyEvent import androidx.arch.core.executor.testing.InstantTaskExecutorRule import io.github.sds100.keymapper.base.actions.Action @@ -8,41 +9,44 @@ import io.github.sds100.keymapper.base.actions.ActionErrorSnapshot import io.github.sds100.keymapper.base.actions.PerformActionsUseCase import io.github.sds100.keymapper.base.actions.RepeatMode import io.github.sds100.keymapper.base.constraints.Constraint +import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.constraints.ConstraintSnapshot import io.github.sds100.keymapper.base.constraints.ConstraintState import io.github.sds100.keymapper.base.constraints.DetectConstraintsUseCase -import io.github.sds100.keymapper.base.keymaps.detection.DetectKeyMapModel -import io.github.sds100.keymapper.base.keymaps.detection.DetectKeyMapsUseCase -import io.github.sds100.keymapper.base.keymaps.detection.KeyMapController +import io.github.sds100.keymapper.base.detection.DetectKeyMapModel +import io.github.sds100.keymapper.base.detection.DetectKeyMapsUseCase +import io.github.sds100.keymapper.base.detection.KeyMapAlgorithm import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.base.trigger.EvdevTriggerKey import io.github.sds100.keymapper.base.trigger.FingerprintTriggerKey -import io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey -import io.github.sds100.keymapper.base.trigger.KeyEventDetectionSource +import io.github.sds100.keymapper.base.trigger.KeyEventTriggerDevice +import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey import io.github.sds100.keymapper.base.trigger.Trigger import io.github.sds100.keymapper.base.trigger.TriggerKey -import io.github.sds100.keymapper.base.trigger.TriggerKeyDevice import io.github.sds100.keymapper.base.trigger.TriggerMode import io.github.sds100.keymapper.base.utils.TestConstraintSnapshot import io.github.sds100.keymapper.base.utils.parallelTrigger import io.github.sds100.keymapper.base.utils.sequenceTrigger import io.github.sds100.keymapper.base.utils.singleKeyTrigger import io.github.sds100.keymapper.base.utils.triggerKey -import io.github.sds100.keymapper.common.utils.InputEventType +import io.github.sds100.keymapper.common.models.EvdevDeviceHandle +import io.github.sds100.keymapper.common.models.EvdevDeviceInfo +import io.github.sds100.keymapper.common.utils.InputDeviceInfo +import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.withFlag import io.github.sds100.keymapper.system.camera.CameraLens -import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import io.github.sds100.keymapper.system.inputevents.MyKeyEvent -import io.github.sds100.keymapper.system.inputevents.MyMotionEvent -import io.github.sds100.keymapper.system.inputmethod.ImeInfo +import io.github.sds100.keymapper.system.inputevents.KMEvdevEvent +import io.github.sds100.keymapper.system.inputevents.KMGamePadEvent +import io.github.sds100.keymapper.system.inputevents.KMKeyEvent +import io.github.sds100.keymapper.system.inputevents.Scancode import junitparams.JUnitParamsRunner import junitparams.Parameters import junitparams.naming.TestCaseName import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -51,11 +55,14 @@ import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.eq +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic import org.mockito.kotlin.any import org.mockito.kotlin.atLeast import org.mockito.kotlin.doAnswer @@ -69,33 +76,56 @@ import org.mockito.kotlin.whenever @ExperimentalCoroutinesApi @RunWith(JUnitParamsRunner::class) -class KeyMapControllerTest { +class KeyMapAlgorithmTest { companion object { private const val FAKE_KEYBOARD_DEVICE_ID = 123 private const val FAKE_KEYBOARD_DESCRIPTOR = "fake_keyboard" - private val FAKE_KEYBOARD_TRIGGER_KEY_DEVICE = TriggerKeyDevice.External( + private val FAKE_KEYBOARD_TRIGGER_KEY_DEVICE = KeyEventTriggerDevice.External( descriptor = FAKE_KEYBOARD_DESCRIPTOR, name = "Fake Keyboard", ) + private val FAKE_INTERNAL_DEVICE = InputDeviceInfo( + descriptor = "volume_keys", + name = "Volume keys", + id = 0, + isExternal = false, + isGameController = false, + sources = InputDevice.SOURCE_UNKNOWN, + ) private const val FAKE_HEADPHONE_DESCRIPTOR = "fake_headphone" - private val FAKE_HEADPHONE_TRIGGER_KEY_DEVICE = TriggerKeyDevice.External( + private val FAKE_HEADPHONE_TRIGGER_KEY_DEVICE = KeyEventTriggerDevice.External( descriptor = FAKE_HEADPHONE_DESCRIPTOR, name = "Fake Headphones", ) private const val FAKE_CONTROLLER_DESCRIPTOR = "fake_controller" - private val FAKE_CONTROLLER_TRIGGER_KEY_DEVICE = TriggerKeyDevice.External( + private val FAKE_CONTROLLER_TRIGGER_KEY_DEVICE = KeyEventTriggerDevice.External( descriptor = FAKE_CONTROLLER_DESCRIPTOR, name = "Fake Controller", ) private val FAKE_CONTROLLER_INPUT_DEVICE = InputDeviceInfo( descriptor = FAKE_CONTROLLER_DESCRIPTOR, name = "Fake Controller", - id = 0, + id = 1, isExternal = true, isGameController = true, + sources = InputDevice.SOURCE_GAMEPAD, + ) + + private val FAKE_CONTROLLER_EVDEV_DEVICE = EvdevDeviceInfo( + name = "Fake Controller", + bus = 1, + vendor = 2, + product = 1, + ) + + private val FAKE_VOLUME_EVDEV_DEVICE = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, ) private const val FAKE_PACKAGE_NAME = "test_package" @@ -116,30 +146,13 @@ class KeyMapControllerTest { private val TEST_ACTION_2: Action = Action( data = ActionData.App(FAKE_PACKAGE_NAME), ) - - private val GUI_KEYBOARD_IME_INFO = ImeInfo( - id = "ime_id", - packageName = "io.github.sds100.keymapper.inputmethod.latin", - label = "Key Mapper GUI Keyboard", - isEnabled = true, - isChosen = true, - ) - - private val GBOARD_IME_INFO = ImeInfo( - id = "gboard_id", - packageName = "com.google.android.inputmethod.latin", - label = "Gboard", - isEnabled = true, - isChosen = false, - ) } - private lateinit var controller: KeyMapController + private lateinit var controller: KeyMapAlgorithm private lateinit var detectKeyMapsUseCase: DetectKeyMapsUseCase private lateinit var performActionsUseCase: PerformActionsUseCase private lateinit var detectConstraintsUseCase: DetectConstraintsUseCase - private lateinit var keyMapListFlow: MutableStateFlow> - private lateinit var detectKeyMapListFlow: MutableStateFlow> + private lateinit var mockedKeyEvent: MockedStatic @get:Rule var instantExecutorRule = InstantTaskExecutorRule() @@ -147,19 +160,17 @@ class KeyMapControllerTest { private val testDispatcher = UnconfinedTestDispatcher() private val testScope = TestScope(testDispatcher) + private fun loadKeyMaps(vararg keyMap: KeyMap) { + controller.loadKeyMaps(keyMap.map { DetectKeyMapModel(it) }) + } + + private fun loadKeyMaps(vararg keyMap: DetectKeyMapModel) { + controller.loadKeyMaps(keyMap.toList()) + } + @Before fun init() { - keyMapListFlow = MutableStateFlow(emptyList()) - detectKeyMapListFlow = MutableStateFlow(emptyList()) - detectKeyMapsUseCase = mock { - on { allKeyMapList } doReturn combine( - keyMapListFlow, - detectKeyMapListFlow, - ) { keyMapList, detectKeyMapList -> - keyMapList.map { DetectKeyMapModel(keyMap = it) }.plus(detectKeyMapList) - } - MutableStateFlow(VIBRATION_DURATION).apply { on { defaultVibrateDuration } doReturn this } @@ -208,7 +219,10 @@ class KeyMapControllerTest { on { getSnapshot() } doReturn TestConstraintSnapshot() } - controller = KeyMapController( + mockedKeyEvent = mockStatic(KeyEvent::class.java) + mockedKeyEvent.`when` { KeyEvent.getMaxKeyCode() }.thenReturn(1000) + + controller = KeyMapAlgorithm( testScope, detectKeyMapsUseCase, performActionsUseCase, @@ -216,31 +230,431 @@ class KeyMapControllerTest { ) } + @After + fun tearDown() { + mockedKeyEvent.close() + } + + @Test + fun `Detect mouse button which only has scan code`() = runTest(testDispatcher) { + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_UNKNOWN, + scanCode = Scancode.BTN_LEFT, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + ), + ) + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + inputDownEvdevEvent( + KeyEvent.KEYCODE_UNKNOWN, + Scancode.BTN_LEFT, + FAKE_CONTROLLER_EVDEV_DEVICE, + ) + inputUpEvdevEvent(KeyEvent.KEYCODE_UNKNOWN, Scancode.BTN_LEFT, FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Detect evdev trigger with scan code if key has unknown key code`() = + runTest(testDispatcher) { + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_UNKNOWN, + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + ), + ) + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + inputDownEvdevEvent(KeyEvent.KEYCODE_B, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + inputUpEvdevEvent(KeyEvent.KEYCODE_B, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Detect evdev trigger with scan code if user setting enabled`() = runTest(testDispatcher) { + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_A, + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + detectWithScanCodeUserSetting = true, + ), + ) + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + inputDownEvdevEvent(KeyEvent.KEYCODE_B, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + inputUpEvdevEvent(KeyEvent.KEYCODE_B, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Detect evdev trigger with scan code when scan code matches but key code differs`() = + runTest(testDispatcher) { + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_A, + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + detectWithScanCodeUserSetting = true, + ), + ) + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + // Input with different key code but matching scan code + inputDownEvdevEvent(KeyEvent.KEYCODE_C, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + inputUpEvdevEvent(KeyEvent.KEYCODE_C, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Do not detect evdev trigger when scan code differs`() = runTest(testDispatcher) { + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_A, + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + detectWithScanCodeUserSetting = true, + ), + ) + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + // Input with matching key code but different scan code + inputDownEvdevEvent(KeyEvent.KEYCODE_A, Scancode.KEY_C, FAKE_CONTROLLER_EVDEV_DEVICE) + inputUpEvdevEvent(KeyEvent.KEYCODE_A, Scancode.KEY_C, FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, never()).perform(TEST_ACTION.data) + } + + @Test + fun `Sequence trigger with multiple evdev keys and scan code detection is triggered`() = + runTest(testDispatcher) { + val trigger = sequenceTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_A, + // Different scan code + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + detectWithScanCodeUserSetting = true, + ), + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_C, + // Different scan code + scanCode = Scancode.KEY_D, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + detectWithScanCodeUserSetting = true, + ), + ) + + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + // Input with scan codes that match the trigger + inputDownEvdevEvent(KeyEvent.KEYCODE_X, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + inputUpEvdevEvent(KeyEvent.KEYCODE_X, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + + inputDownEvdevEvent(KeyEvent.KEYCODE_Y, Scancode.KEY_D, FAKE_CONTROLLER_EVDEV_DEVICE) + inputUpEvdevEvent(KeyEvent.KEYCODE_Y, Scancode.KEY_D, FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Parallel trigger with multiple evdev keys and scan code detection is triggered`() = + runTest(testDispatcher) { + val trigger = parallelTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_A, + // Different scan code + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + detectWithScanCodeUserSetting = true, + ), + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_C, + // Different scan code + scanCode = Scancode.KEY_D, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + detectWithScanCodeUserSetting = true, + ), + ) + + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + // Input both keys simultaneously with scan codes that match the trigger + inputDownEvdevEvent(KeyEvent.KEYCODE_X, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + inputDownEvdevEvent(KeyEvent.KEYCODE_Y, Scancode.KEY_D, FAKE_CONTROLLER_EVDEV_DEVICE) + + inputUpEvdevEvent(KeyEvent.KEYCODE_X, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + inputUpEvdevEvent(KeyEvent.KEYCODE_Y, Scancode.KEY_D, FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Scan code detection works with long press evdev trigger`() = runTest(testDispatcher) { + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_A, + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + clickType = ClickType.LONG_PRESS, + detectWithScanCodeUserSetting = true, + ), + ) + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + inputDownEvdevEvent(KeyEvent.KEYCODE_X, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + // Wait for long press duration + delay(LONG_PRESS_DELAY + 100L) + inputUpEvdevEvent(KeyEvent.KEYCODE_X, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Scan code detection works with double press evdev trigger`() = runTest(testDispatcher) { + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_A, + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + clickType = ClickType.DOUBLE_PRESS, + detectWithScanCodeUserSetting = true, + ), + ) + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + // First press + inputDownEvdevEvent(KeyEvent.KEYCODE_X, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + delay(50L) + inputUpEvdevEvent(KeyEvent.KEYCODE_X, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + delay(50L) + + // Second press + inputDownEvdevEvent(KeyEvent.KEYCODE_X, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + delay(50L) + inputUpEvdevEvent(KeyEvent.KEYCODE_X, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Scan code detection fails when device differs for evdev trigger`() = + runTest(testDispatcher) { + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_A, + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + ), + ) + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + // Input from different device + inputDownEvdevEvent(KeyEvent.KEYCODE_X, Scancode.KEY_B, FAKE_VOLUME_EVDEV_DEVICE) + inputUpEvdevEvent(KeyEvent.KEYCODE_X, Scancode.KEY_B, FAKE_VOLUME_EVDEV_DEVICE) + + verify(performActionsUseCase, never()).perform(TEST_ACTION.data) + } + + @Test + fun `Detect key event trigger with scan code if key has unknown key code`() = + runTest(testDispatcher) { + val trigger = singleKeyTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_UNKNOWN, + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_TRIGGER_KEY_DEVICE, + clickType = ClickType.SHORT_PRESS, + // It will be automatically enabled even if the user hasn't explicitly turned it on + detectWithScanCodeUserSetting = false, + ), + ) + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + inputKeyEvent( + keyCode = KeyEvent.KEYCODE_B, + action = KeyEvent.ACTION_DOWN, + device = FAKE_CONTROLLER_INPUT_DEVICE, + scanCode = Scancode.KEY_B, + ) + inputKeyEvent( + keyCode = KeyEvent.KEYCODE_B, + action = KeyEvent.ACTION_UP, + device = FAKE_CONTROLLER_INPUT_DEVICE, + scanCode = Scancode.KEY_B, + ) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Detect key event trigger with scan code if user setting enabled`() = + runTest(testDispatcher) { + val trigger = singleKeyTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_A, + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_TRIGGER_KEY_DEVICE, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ), + ) + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + inputKeyEvent( + keyCode = KeyEvent.KEYCODE_B, + action = KeyEvent.ACTION_DOWN, + device = FAKE_CONTROLLER_INPUT_DEVICE, + scanCode = Scancode.KEY_B, + ) + inputKeyEvent( + keyCode = KeyEvent.KEYCODE_B, + action = KeyEvent.ACTION_UP, + device = FAKE_CONTROLLER_INPUT_DEVICE, + scanCode = Scancode.KEY_B, + ) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Sequence trigger with multiple evdev keys is triggered`() = runTest(testDispatcher) { + val trigger = sequenceTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_A, + scanCode = Scancode.KEY_A, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + ), + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_B, + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + ), + ) + + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + inputDownEvdevEvent(KeyEvent.KEYCODE_A, Scancode.KEY_A, FAKE_CONTROLLER_EVDEV_DEVICE) + inputUpEvdevEvent(KeyEvent.KEYCODE_A, Scancode.KEY_A, FAKE_CONTROLLER_EVDEV_DEVICE) + + inputDownEvdevEvent(KeyEvent.KEYCODE_B, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + inputUpEvdevEvent(KeyEvent.KEYCODE_B, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Parallel trigger with multiple evdev keys is triggered`() = runTest(testDispatcher) { + val trigger = parallelTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_A, + scanCode = Scancode.KEY_A, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + ), + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_B, + scanCode = Scancode.KEY_B, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + ), + ) + + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + inputDownEvdevEvent(KeyEvent.KEYCODE_A, Scancode.KEY_A, FAKE_CONTROLLER_EVDEV_DEVICE) + inputDownEvdevEvent(KeyEvent.KEYCODE_B, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + + inputUpEvdevEvent(KeyEvent.KEYCODE_A, Scancode.KEY_A, FAKE_CONTROLLER_EVDEV_DEVICE) + inputUpEvdevEvent(KeyEvent.KEYCODE_B, Scancode.KEY_B, FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Evdev trigger is not triggered from events from other devices`() = + runTest(testDispatcher) { + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_POWER, + scanCode = Scancode.KEY_POWER, + device = FAKE_VOLUME_EVDEV_DEVICE, + ), + ) + + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + mockEvdevKeyInput(trigger.keys[0], FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, never()).perform(TEST_ACTION.data) + } + + @Test + fun `Short press trigger evdev trigger from external device`() = runTest(testDispatcher) { + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_A, + scanCode = Scancode.KEY_POWER, + device = FAKE_CONTROLLER_EVDEV_DEVICE, + ), + ) + + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + mockEvdevKeyInput(trigger.keys[0], FAKE_CONTROLLER_EVDEV_DEVICE) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + + @Test + fun `Short press trigger evdev trigger from internal device`() = runTest(testDispatcher) { + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_POWER, + scanCode = Scancode.KEY_POWER, + device = FAKE_VOLUME_EVDEV_DEVICE, + ), + ) + + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION))) + + mockEvdevKeyInput(trigger.keys[0], FAKE_VOLUME_EVDEV_DEVICE) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + } + @Test fun `Do not perform if one group constraint set is not satisfied`() = runTest(testDispatcher) { val trigger = singleKeyTrigger(triggerKey(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN)) - detectKeyMapListFlow.value = listOf( + loadKeyMaps( DetectKeyMapModel( keyMap = KeyMap( trigger = trigger, actionList = listOf(TEST_ACTION), constraintState = ConstraintState( - constraints = setOf(Constraint.WifiOn(), Constraint.DeviceIsLocked()), + constraints = setOf( + Constraint(data = ConstraintData.WifiOn), + Constraint(data = ConstraintData.DeviceIsLocked), + ), mode = ConstraintMode.OR, ), ), groupConstraintStates = listOf( ConstraintState( constraints = setOf( - Constraint.LockScreenNotShowing(), - Constraint.DeviceIsLocked(), + Constraint(data = ConstraintData.LockScreenNotShowing), + Constraint(data = ConstraintData.DeviceIsLocked), ), mode = ConstraintMode.AND, ), ConstraintState( constraints = setOf( - Constraint.AppInForeground(packageName = "app"), - Constraint.DeviceIsUnlocked(), + Constraint(data = ConstraintData.AppInForeground(packageName = "app")), + Constraint(data = ConstraintData.DeviceIsUnlocked), ), mode = ConstraintMode.OR, ), @@ -267,28 +681,33 @@ class KeyMapControllerTest { fun `Perform if all group constraints and key map constraints are satisfied`() = runTest(testDispatcher) { val trigger = singleKeyTrigger(triggerKey(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN)) - detectKeyMapListFlow.value = listOf( + loadKeyMaps( DetectKeyMapModel( keyMap = KeyMap( trigger = trigger, actionList = listOf(TEST_ACTION), constraintState = ConstraintState( - constraints = setOf(Constraint.WifiOn(), Constraint.DeviceIsLocked()), + constraints = setOf( + Constraint(data = ConstraintData.WifiOn), + Constraint(data = ConstraintData.DeviceIsLocked), + ), mode = ConstraintMode.OR, ), ), groupConstraintStates = listOf( ConstraintState( constraints = setOf( - Constraint.LockScreenNotShowing(), - Constraint.DeviceIsLocked(), + Constraint(data = ConstraintData.LockScreenNotShowing), + Constraint(data = ConstraintData.DeviceIsLocked), ), mode = ConstraintMode.AND, ), ConstraintState( constraints = setOf( - Constraint.AppInForeground(packageName = "app"), - Constraint.DeviceIsUnlocked(), + Constraint( + data = ConstraintData.AppInForeground(packageName = "app"), + ), + Constraint(data = ConstraintData.DeviceIsUnlocked), ), mode = ConstraintMode.OR, ), @@ -348,7 +767,7 @@ class KeyMapControllerTest { vibrate = true, ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = shortPressTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = doublePressTrigger, actionList = listOf(TEST_ACTION_2)), KeyMap(2, trigger = longPressTrigger, actionList = listOf(TEST_ACTION_2)), @@ -389,7 +808,7 @@ class KeyMapControllerTest { vibrate = true, ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = shortPressTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = doublePressTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -428,7 +847,7 @@ class KeyMapControllerTest { vibrate = true, ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = shortPressTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = doublePressTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -469,7 +888,7 @@ class KeyMapControllerTest { vibrate = true, ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = shortPressTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = longPressTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -510,7 +929,7 @@ class KeyMapControllerTest { vibrate = true, ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = shortPressTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = longPressTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -551,7 +970,7 @@ class KeyMapControllerTest { vibrate = true, ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = shortPressTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = longPressTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -590,7 +1009,7 @@ class KeyMapControllerTest { vibrate = true, ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = shortPressTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = longPressTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -606,7 +1025,7 @@ class KeyMapControllerTest { @Test fun `Sequence trigger with fingerprint gesture and key code`() = runTest(testDispatcher) { // GIVEN - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap( trigger = sequenceTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), @@ -631,7 +1050,7 @@ class KeyMapControllerTest { @Test fun `Input fingerprint gesture`() = runTest(testDispatcher) { // GIVEN - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap( trigger = singleKeyTrigger( FingerprintTriggerKey( @@ -664,7 +1083,7 @@ class KeyMapControllerTest { sequenceTrigger(triggerKey(KeyEvent.KEYCODE_J), triggerKey(KeyEvent.KEYCODE_K)) val enterAction = Action(data = ActionData.InputKeyEvent(KeyEvent.KEYCODE_ENTER)) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = copyTrigger, actionList = listOf(copyAction)), KeyMap(1, trigger = sequenceTrigger, actionList = listOf(enterAction)), ) @@ -715,7 +1134,7 @@ class KeyMapControllerTest { val sequenceTriggerAction2 = Action(data = ActionData.InputKeyEvent(KeyEvent.KEYCODE_ENTER)) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = copyTrigger, actionList = listOf(copyAction)), KeyMap(1, trigger = sequenceTrigger1, actionList = listOf(sequenceTriggerAction1)), KeyMap(2, trigger = sequenceTrigger2, actionList = listOf(sequenceTriggerAction2)), @@ -758,7 +1177,7 @@ class KeyMapControllerTest { sequenceTrigger(triggerKey(KeyEvent.KEYCODE_J), triggerKey(KeyEvent.KEYCODE_K)) val enterAction = Action(data = ActionData.InputKeyEvent(KeyEvent.KEYCODE_ENTER)) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = copyTrigger, actionList = listOf(copyAction)), KeyMap(1, trigger = pasteTrigger, actionList = listOf(pasteAction)), KeyMap(2, trigger = sequenceTrigger, actionList = listOf(enterAction)), @@ -791,7 +1210,7 @@ class KeyMapControllerTest { sequenceTrigger(triggerKey(KeyEvent.KEYCODE_J), triggerKey(KeyEvent.KEYCODE_K)) val enterAction = Action(data = ActionData.InputKeyEvent(KeyEvent.KEYCODE_ENTER)) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = copyTrigger, actionList = listOf(copyAction)), KeyMap(1, trigger = pasteTrigger, actionList = listOf(pasteAction)), KeyMap(2, trigger = sequenceTrigger, actionList = listOf(enterAction)), @@ -823,7 +1242,7 @@ class KeyMapControllerTest { sequenceTrigger(triggerKey(KeyEvent.KEYCODE_J), triggerKey(KeyEvent.KEYCODE_K)) val enterAction = Action(data = ActionData.InputKeyEvent(KeyEvent.KEYCODE_ENTER)) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = copyTrigger, actionList = listOf(copyAction)), KeyMap(1, trigger = pasteTrigger, actionList = listOf(pasteAction)), KeyMap(2, trigger = sequenceTrigger, actionList = listOf(enterAction)), @@ -846,7 +1265,7 @@ class KeyMapControllerTest { triggerKey( KeyEvent.KEYCODE_DPAD_LEFT, clickType = ClickType.SHORT_PRESS, - detectionSource = KeyEventDetectionSource.INPUT_METHOD, + requiresIme = true, device = FAKE_CONTROLLER_TRIGGER_KEY_DEVICE, ), ) @@ -856,17 +1275,17 @@ class KeyMapControllerTest { holdDown = true, ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = trigger, actionList = listOf(action)), ) inOrder(performActionsUseCase) { inputMotionEvent(axisHatX = -1.0f) - verify(performActionsUseCase, times(1)).perform(action.data, InputEventType.DOWN) + verify(performActionsUseCase, times(1)).perform(action.data, InputEventAction.DOWN) delay(1000) // Hold down the DPAD button for 1 second. inputMotionEvent(axisHatX = 0.0f) - verify(performActionsUseCase, times(1)).perform(action.data, InputEventType.UP) + verify(performActionsUseCase, times(1)).perform(action.data, InputEventAction.UP) } } @@ -877,12 +1296,12 @@ class KeyMapControllerTest { triggerKey( KeyEvent.KEYCODE_DPAD_LEFT, clickType = ClickType.SHORT_PRESS, - detectionSource = KeyEventDetectionSource.INPUT_METHOD, + requiresIme = true, device = FAKE_CONTROLLER_TRIGGER_KEY_DEVICE, ), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION)), ) @@ -912,12 +1331,12 @@ class KeyMapControllerTest { triggerKey( KeyEvent.KEYCODE_DPAD_LEFT, clickType = ClickType.SHORT_PRESS, - detectionSource = KeyEventDetectionSource.INPUT_METHOD, + requiresIme = true, device = FAKE_CONTROLLER_TRIGGER_KEY_DEVICE, ), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION)), ) @@ -941,12 +1360,12 @@ class KeyMapControllerTest { triggerKey( KeyEvent.KEYCODE_DPAD_LEFT, clickType = ClickType.LONG_PRESS, - detectionSource = KeyEventDetectionSource.INPUT_METHOD, + requiresIme = true, device = FAKE_CONTROLLER_TRIGGER_KEY_DEVICE, ), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION)), ) @@ -968,12 +1387,12 @@ class KeyMapControllerTest { triggerKey( KeyEvent.KEYCODE_DPAD_LEFT, clickType = ClickType.SHORT_PRESS, - detectionSource = KeyEventDetectionSource.INPUT_METHOD, + requiresIme = true, device = FAKE_CONTROLLER_TRIGGER_KEY_DEVICE, ), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION)), ) @@ -998,11 +1417,11 @@ class KeyMapControllerTest { triggerKey( KeyEvent.KEYCODE_DPAD_LEFT, clickType = ClickType.SHORT_PRESS, - detectionSource = KeyEventDetectionSource.INPUT_METHOD, + requiresIme = true, ), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap( 0, trigger = trigger, @@ -1047,11 +1466,11 @@ class KeyMapControllerTest { triggerKey( KeyEvent.KEYCODE_DPAD_LEFT, clickType = ClickType.LONG_PRESS, - detectionSource = KeyEventDetectionSource.INPUT_METHOD, + requiresIme = true, ), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap( 0, trigger = longPressTrigger, @@ -1085,14 +1504,16 @@ class KeyMapControllerTest { val shortPressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), ) - val shortPressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOn())) + val shortPressConstraints = + ConstraintState(constraints = setOf(Constraint(data = ConstraintData.WifiOn))) val longPressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.LONG_PRESS), ) - val doublePressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOff())) + val doublePressConstraints = + ConstraintState(constraints = setOf(Constraint(data = ConstraintData.WifiOff))) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap( 0, trigger = shortPressTrigger, @@ -1126,14 +1547,16 @@ class KeyMapControllerTest { val shortPressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), ) - val shortPressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOn())) + val shortPressConstraints = + ConstraintState(constraints = setOf(Constraint(data = ConstraintData.WifiOn))) val doublePressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.DOUBLE_PRESS), ) - val doublePressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOff())) + val doublePressConstraints = + ConstraintState(constraints = setOf(Constraint(data = ConstraintData.WifiOff))) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap( 0, trigger = shortPressTrigger, @@ -1181,7 +1604,7 @@ class KeyMapControllerTest { ) .copy(longPressDelay = 500) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = longerTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = shorterTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -1193,7 +1616,7 @@ class KeyMapControllerTest { verify(performActionsUseCase, times(1)).perform(TEST_ACTION_2.data) verify(performActionsUseCase, never()).perform(TEST_ACTION_2.data) - verify(detectKeyMapsUseCase, never()).imitateButtonPress( + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( any(), any(), any(), @@ -1208,7 +1631,7 @@ class KeyMapControllerTest { verify(performActionsUseCase, times(1)).perform(TEST_ACTION_2.data) verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) - verify(detectKeyMapsUseCase, never()).imitateButtonPress( + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( any(), any(), any(), @@ -1218,12 +1641,11 @@ class KeyMapControllerTest { ) // If no triggers are detected - mockTriggerKeyInput(shorterTrigger.keys[0], 100L) verify(performActionsUseCase, never()).perform(TEST_ACTION_2.data) verify(performActionsUseCase, never()).perform(TEST_ACTION.data) - verify(detectKeyMapsUseCase, times(1)).imitateButtonPress( + verify(detectKeyMapsUseCase, times(2)).imitateKeyEvent( any(), any(), any(), @@ -1252,11 +1674,13 @@ class KeyMapControllerTest { ), actionList = listOf(Action(data = actionData)), constraintState = ConstraintState( - constraints = setOf(Constraint.FlashlightOn(lens = CameraLens.BACK)), + constraints = setOf( + Constraint(data = ConstraintData.FlashlightOn(lens = CameraLens.BACK)), + ), ), ) - keyMapListFlow.value = listOf(keyMap) + loadKeyMaps(keyMap) var isFlashlightEnabled = false @@ -1310,7 +1734,7 @@ class KeyMapControllerTest { actionList = listOf(TEST_ACTION_2), ) - keyMapListFlow.value = listOf(keyMap1, keyMap2) + loadKeyMaps(keyMap1, keyMap2) // WHEN inOrder(performActionsUseCase) { @@ -1356,7 +1780,7 @@ class KeyMapControllerTest { val trigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) val actionList = listOf(Action(data = ActionData.InputKeyEvent(2))) - keyMapListFlow.value = listOf(KeyMap(trigger = trigger, actionList = actionList)) + loadKeyMaps(KeyMap(trigger = trigger, actionList = actionList)) // WHEN whenever(performActionsUseCase.getErrorSnapshot()).thenReturn(object : @@ -1394,7 +1818,7 @@ class KeyMapControllerTest { Action(data = ActionData.InputKeyEvent(2)), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(trigger = trigger, actionList = actionList), ) @@ -1424,7 +1848,7 @@ class KeyMapControllerTest { repeatLimit = 2, ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(trigger = trigger, actionList = listOf(action)), ) @@ -1461,15 +1885,13 @@ class KeyMapControllerTest { data = ActionData.InputKeyEvent(keyCode = 3), ) - val keyMaps = listOf( + loadKeyMaps( KeyMap( trigger = trigger, actionList = listOf(action1, action2, action3), ), ) - keyMapListFlow.value = keyMaps - // WHEN // ensure consumed @@ -1488,7 +1910,7 @@ class KeyMapControllerTest { // GIVEN val trigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) - val keyMaps = listOf( + loadKeyMaps( KeyMap( trigger = trigger, actionList = listOf(TEST_ACTION), @@ -1499,8 +1921,6 @@ class KeyMapControllerTest { ), ) - keyMapListFlow.value = keyMaps - // WHEN // ensure consumed @@ -1536,7 +1956,7 @@ class KeyMapControllerTest { actionList = listOf(action), ) - keyMapListFlow.value = listOf(keyMap) + loadKeyMaps(keyMap) // WHEN mockTriggerKeyInput(keyMap.trigger.keys[0]) @@ -1567,7 +1987,7 @@ class KeyMapControllerTest { actionList = listOf(action), ) - keyMapListFlow.value = listOf(keyMap) + loadKeyMaps(keyMap) // WHEN mockTriggerKeyInput(keyMap.trigger.keys[0]) @@ -1600,7 +2020,7 @@ class KeyMapControllerTest { actionList = listOf(action), ) - keyMapListFlow.value = listOf(keyMap) + loadKeyMaps(keyMap) // WHEN mockTriggerKeyInput(keyMap.trigger.keys[0]) @@ -1636,7 +2056,7 @@ class KeyMapControllerTest { actionList = listOf(action), ) - keyMapListFlow.value = listOf(keyMap) + loadKeyMaps(keyMap) // WHEN mockTriggerKeyInput(keyMap.trigger.keys[0], delay = 300) @@ -1664,7 +2084,7 @@ class KeyMapControllerTest { actionList = listOf(action), ) - keyMapListFlow.value = listOf(keyMap) + loadKeyMaps(keyMap) // WHEN @@ -1681,7 +2101,7 @@ class KeyMapControllerTest { @Test fun `overlapping triggers 3`() = runTest(testDispatcher) { // GIVEN - val keyMaps = listOf( + val keyMaps = arrayOf( KeyMap( trigger = parallelTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), @@ -1697,7 +2117,7 @@ class KeyMapControllerTest { ), ) - keyMapListFlow.value = keyMaps + loadKeyMaps(*keyMaps) inOrder(performActionsUseCase) { // WHEN @@ -1749,7 +2169,7 @@ class KeyMapControllerTest { @Test fun `overlapping triggers 2`() = runTest(testDispatcher) { // GIVEN - val keyMaps = listOf( + val keyMaps = arrayOf( KeyMap( trigger = parallelTrigger( triggerKey(KeyEvent.KEYCODE_P), @@ -1765,7 +2185,7 @@ class KeyMapControllerTest { ), ) - keyMapListFlow.value = keyMaps + loadKeyMaps(*keyMaps) inOrder(performActionsUseCase) { // WHEN @@ -1810,7 +2230,7 @@ class KeyMapControllerTest { @Test fun `overlapping triggers 1`() = runTest(testDispatcher) { // GIVEN - val keyMaps = listOf( + val keyMaps = arrayOf( KeyMap( trigger = parallelTrigger( triggerKey(KeyEvent.KEYCODE_CTRL_LEFT), @@ -1828,7 +2248,7 @@ class KeyMapControllerTest { ), ) - keyMapListFlow.value = keyMaps + loadKeyMaps(*keyMaps) inOrder(performActionsUseCase) { // WHEN @@ -1841,13 +2261,19 @@ class KeyMapControllerTest { inputKeyEvent( keyCode = KeyEvent.KEYCODE_SHIFT_LEFT, action = KeyEvent.ACTION_DOWN, - metaState = KeyEvent.META_CTRL_LEFT_ON or KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_LEFT_ON or KeyEvent.META_SHIFT_ON, + metaState = + KeyEvent.META_CTRL_LEFT_ON or KeyEvent.META_CTRL_ON or + KeyEvent.META_SHIFT_LEFT_ON or + KeyEvent.META_SHIFT_ON, ) inputKeyEvent( keyCode = KeyEvent.KEYCODE_1, action = KeyEvent.ACTION_DOWN, - metaState = KeyEvent.META_CTRL_LEFT_ON or KeyEvent.META_CTRL_ON or KeyEvent.META_SHIFT_LEFT_ON or KeyEvent.META_SHIFT_ON, + metaState = + KeyEvent.META_CTRL_LEFT_ON or KeyEvent.META_CTRL_ON or + KeyEvent.META_SHIFT_LEFT_ON or + KeyEvent.META_SHIFT_ON, ) inputKeyEvent( @@ -1912,7 +2338,7 @@ class KeyMapControllerTest { triggerKey(keyCode = 2), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap( trigger = trigger, actionList = listOf(TEST_ACTION), @@ -1925,7 +2351,14 @@ class KeyMapControllerTest { inputKeyEvent(keyCode = 1, action = KeyEvent.ACTION_UP) // THEN - verify(detectKeyMapsUseCase, times(1)).imitateButtonPress(keyCode = 1) + verify(detectKeyMapsUseCase, times(1)).imitateKeyEvent( + keyCode = 1, + action = KeyEvent.ACTION_DOWN, + ) + verify(detectKeyMapsUseCase, times(1)).imitateKeyEvent( + keyCode = 1, + action = KeyEvent.ACTION_UP, + ) verifyNoMoreInteractions() // verify nothing happens and no key events are consumed when the 2nd key in the trigger is pressed @@ -1934,8 +2367,22 @@ class KeyMapControllerTest { assertThat(inputKeyEvent(keyCode = 2, action = KeyEvent.ACTION_UP), `is`(false)) // THEN - verify(detectKeyMapsUseCase, never()).imitateButtonPress(keyCode = 1) - verify(detectKeyMapsUseCase, never()).imitateButtonPress(keyCode = 2) + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( + keyCode = 1, + action = KeyEvent.ACTION_DOWN, + ) + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( + keyCode = 1, + action = KeyEvent.ACTION_UP, + ) + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( + keyCode = 2, + action = KeyEvent.ACTION_DOWN, + ) + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( + keyCode = 2, + action = KeyEvent.ACTION_UP, + ) verify(performActionsUseCase, never()).perform(action = TEST_ACTION.data) // verify the action is performed and no keys are imitated when triggering the key map @@ -1972,7 +2419,7 @@ class KeyMapControllerTest { triggerKey(keyCode = 2), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap( trigger = trigger, actionList = listOf(TEST_ACTION), @@ -1986,8 +2433,22 @@ class KeyMapControllerTest { inputKeyEvent(keyCode = 2, action = KeyEvent.ACTION_UP) // THEN - verify(detectKeyMapsUseCase, never()).imitateButtonPress(keyCode = 1) - verify(detectKeyMapsUseCase, never()).imitateButtonPress(keyCode = 2) + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( + keyCode = 1, + action = KeyEvent.ACTION_DOWN, + ) + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( + keyCode = 1, + action = KeyEvent.ACTION_UP, + ) + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( + keyCode = 2, + action = KeyEvent.ACTION_DOWN, + ) + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( + keyCode = 2, + action = KeyEvent.ACTION_UP, + ) } /** @@ -2010,7 +2471,7 @@ class KeyMapControllerTest { actionList = listOf(action), ) - keyMapListFlow.value = listOf(keyMap) + loadKeyMaps(keyMap) // WHEN mockTriggerKeyInput(triggerKey(keyCode = 2), delay = 1) @@ -2054,7 +2515,7 @@ class KeyMapControllerTest { repeat = true, ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = trigger1, actionList = listOf(action1)), KeyMap(1, trigger = trigger2, actionList = listOf(action2)), ) @@ -2101,7 +2562,7 @@ class KeyMapControllerTest { sequenceTrigger(triggerKey(clickType = ClickType.DOUBLE_PRESS, keyCode = 1)) val action2 = Action(data = ActionData.InputKeyEvent(keyCode = 3)) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = trigger1, actionList = listOf(action1)), KeyMap(1, trigger = trigger2, actionList = listOf(action2)), ) @@ -2157,7 +2618,7 @@ class KeyMapControllerTest { sequenceTrigger(triggerKey(clickType = ClickType.DOUBLE_PRESS, keyCode = 1)) val action3 = Action(data = ActionData.InputKeyEvent(keyCode = 4)) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = trigger1, actionList = listOf(action1)), KeyMap(1, trigger = trigger2, actionList = listOf(action2)), KeyMap(2, trigger = trigger3, actionList = listOf(action3)), @@ -2212,7 +2673,7 @@ class KeyMapControllerTest { parallelTrigger(triggerKey(clickType = ClickType.LONG_PRESS, keyCode = 1)) val action2 = Action(data = ActionData.InputKeyEvent(keyCode = 3), repeat = true) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = trigger1, actionList = listOf(action1)), KeyMap(1, trigger = trigger2, actionList = listOf(action2)), ) @@ -2262,7 +2723,7 @@ class KeyMapControllerTest { sequenceTrigger(triggerKey(clickType = ClickType.DOUBLE_PRESS, keyCode = 1)) val action2 = Action(data = ActionData.InputKeyEvent(keyCode = 3)) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = trigger1, actionList = listOf(action1)), KeyMap(1, trigger = trigger2, actionList = listOf(action2)), ) @@ -2324,7 +2785,7 @@ class KeyMapControllerTest { sequenceTrigger(triggerKey(clickType = ClickType.DOUBLE_PRESS, keyCode = 1)) val action3 = Action(data = ActionData.InputKeyEvent(keyCode = 4)) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = trigger1, actionList = listOf(action1)), KeyMap(1, trigger = trigger2, actionList = listOf(action2)), KeyMap(2, trigger = trigger3, actionList = listOf(action3)), @@ -2349,7 +2810,8 @@ class KeyMapControllerTest { // then verify(performActionsUseCase, atLeast(2)).perform(action2.data) - delay(1000) // have a delay after a long press of the key is released so a double press isn't detected + // have a delay after a long press of the key is released so a double press isn't detected + delay(1000) // when double press mockTriggerKeyInput(trigger3.keys[0]) @@ -2371,7 +2833,7 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_A, clickType = ClickType.DOUBLE_PRESS), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION)), ) @@ -2402,7 +2864,7 @@ class KeyMapControllerTest { holdDown = true, ) - keyMapListFlow.value = listOf(KeyMap(trigger = trigger, actionList = listOf(action))) + loadKeyMaps(KeyMap(trigger = trigger, actionList = listOf(action))) val metaState = KeyEvent.META_META_ON.withFlag(KeyEvent.META_META_LEFT_ON) @@ -2449,29 +2911,29 @@ class KeyMapControllerTest { verify(performActionsUseCase, times(1)).perform( action.data, - InputEventType.DOWN, + InputEventAction.DOWN, metaState, ) - verify(detectKeyMapsUseCase, times(1)).imitateButtonPress( + verify(detectKeyMapsUseCase, times(1)).imitateKeyEvent( KeyEvent.KEYCODE_E, metaState, FAKE_KEYBOARD_DEVICE_ID, - InputEventType.DOWN, + KeyEvent.ACTION_DOWN, scanCode = 33, ) verify(performActionsUseCase, times(1)).perform( action.data, - InputEventType.UP, + InputEventAction.UP, 0, ) - verify(detectKeyMapsUseCase, times(1)).imitateButtonPress( + verify(detectKeyMapsUseCase, times(1)).imitateKeyEvent( KeyEvent.KEYCODE_E, 0, FAKE_KEYBOARD_DEVICE_ID, - InputEventType.UP, + KeyEvent.ACTION_UP, scanCode = 33, ) @@ -2518,29 +2980,29 @@ class KeyMapControllerTest { verify(performActionsUseCase, times(1)).perform( action.data, - InputEventType.DOWN, + InputEventAction.DOWN, metaState, ) - verify(detectKeyMapsUseCase, times(1)).imitateButtonPress( + verify(detectKeyMapsUseCase, times(1)).imitateKeyEvent( KeyEvent.KEYCODE_E, metaState, FAKE_KEYBOARD_DEVICE_ID, - InputEventType.DOWN, + KeyEvent.ACTION_DOWN, scanCode = 33, ) - verify(detectKeyMapsUseCase, times(1)).imitateButtonPress( + verify(detectKeyMapsUseCase, times(1)).imitateKeyEvent( KeyEvent.KEYCODE_E, metaState, FAKE_KEYBOARD_DEVICE_ID, - InputEventType.UP, + KeyEvent.ACTION_UP, scanCode = 33, ) verify(performActionsUseCase, times(1)).perform( action.data, - InputEventType.UP, + InputEventAction.UP, 0, ) @@ -2561,7 +3023,7 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_A), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = oneKeyTrigger, actionList = listOf(TEST_ACTION_2)), KeyMap(1, trigger = twoKeyTrigger, actionList = listOf(TEST_ACTION)), ) @@ -2602,11 +3064,11 @@ class KeyMapControllerTest { val triggerAnyDevice = singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_A, - device = TriggerKeyDevice.Any, + device = KeyEventTriggerDevice.Any, ), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = triggerKeyboard, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = triggerAnyDevice, actionList = listOf(TEST_ACTION_2)), ) @@ -2626,7 +3088,7 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_A, FAKE_HEADPHONE_TRIGGER_KEY_DEVICE), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = triggerHeadphone, actionList = listOf(TEST_ACTION)), ) @@ -2656,7 +3118,7 @@ class KeyMapControllerTest { actionList = listOf(action), ) - keyMapListFlow.value = listOf(keymap) + loadKeyMaps(keymap) // WHEN mockTriggerKeyInput(trigger.keys[0]) @@ -2664,7 +3126,7 @@ class KeyMapControllerTest { // THEN verify(performActionsUseCase, times(1)).perform( action.data, - InputEventType.DOWN, + InputEventAction.DOWN, ) // WHEN @@ -2672,7 +3134,7 @@ class KeyMapControllerTest { verify(performActionsUseCase, times(1)).perform( action.data, - InputEventType.UP, + InputEventAction.UP, ) } @@ -2684,11 +3146,13 @@ class KeyMapControllerTest { runTest(testDispatcher) { val trigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_CTRL_LEFT)) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap( 0, trigger = trigger, - actionList = listOf(Action(data = ActionData.InputKeyEvent(KeyEvent.KEYCODE_ALT_LEFT))), + actionList = listOf( + Action(data = ActionData.InputKeyEvent(KeyEvent.KEYCODE_ALT_LEFT)), + ), ), ) @@ -2702,18 +3166,24 @@ class KeyMapControllerTest { inputKeyEvent( KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.ACTION_DOWN, - metaState = KeyEvent.META_CTRL_LEFT_ON + KeyEvent.META_CTRL_ON + KeyEvent.META_SHIFT_LEFT_ON + KeyEvent.META_SHIFT_ON, + metaState = + KeyEvent.META_CTRL_LEFT_ON + KeyEvent.META_CTRL_ON + KeyEvent.META_SHIFT_LEFT_ON + + KeyEvent.META_SHIFT_ON, ) inputKeyEvent( KeyEvent.KEYCODE_C, KeyEvent.ACTION_DOWN, - metaState = KeyEvent.META_CTRL_LEFT_ON + KeyEvent.META_CTRL_ON + KeyEvent.META_SHIFT_LEFT_ON + KeyEvent.META_SHIFT_ON, + metaState = + KeyEvent.META_CTRL_LEFT_ON + KeyEvent.META_CTRL_ON + KeyEvent.META_SHIFT_LEFT_ON + + KeyEvent.META_SHIFT_ON, ) inputKeyEvent( KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.ACTION_UP, - metaState = KeyEvent.META_CTRL_LEFT_ON + KeyEvent.META_CTRL_ON + KeyEvent.META_SHIFT_LEFT_ON + KeyEvent.META_SHIFT_ON, + metaState = + KeyEvent.META_CTRL_LEFT_ON + KeyEvent.META_CTRL_ON + KeyEvent.META_SHIFT_LEFT_ON + + KeyEvent.META_SHIFT_ON, ) inputKeyEvent( KeyEvent.KEYCODE_SHIFT_LEFT, @@ -2724,16 +3194,20 @@ class KeyMapControllerTest { inputKeyEvent(KeyEvent.KEYCODE_C, KeyEvent.ACTION_UP) inOrder(detectKeyMapsUseCase) { - verify(detectKeyMapsUseCase, times(1)).imitateButtonPress( + verify(detectKeyMapsUseCase, times(1)).imitateKeyEvent( any(), - metaState = eq(KeyEvent.META_ALT_LEFT_ON + KeyEvent.META_ALT_ON + KeyEvent.META_SHIFT_LEFT_ON + KeyEvent.META_SHIFT_ON), + metaState = eq( + KeyEvent.META_ALT_LEFT_ON + KeyEvent.META_ALT_ON + + KeyEvent.META_SHIFT_LEFT_ON + + KeyEvent.META_SHIFT_ON, + ), any(), any(), any(), any(), ) - verify(detectKeyMapsUseCase, times(1)).imitateButtonPress( + verify(detectKeyMapsUseCase, times(1)).imitateKeyEvent( any(), metaState = eq(0), any(), @@ -2750,7 +3224,7 @@ class KeyMapControllerTest { val firstTrigger = sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - device = TriggerKeyDevice.Any, + device = KeyEventTriggerDevice.Any, ), triggerKey(KeyEvent.KEYCODE_VOLUME_UP), ) @@ -2759,12 +3233,12 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_HOME), triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - device = TriggerKeyDevice.Any, + device = KeyEventTriggerDevice.Any, ), triggerKey(KeyEvent.KEYCODE_VOLUME_UP), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = firstTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = secondTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -2773,7 +3247,7 @@ class KeyMapControllerTest { mockTriggerKeyInput( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - device = TriggerKeyDevice.Any, + device = KeyEventTriggerDevice.Any, ), ) mockTriggerKeyInput(triggerKey(KeyEvent.KEYCODE_VOLUME_UP)) @@ -2793,17 +3267,18 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.LONG_PRESS), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = homeTrigger, actionList = listOf(TEST_ACTION)), ) - val consumedHomeDown = inputKeyEvent(KeyEvent.KEYCODE_HOME, KeyEvent.ACTION_DOWN, null) - inputKeyEvent(KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.ACTION_DOWN, null) + val consumedHomeDown = + inputKeyEvent(KeyEvent.KEYCODE_HOME, KeyEvent.ACTION_DOWN, FAKE_INTERNAL_DEVICE) + inputKeyEvent(KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.ACTION_DOWN, FAKE_INTERNAL_DEVICE) advanceUntilIdle() - inputKeyEvent(KeyEvent.KEYCODE_HOME, KeyEvent.ACTION_UP, null) - inputKeyEvent(KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.ACTION_UP, null) + inputKeyEvent(KeyEvent.KEYCODE_HOME, KeyEvent.ACTION_UP, FAKE_INTERNAL_DEVICE) + inputKeyEvent(KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.ACTION_UP, FAKE_INTERNAL_DEVICE) assertThat(consumedHomeDown, `is`(true)) @@ -2816,18 +3291,22 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.LONG_PRESS), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = recentsTrigger, actionList = listOf(TEST_ACTION)), ) val consumedRecentsDown = - inputKeyEvent(KeyEvent.KEYCODE_APP_SWITCH, KeyEvent.ACTION_DOWN, null) - inputKeyEvent(KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.ACTION_DOWN, null) + inputKeyEvent( + KeyEvent.KEYCODE_APP_SWITCH, + KeyEvent.ACTION_DOWN, + FAKE_INTERNAL_DEVICE, + ) + inputKeyEvent(KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.ACTION_DOWN, FAKE_INTERNAL_DEVICE) advanceUntilIdle() - inputKeyEvent(KeyEvent.KEYCODE_APP_SWITCH, KeyEvent.ACTION_UP, null) - inputKeyEvent(KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.ACTION_UP, null) + inputKeyEvent(KeyEvent.KEYCODE_APP_SWITCH, KeyEvent.ACTION_UP, FAKE_INTERNAL_DEVICE) + inputKeyEvent(KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.ACTION_UP, FAKE_INTERNAL_DEVICE) assertThat(consumedRecentsDown, `is`(true)) } @@ -2841,7 +3320,7 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.DOUBLE_PRESS), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = shortPressTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = doublePressTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -2882,7 +3361,7 @@ class KeyMapControllerTest { ), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = shortPressTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = longPressTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -2915,7 +3394,7 @@ class KeyMapControllerTest { repeat = true, ) - keyMapListFlow.value = listOf(KeyMap(0, trigger = trigger, actionList = listOf(action))) + loadKeyMaps(KeyMap(0, trigger = trigger, actionList = listOf(action))) when (trigger.mode) { is TriggerMode.Parallel -> mockParallelTrigger(trigger, delay = 2000L) @@ -2957,34 +3436,31 @@ class KeyMapControllerTest { @Test @Parameters(method = "params_dualParallelTrigger_input2ndKey_doNotConsumeUp") - fun dualParallelTrigger_input2ndKey_doNotConsumeUp( - description: String, - trigger: Trigger, - ) = runTest(testDispatcher) { - // given - keyMapListFlow.value = - listOf(KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION))) + fun dualParallelTrigger_input2ndKey_doNotConsumeUp(description: String, trigger: Trigger) = + runTest(testDispatcher) { + // given + loadKeyMaps(KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION))) - // when - (trigger.keys[1] as KeyCodeTriggerKey).let { - inputKeyEvent( - it.keyCode, - KeyEvent.ACTION_DOWN, - triggerKeyDeviceToInputDevice(it.device), - ) - } + // when + (trigger.keys[1] as KeyEventTriggerKey).let { + inputKeyEvent( + it.keyCode, + KeyEvent.ACTION_DOWN, + triggerKeyDeviceToInputDevice(it.device), + ) + } - (trigger.keys[1] as KeyCodeTriggerKey).let { - val consumed = inputKeyEvent( - it.keyCode, - KeyEvent.ACTION_UP, - triggerKeyDeviceToInputDevice(it.device), - ) + (trigger.keys[1] as KeyEventTriggerKey).let { + val consumed = inputKeyEvent( + it.keyCode, + KeyEvent.ACTION_UP, + triggerKeyDeviceToInputDevice(it.device), + ) - // then - assertThat(consumed, `is`(false)) + // then + assertThat(consumed, `is`(false)) + } } - } fun params_dualParallelTrigger_input2ndKey_doNotConsumeUp() = listOf( arrayOf( @@ -3012,11 +3488,10 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_VOLUME_UP), ) - keyMapListFlow.value = - listOf(KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION))) + loadKeyMaps(KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION))) // when - for (key in trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { + for (key in trigger.keys.mapNotNull { it as? KeyEventTriggerKey }) { inputKeyEvent( key.keyCode, KeyEvent.ACTION_DOWN, @@ -3026,7 +3501,7 @@ class KeyMapControllerTest { var consumedUpCount = 0 - for (key in trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { + for (key in trigger.keys.mapNotNull { it as? KeyEventTriggerKey }) { val consumed = inputKeyEvent( key.keyCode, @@ -3051,11 +3526,10 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_VOLUME_UP, clickType = ClickType.LONG_PRESS), ) - keyMapListFlow.value = - listOf(KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION))) + loadKeyMaps(KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION))) // when - for (key in trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { + for (key in trigger.keys.mapNotNull { it as? KeyEventTriggerKey }) { inputKeyEvent( key.keyCode, KeyEvent.ACTION_DOWN, @@ -3067,7 +3541,7 @@ class KeyMapControllerTest { var consumedUpCount = 0 - for (key in trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { + for (key in trigger.keys.mapNotNull { it as? KeyEventTriggerKey }) { val consumed = inputKeyEvent( key.keyCode, @@ -3099,7 +3573,7 @@ class KeyMapControllerTest { ), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = longPressTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = doublePressTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -3109,10 +3583,16 @@ class KeyMapControllerTest { advanceUntilIdle() // then - verify( - detectKeyMapsUseCase, - times(1), - ).imitateButtonPress(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN) + verify(detectKeyMapsUseCase, times(1)) + .imitateKeyEvent( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + action = KeyEvent.ACTION_DOWN, + ) + verify(detectKeyMapsUseCase, times(1)) + .imitateKeyEvent( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + action = KeyEvent.ACTION_UP, + ) } @Test @@ -3125,7 +3605,7 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.LONG_PRESS), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = shortPressTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = longPressTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -3136,7 +3616,7 @@ class KeyMapControllerTest { // then verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) verify(performActionsUseCase, never()).perform(TEST_ACTION_2.data) - verify(detectKeyMapsUseCase, never()).imitateButtonPress( + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( any(), any(), any(), @@ -3156,7 +3636,7 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.DOUBLE_PRESS), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = shortPressTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = doublePressTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -3169,7 +3649,7 @@ class KeyMapControllerTest { verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) // wait for the double press to try and imitate the key. - verify(detectKeyMapsUseCase, never()).imitateButtonPress( + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( any(), any(), any(), @@ -3180,7 +3660,7 @@ class KeyMapControllerTest { } @Test - fun singleKeyTriggerAndShortPressParallelTriggerWithSameInitialKey_validSingleKeyTriggerInput_onlyPerformActiondoNotImitateKey() = + fun `singleKeyTriggerAndShortPressParallelTriggerWithSameInitialKey validSingleKeyTriggerInput onlyPerformActiondoNotImitateKey`() = runTest(testDispatcher) { // given val singleKeyTrigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) @@ -3189,7 +3669,7 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_VOLUME_UP), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(0, trigger = singleKeyTrigger, actionList = listOf(TEST_ACTION)), KeyMap(1, trigger = parallelTrigger, actionList = listOf(TEST_ACTION_2)), ) @@ -3198,7 +3678,7 @@ class KeyMapControllerTest { mockTriggerKeyInput(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) // then - verify(detectKeyMapsUseCase, never()).imitateButtonPress( + verify(detectKeyMapsUseCase, never()).imitateKeyEvent( any(), any(), any(), @@ -3216,7 +3696,7 @@ class KeyMapControllerTest { triggerKey(KeyEvent.KEYCODE_VOLUME_UP), ) - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(trigger = trigger, actionList = listOf(TEST_ACTION)), ) @@ -3225,10 +3705,14 @@ class KeyMapControllerTest { delay = 100L, ) - verify( - detectKeyMapsUseCase, - times(1), - ).imitateButtonPress(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN) + verify(detectKeyMapsUseCase, times(1)) + .imitateKeyEvent( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + action = KeyEvent.ACTION_DOWN, + ) + + verify(detectKeyMapsUseCase, times(1)) + .imitateKeyEvent(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, action = KeyEvent.ACTION_UP) } @Test @@ -3237,7 +3721,7 @@ class KeyMapControllerTest { runTest(testDispatcher) { val actionList = listOf(TEST_ACTION, TEST_ACTION_2) // GIVEN - keyMapListFlow.value = listOf( + loadKeyMaps( KeyMap(trigger = trigger, actionList = actionList), ) @@ -3262,7 +3746,7 @@ class KeyMapControllerTest { singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, ), ), ), @@ -3271,7 +3755,7 @@ class KeyMapControllerTest { sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, ), ), ), @@ -3290,12 +3774,12 @@ class KeyMapControllerTest { fun invalidInput_downNotConsumed(description: String, keyMap: KeyMap) = runTest(testDispatcher) { // GIVEN - keyMapListFlow.value = listOf(keyMap) + loadKeyMaps(keyMap) // WHEN var consumedCount = 0 - for (key in keyMap.trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { + for (key in keyMap.trigger.keys.mapNotNull { it as? KeyEventTriggerKey }) { val consumed = inputKeyEvent( 999, @@ -3317,11 +3801,11 @@ class KeyMapControllerTest { @TestCaseName("{0}") fun validInput_downConsumed(description: String, keyMap: KeyMap) = runTest(testDispatcher) { // GIVEN - keyMapListFlow.value = listOf(keyMap) + loadKeyMaps(keyMap) var consumedCount = 0 - for (key in keyMap.trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { + for (key in keyMap.trigger.keys.mapNotNull { it as? KeyEventTriggerKey }) { val consumed = inputKeyEvent( key.keyCode, @@ -3342,11 +3826,11 @@ class KeyMapControllerTest { @TestCaseName("{0}") fun validInput_doNotConsumeFlag_doNotConsumeDown(description: String, keyMap: KeyMap) = runTest(testDispatcher) { - keyMapListFlow.value = listOf(keyMap) + loadKeyMaps(keyMap) var consumedCount = 0 - for (key in keyMap.trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { + for (key in keyMap.trigger.keys.mapNotNull { it as? KeyEventTriggerKey }) { val consumed = inputKeyEvent( key.keyCode, @@ -3367,14 +3851,14 @@ class KeyMapControllerTest { "undefined single short-press this-device, do not consume" to singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, consume = false, ), ), "undefined single long-press this-device, do not consume" to singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, consume = false, ), @@ -3382,7 +3866,7 @@ class KeyMapControllerTest { "undefined single double-press this-device, do not consume" to singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, consume = false, ), @@ -3391,14 +3875,14 @@ class KeyMapControllerTest { "undefined single short-press any-device, do not consume" to singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, consume = false, ), ), "undefined single long-press any-device, do not consume" to singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.LONG_PRESS, consume = false, ), @@ -3406,7 +3890,7 @@ class KeyMapControllerTest { "undefined single double-press any-device, do not consume" to singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.DOUBLE_PRESS, consume = false, ), @@ -3415,19 +3899,19 @@ class KeyMapControllerTest { "sequence multiple short-press this-device, do not consume" to sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, consume = false, ), @@ -3436,19 +3920,19 @@ class KeyMapControllerTest { "sequence multiple long-press this-device, do not consume" to sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, consume = false, ), @@ -3457,19 +3941,19 @@ class KeyMapControllerTest { "sequence multiple double-press this-device, do not consume" to sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, consume = false, ), @@ -3478,19 +3962,19 @@ class KeyMapControllerTest { "sequence multiple mix this-device, do not consume" to sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, consume = false, ), @@ -3532,13 +4016,13 @@ class KeyMapControllerTest { ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.SHORT_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, consume = false, ), @@ -3553,13 +4037,13 @@ class KeyMapControllerTest { ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.LONG_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, consume = false, ), @@ -3574,13 +4058,13 @@ class KeyMapControllerTest { ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.DOUBLE_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, consume = false, ), @@ -3595,13 +4079,13 @@ class KeyMapControllerTest { ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.SHORT_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, consume = false, ), @@ -3610,7 +4094,7 @@ class KeyMapControllerTest { "sequence multiple mix mixed-device, do not consume" to sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.LONG_PRESS, consume = false, ), @@ -3622,7 +4106,7 @@ class KeyMapControllerTest { ), triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, consume = false, ), @@ -3637,19 +4121,19 @@ class KeyMapControllerTest { "parallel multiple short-press this-device, do not consume" to parallelTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, consume = false, ), @@ -3658,19 +4142,19 @@ class KeyMapControllerTest { "parallel multiple long-press this-device, do not consume" to parallelTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, consume = false, ), @@ -3721,13 +4205,13 @@ class KeyMapControllerTest { "parallel multiple short-press mix-device, do not consume" to parallelTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.SHORT_PRESS, consume = false, ), @@ -3742,13 +4226,13 @@ class KeyMapControllerTest { "parallel multiple long-press mix-device, do not consume" to parallelTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, consume = false, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.LONG_PRESS, consume = false, ), @@ -3778,20 +4262,20 @@ class KeyMapControllerTest { "undefined single short-press this-device" to singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, ), ), "undefined single long-press this-device" to singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, ), ), "undefined single double-press this-device" to singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, ), ), @@ -3799,20 +4283,20 @@ class KeyMapControllerTest { "undefined single short-press any-device" to singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, ), ), "undefined single long-press any-device" to singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.LONG_PRESS, ), ), "undefined single double-press any-device" to singleKeyTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.DOUBLE_PRESS, ), ), @@ -3820,68 +4304,68 @@ class KeyMapControllerTest { "sequence multiple short-press this-device" to sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, ), ), "sequence multiple long-press this-device" to sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, ), ), "sequence multiple double-press this-device" to sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, ), ), "sequence multiple mix this-device" to sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, ), ), @@ -3916,12 +4400,12 @@ class KeyMapControllerTest { ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.SHORT_PRESS, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, ), ), @@ -3933,12 +4417,12 @@ class KeyMapControllerTest { ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.LONG_PRESS, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, ), ), @@ -3950,12 +4434,12 @@ class KeyMapControllerTest { ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.DOUBLE_PRESS, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, ), ), @@ -3967,19 +4451,19 @@ class KeyMapControllerTest { ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.SHORT_PRESS, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.DOUBLE_PRESS, ), ), "sequence multiple mix mixed-device" to sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.LONG_PRESS, ), triggerKey( @@ -3989,7 +4473,7 @@ class KeyMapControllerTest { ), triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, ), triggerKey( @@ -4002,34 +4486,34 @@ class KeyMapControllerTest { "parallel multiple short-press this-device" to parallelTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, ), ), "parallel multiple long-press this-device" to parallelTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, ), triggerKey( KeyEvent.KEYCODE_A, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, ), ), @@ -4070,12 +4554,12 @@ class KeyMapControllerTest { "parallel multiple short-press mix-device" to parallelTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.SHORT_PRESS, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.SHORT_PRESS, ), triggerKey( @@ -4087,12 +4571,12 @@ class KeyMapControllerTest { "parallel multiple long-press mix-device" to parallelTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, - TriggerKeyDevice.Internal, + KeyEventTriggerDevice.Internal, clickType = ClickType.LONG_PRESS, ), triggerKey( KeyEvent.KEYCODE_VOLUME_UP, - TriggerKeyDevice.Any, + KeyEventTriggerDevice.Any, clickType = ClickType.LONG_PRESS, ), triggerKey( @@ -4120,7 +4604,7 @@ class KeyMapControllerTest { @TestCaseName("{0}") fun validInput_actionPerformed(description: String, keyMap: KeyMap) = runTest(testDispatcher) { // GIVEN - keyMapListFlow.value = listOf(keyMap) + loadKeyMaps(keyMap) if (keyMap.trigger.mode is TriggerMode.Parallel) { // WHEN @@ -4140,37 +4624,76 @@ class KeyMapControllerTest { } private suspend fun mockTriggerKeyInput(key: TriggerKey, delay: Long? = null) { - if (key !is KeyCodeTriggerKey) { - return + if (key !is KeyEventTriggerKey) { + throw IllegalArgumentException("Key must be a KeyEventTriggerKey") + } + + val inputDevice = triggerKeyDeviceToInputDevice(key.device) + val pressDuration: Long = delay ?: when (key.clickType) { + ClickType.LONG_PRESS -> LONG_PRESS_DELAY + 100L + else -> 50L + } + + inputKeyEvent(key.keyCode, KeyEvent.ACTION_DOWN, inputDevice) + + when (key.clickType) { + ClickType.SHORT_PRESS -> { + delay(pressDuration) + inputKeyEvent(key.keyCode, KeyEvent.ACTION_UP, inputDevice) + } + + ClickType.LONG_PRESS -> { + delay(pressDuration) + inputKeyEvent(key.keyCode, KeyEvent.ACTION_UP, inputDevice) + } + + ClickType.DOUBLE_PRESS -> { + delay(pressDuration) + inputKeyEvent(key.keyCode, KeyEvent.ACTION_UP, inputDevice) + delay(pressDuration) + + inputKeyEvent(key.keyCode, KeyEvent.ACTION_DOWN, inputDevice) + delay(pressDuration) + inputKeyEvent(key.keyCode, KeyEvent.ACTION_UP, inputDevice) + } + } + } + + private suspend fun mockEvdevKeyInput( + key: TriggerKey, + evdevDevice: EvdevDeviceInfo, + delay: Long? = null, + ) { + if (key !is EvdevTriggerKey) { + throw IllegalArgumentException("Key must be an EvdevTriggerKey") } - val deviceDescriptor = triggerKeyDeviceToInputDevice(key.device) val pressDuration: Long = delay ?: when (key.clickType) { ClickType.LONG_PRESS -> LONG_PRESS_DELAY + 100L else -> 50L } - inputKeyEvent(key.keyCode, KeyEvent.ACTION_DOWN, deviceDescriptor) + inputDownEvdevEvent(key.keyCode, key.scanCode, evdevDevice) when (key.clickType) { ClickType.SHORT_PRESS -> { delay(pressDuration) - inputKeyEvent(key.keyCode, KeyEvent.ACTION_UP, deviceDescriptor) + inputUpEvdevEvent(key.keyCode, key.scanCode, evdevDevice) } ClickType.LONG_PRESS -> { delay(pressDuration) - inputKeyEvent(key.keyCode, KeyEvent.ACTION_UP, deviceDescriptor) + inputUpEvdevEvent(key.keyCode, key.scanCode, evdevDevice) } ClickType.DOUBLE_PRESS -> { delay(pressDuration) - inputKeyEvent(key.keyCode, KeyEvent.ACTION_UP, deviceDescriptor) + inputUpEvdevEvent(key.keyCode, key.scanCode, evdevDevice) delay(pressDuration) - inputKeyEvent(key.keyCode, KeyEvent.ACTION_DOWN, deviceDescriptor) + inputDownEvdevEvent(key.keyCode, key.scanCode, evdevDevice) delay(pressDuration) - inputKeyEvent(key.keyCode, KeyEvent.ACTION_UP, deviceDescriptor) + inputUpEvdevEvent(key.keyCode, key.scanCode, evdevDevice) } } } @@ -4179,14 +4702,13 @@ class KeyMapControllerTest { axisHatX: Float = 0.0f, axisHatY: Float = 0.0f, device: InputDeviceInfo = FAKE_CONTROLLER_INPUT_DEVICE, - isDpad: Boolean = true, - ): MyMotionEvent { - return MyMotionEvent( + ): KMGamePadEvent { + return KMGamePadEvent( metaState = 0, device = device, axisHatX = axisHatX, axisHatY = axisHatY, - isDpad = isDpad, + eventTime = System.currentTimeMillis(), ) } @@ -4195,24 +4717,24 @@ class KeyMapControllerTest { axisHatY: Float = 0.0f, device: InputDeviceInfo = FAKE_CONTROLLER_INPUT_DEVICE, ): Boolean = controller.onMotionEvent( - MyMotionEvent( + KMGamePadEvent( metaState = 0, device = device, axisHatX = axisHatX, axisHatY = axisHatY, - isDpad = true, + eventTime = System.currentTimeMillis(), ), ) private fun inputKeyEvent( keyCode: Int, action: Int, - device: InputDeviceInfo? = null, + device: InputDeviceInfo = FAKE_INTERNAL_DEVICE, metaState: Int? = null, scanCode: Int = 0, repeatCount: Int = 0, - ): Boolean = controller.onKeyEvent( - MyKeyEvent( + ): Boolean = controller.onInputEvent( + KMKeyEvent( keyCode = keyCode, action = action, metaState = metaState ?: 0, @@ -4220,30 +4742,66 @@ class KeyMapControllerTest { device = device, repeatCount = repeatCount, source = 0, + eventTime = System.currentTimeMillis(), ), ) - private suspend fun mockParallelTrigger( - trigger: Trigger, - delay: Long? = null, - ) { + private fun inputDownEvdevEvent(keyCode: Int, scanCode: Int, device: EvdevDeviceInfo): Boolean = + controller.onInputEvent( + KMEvdevEvent( + type = KMEvdevEvent.TYPE_KEY_EVENT, + device = EvdevDeviceHandle( + path = "/dev/input${device.name}", + name = device.name, + bus = device.bus, + vendor = device.vendor, + product = device.product, + ), + code = scanCode, + androidCode = keyCode, + value = KMEvdevEvent.VALUE_DOWN, + timeSec = testScope.currentTime, + timeUsec = 0, + ), + ) + + private fun inputUpEvdevEvent(keyCode: Int, scanCode: Int, device: EvdevDeviceInfo): Boolean = + controller.onInputEvent( + KMEvdevEvent( + type = KMEvdevEvent.TYPE_KEY_EVENT, + device = EvdevDeviceHandle( + path = "/dev/input${device.name}", + name = device.name, + bus = device.bus, + vendor = device.vendor, + product = device.product, + ), + code = scanCode, + androidCode = keyCode, + value = KMEvdevEvent.VALUE_UP, + timeSec = testScope.currentTime, + timeUsec = 0, + ), + ) + + private suspend fun mockParallelTrigger(trigger: Trigger, delay: Long? = null) { require(trigger.mode is TriggerMode.Parallel) - require(trigger.keys.all { it is KeyCodeTriggerKey }) + require(trigger.keys.all { it is KeyEventTriggerKey }) for (key in trigger.keys) { - if (key !is KeyCodeTriggerKey) { + if (key !is KeyEventTriggerKey) { continue } - val deviceDescriptor = triggerKeyDeviceToInputDevice(key.device) + val inputDevice = triggerKeyDeviceToInputDevice(key.device) - inputKeyEvent(key.keyCode, KeyEvent.ACTION_DOWN, deviceDescriptor) + inputKeyEvent(key.keyCode, KeyEvent.ACTION_DOWN, inputDevice) } if (delay != null) { delay(delay) } else { - when ((trigger.mode as TriggerMode.Parallel).clickType) { + when (trigger.mode.clickType) { ClickType.SHORT_PRESS -> delay(50) ClickType.LONG_PRESS -> delay(LONG_PRESS_DELAY + 100L) ClickType.DOUBLE_PRESS -> {} @@ -4251,7 +4809,7 @@ class KeyMapControllerTest { } for (key in trigger.keys) { - if (key !is KeyCodeTriggerKey) { + if (key !is KeyEventTriggerKey) { continue } @@ -4262,32 +4820,47 @@ class KeyMapControllerTest { } private fun triggerKeyDeviceToInputDevice( - device: TriggerKeyDevice, + device: KeyEventTriggerDevice, deviceId: Int = 0, isGameController: Boolean = false, ): InputDeviceInfo = when (device) { - TriggerKeyDevice.Any -> InputDeviceInfo( + KeyEventTriggerDevice.Any -> InputDeviceInfo( descriptor = "any_device", name = "any_device_name", isExternal = false, id = deviceId, isGameController = isGameController, + sources = if (isGameController) { + InputDevice.SOURCE_GAMEPAD + } else { + InputDevice.SOURCE_KEYBOARD + }, ) - is TriggerKeyDevice.External -> InputDeviceInfo( + is KeyEventTriggerDevice.External -> InputDeviceInfo( descriptor = device.descriptor, name = "device_name", isExternal = true, id = deviceId, isGameController = isGameController, + sources = if (isGameController) { + InputDevice.SOURCE_GAMEPAD + } else { + InputDevice.SOURCE_KEYBOARD + }, ) - TriggerKeyDevice.Internal -> InputDeviceInfo( + KeyEventTriggerDevice.Internal -> InputDeviceInfo( descriptor = "internal_device", name = "internal_device_name", isExternal = false, id = deviceId, isGameController = isGameController, + sources = if (isGameController) { + InputDevice.SOURCE_GAMEPAD + } else { + InputDevice.SOURCE_KEYBOARD + }, ) } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/ProcessKeyMapGroupsForDetectionTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/ProcessKeyMapGroupsForDetectionTest.kt index abcd815259..c20fc2682c 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/ProcessKeyMapGroupsForDetectionTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/ProcessKeyMapGroupsForDetectionTest.kt @@ -1,11 +1,12 @@ package io.github.sds100.keymapper.base.keymaps import io.github.sds100.keymapper.base.constraints.Constraint +import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.constraints.ConstraintState +import io.github.sds100.keymapper.base.detection.DetectKeyMapModel +import io.github.sds100.keymapper.base.detection.DetectKeyMapsUseCaseImpl import io.github.sds100.keymapper.base.groups.Group -import io.github.sds100.keymapper.base.keymaps.detection.DetectKeyMapModel -import io.github.sds100.keymapper.base.keymaps.detection.DetectKeyMapsUseCaseImpl import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers import org.junit.Test @@ -22,15 +23,15 @@ class ProcessKeyMapGroupsForDetectionTest { "child", parentUid = "parent", mode = ConstraintMode.OR, - Constraint.LockScreenNotShowing(), - Constraint.Discharging(), + Constraint(data = ConstraintData.LockScreenNotShowing), + Constraint(data = ConstraintData.Discharging), ), group( "parent", parentUid = "bad_parent", mode = ConstraintMode.AND, - Constraint.DeviceIsLocked(), - Constraint.NotInPhoneCall(), + Constraint(data = ConstraintData.DeviceIsLocked), + Constraint(data = ConstraintData.NotInPhoneCall), ), ), ) @@ -43,13 +44,13 @@ class ProcessKeyMapGroupsForDetectionTest { val keyMap = KeyMap(groupUid = "child") val constraints1 = arrayOf( - Constraint.LockScreenNotShowing(), - Constraint.Discharging(), + Constraint(data = ConstraintData.LockScreenNotShowing), + Constraint(data = ConstraintData.Discharging), ) val constraints2 = arrayOf( - Constraint.DeviceIsLocked(), - Constraint.NotInPhoneCall(), + Constraint(data = ConstraintData.DeviceIsLocked), + Constraint(data = ConstraintData.NotInPhoneCall), ) val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( @@ -90,8 +91,8 @@ class ProcessKeyMapGroupsForDetectionTest { fun `Key map in grandchild group and child only has constraints`() { val keyMap = KeyMap(groupUid = "child") val constraints1 = arrayOf( - Constraint.LockScreenNotShowing(), - Constraint.Discharging(), + Constraint(data = ConstraintData.LockScreenNotShowing), + Constraint(data = ConstraintData.Discharging), ) val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( keyMaps = listOf(keyMap), @@ -125,8 +126,8 @@ class ProcessKeyMapGroupsForDetectionTest { fun `Key map in grandchild group and parent only has constraints`() { val keyMap = KeyMap(groupUid = "child") val constraints1 = arrayOf( - Constraint.LockScreenNotShowing(), - Constraint.Discharging(), + Constraint(data = ConstraintData.LockScreenNotShowing), + Constraint(data = ConstraintData.Discharging), ) val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( diff --git a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt index 4b95335488..8383bbc63f 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt @@ -6,8 +6,8 @@ import io.github.sds100.keymapper.base.actions.ActionErrorSnapshot import io.github.sds100.keymapper.base.actions.PerformActionsUseCase import io.github.sds100.keymapper.base.actions.RepeatMode import io.github.sds100.keymapper.base.constraints.DetectConstraintsUseCase -import io.github.sds100.keymapper.base.keymaps.detection.DetectKeyMapsUseCase -import io.github.sds100.keymapper.base.keymaps.detection.TriggerKeyMapFromOtherAppsController +import io.github.sds100.keymapper.base.detection.DetectKeyMapsUseCase +import io.github.sds100.keymapper.base.detection.TriggerKeyMapFromOtherAppsController import io.github.sds100.keymapper.base.trigger.Trigger import io.github.sds100.keymapper.base.utils.TestConstraintSnapshot import io.github.sds100.keymapper.common.utils.KMError @@ -100,29 +100,30 @@ class TriggerKeyMapFromOtherAppsControllerTest { * #707 */ @Test - fun `Key map with repeat option, don't repeat when triggered if repeat until released`() = runTest(testDispatcher) { - // GIVEN - val action = - Action( - data = ActionData.InputKeyEvent(keyCode = 1), - repeat = true, - repeatMode = RepeatMode.TRIGGER_RELEASED, + fun `Key map with repeat option, don't repeat when triggered if repeat until released`() = + runTest(testDispatcher) { + // GIVEN + val action = + Action( + data = ActionData.InputKeyEvent(keyCode = 1), + repeat = true, + repeatMode = RepeatMode.TRIGGER_RELEASED, + ) + val keyMap = KeyMap( + actionList = listOf(action), + trigger = Trigger(triggerFromOtherApps = true), ) - val keyMap = KeyMap( - actionList = listOf(action), - trigger = Trigger(triggerFromOtherApps = true), - ) - keyMapListFlow.value = listOf(keyMap) + keyMapListFlow.value = listOf(keyMap) - advanceUntilIdle() + advanceUntilIdle() - // WHEN - controller.onDetected(keyMap.uid) - delay(500) - controller.reset() // stop any repeating that might be happening - advanceUntilIdle() + // WHEN + controller.onDetected(keyMap.uid) + delay(500) + controller.reset() // stop any repeating that might be happening + advanceUntilIdle() - // THEN - verify(performActionsUseCase, times(1)).perform(action.data) - } + // THEN + verify(performActionsUseCase, times(1)).perform(action.data) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/repositories/KeyMapRepositoryTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/repositories/KeyMapRepositoryTest.kt index fb53bae60e..0a51cf967c 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/repositories/KeyMapRepositoryTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/repositories/KeyMapRepositoryTest.kt @@ -6,7 +6,6 @@ import io.github.sds100.keymapper.data.db.dao.KeyMapDao import io.github.sds100.keymapper.data.entities.FingerprintMapEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.repositories.RoomKeyMapRepository -import io.github.sds100.keymapper.system.devices.InputDeviceInfo import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -26,16 +25,6 @@ import org.mockito.kotlin.times @RunWith(MockitoJUnitRunner::class) class KeyMapRepositoryTest { - companion object { - private val FAKE_KEYBOARD = InputDeviceInfo( - descriptor = "fake_keyboard_descriptor", - name = "fake keyboard", - id = 1, - isExternal = true, - isGameController = false, - ) - } - private val testDispatcher = UnconfinedTestDispatcher() private val testScope = TestScope(testDispatcher) @@ -68,36 +57,37 @@ class KeyMapRepositoryTest { * issue #641 */ @Test - fun `if modifying a huge number of key maps then split job into batches`() = runTest(testDispatcher) { - // GIVEN - val keyMapList = sequence { - repeat(991) { - yield(KeyMapEntity(id = it.toLong())) - } - }.toList() + fun `if modifying a huge number of key maps then split job into batches`() = + runTest(testDispatcher) { + // GIVEN + val keyMapList = sequence { + repeat(991) { + yield(KeyMapEntity(id = it.toLong())) + } + }.toList() - keyMaps.emit(keyMapList) + keyMaps.emit(keyMapList) - inOrder(mockDao) { - // WHEN, THEN - // split job up into batches of 200 key maps - repository.enableById(*keyMapList.map { it.uid }.toTypedArray()) - verify(mockDao, times(5)).enableKeyMapByUid(anyVararg()) + inOrder(mockDao) { + // WHEN, THEN + // split job up into batches of 200 key maps + repository.enableById(*keyMapList.map { it.uid }.toTypedArray()) + verify(mockDao, times(5)).enableKeyMapByUid(anyVararg()) - repository.disableById(*keyMapList.map { it.uid }.toTypedArray()) - verify(mockDao, times(5)).disableKeyMapByUid(anyVararg()) + repository.disableById(*keyMapList.map { it.uid }.toTypedArray()) + verify(mockDao, times(5)).disableKeyMapByUid(anyVararg()) - repository.delete(*keyMapList.map { it.uid }.toTypedArray()) - verify(mockDao, times(5)).deleteById(anyVararg()) + repository.delete(*keyMapList.map { it.uid }.toTypedArray()) + verify(mockDao, times(5)).deleteById(anyVararg()) - repository.duplicate(*keyMapList.map { it.uid }.toTypedArray()) - verify(mockDao, times(5)).insert(anyVararg()) + repository.duplicate(*keyMapList.map { it.uid }.toTypedArray()) + verify(mockDao, times(5)).insert(anyVararg()) - repository.insert(*keyMapList.toTypedArray()) - verify(mockDao, times(5)).insert(anyVararg()) + repository.insert(*keyMapList.toTypedArray()) + verify(mockDao, times(5)).insert(anyVararg()) - repository.update(*keyMapList.toTypedArray()) - verify(mockDao, times(5)).update(anyVararg()) + repository.update(*keyMapList.toTypedArray()) + verify(mockDao, times(5)).update(anyVararg()) + } } - } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/system/devices/FakeDevicesAdapter.kt b/base/src/test/java/io/github/sds100/keymapper/base/system/devices/FakeDevicesAdapter.kt index ba2e23d056..298a040d79 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/system/devices/FakeDevicesAdapter.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/system/devices/FakeDevicesAdapter.kt @@ -1,10 +1,10 @@ package io.github.sds100.keymapper.base.system.devices +import io.github.sds100.keymapper.common.utils.InputDeviceInfo import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.bluetooth.BluetoothDeviceInfo import io.github.sds100.keymapper.system.devices.DevicesAdapter -import io.github.sds100.keymapper.system.devices.InputDeviceInfo import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -31,4 +31,8 @@ class FakeDevicesAdapter : DevicesAdapter { override fun getInputDeviceName(descriptor: String): KMResult { throw Exception() } + + override fun getInputDevice(deviceId: Int): InputDeviceInfo? { + throw NotImplementedError() + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/system/files/FakeFileAdapter.kt b/base/src/test/java/io/github/sds100/keymapper/base/system/files/FakeFileAdapter.kt index 6321c20df8..c61444a990 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/system/files/FakeFileAdapter.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/system/files/FakeFileAdapter.kt @@ -4,14 +4,12 @@ import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.system.files.FileAdapter import io.github.sds100.keymapper.system.files.IFile -import kotlinx.coroutines.runBlocking -import org.junit.rules.TemporaryFolder import java.io.File import java.io.InputStream +import kotlinx.coroutines.runBlocking +import org.junit.rules.TemporaryFolder -class FakeFileAdapter( - private val tempFolder: TemporaryFolder, -) : FileAdapter { +class FakeFileAdapter(private val tempFolder: TemporaryFolder) : FileAdapter { val privateFolder = tempFolder.newFolder("private") diff --git a/base/src/test/java/io/github/sds100/keymapper/base/system/files/JavaFile.kt b/base/src/test/java/io/github/sds100/keymapper/base/system/files/JavaFile.kt index 4e86d7b9ee..cdd4b42628 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/system/files/JavaFile.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/system/files/JavaFile.kt @@ -4,10 +4,10 @@ import com.anggrayudi.storage.file.recreateFile import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.system.files.IFile -import timber.log.Timber import java.io.File import java.io.InputStream import java.io.OutputStream +import timber.log.Timber class JavaFile(val file: File) : IFile { diff --git a/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt b/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt index 0b62e219ab..8410ff4c26 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt @@ -15,23 +15,14 @@ class FakeInputMethodAdapter : InputMethodAdapter { override val chosenIme = MutableStateFlow(null) - override val isUserInputRequiredToChangeIme = MutableStateFlow(false) - - override fun showImePicker(fromForeground: Boolean): KMResult<*> { - return Success(Unit) + override fun getChosenIme(): ImeInfo? { + return chosenIme.value } - override suspend fun enableIme(imeId: String): KMResult<*> { + override fun showImePicker(fromForeground: Boolean): KMResult<*> { return Success(Unit) } - override suspend fun chooseImeWithoutUserInput(imeId: String): KMResult { - return inputMethods.value - .firstOrNull { it.id == imeId } - ?.let { Success(it) } - ?: KMError.InputMethodNotFound(imeId) - } - override fun getInfoById(imeId: String): KMResult { return inputMethods.value .firstOrNull { it.id == imeId } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegateTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegateTest.kt new file mode 100644 index 0000000000..312edb60aa --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegateTest.kt @@ -0,0 +1,1390 @@ +package io.github.sds100.keymapper.base.trigger + +import android.view.KeyEvent +import io.github.sds100.keymapper.base.keymaps.ClickType +import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.base.utils.parallelTrigger +import io.github.sds100.keymapper.base.utils.sequenceTrigger +import io.github.sds100.keymapper.base.utils.singleKeyTrigger +import io.github.sds100.keymapper.base.utils.triggerKey +import io.github.sds100.keymapper.common.models.EvdevDeviceInfo +import io.github.sds100.keymapper.system.inputevents.Scancode +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.instanceOf +import org.hamcrest.Matchers.`is` +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic + +class ConfigTriggerDelegateTest { + + private lateinit var mockedKeyEvent: MockedStatic + private lateinit var delegate: ConfigTriggerDelegate + + @Before + fun before() { + mockedKeyEvent = mockStatic(KeyEvent::class.java) + mockedKeyEvent.`when` { KeyEvent.getMaxKeyCode() }.thenReturn(1000) + + delegate = ConfigTriggerDelegate() + } + + @After + fun tearDown() { + mockedKeyEvent.close() + } + + @Test + fun `set click type to long press when adding power button by key event to empty trigger`() { + val trigger = Trigger() + + val newTrigger = delegate.addKeyEventTriggerKey( + trigger, + keyCode = KeyEvent.KEYCODE_POWER, + scanCode = Scancode.KEY_POWER, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + assertThat(newTrigger.keys, hasSize(1)) + assertThat(newTrigger.keys[0].clickType, `is`(ClickType.LONG_PRESS)) + } + + @Test + fun `set click type to long press when adding KEY_POWER by evdev event to empty trigger`() { + val trigger = Trigger() + val device = EvdevDeviceInfo( + name = "Power Button", + bus = 0, + vendor = 1, + product = 2, + ) + + val newTrigger = delegate.addEvdevTriggerKey( + trigger, + keyCode = KeyEvent.KEYCODE_POWER, + scanCode = Scancode.KEY_POWER, + device = device, + ) + + assertThat(newTrigger.keys, hasSize(1)) + assertThat(newTrigger.keys[0].clickType, `is`(ClickType.LONG_PRESS)) + } + + @Test + fun `set click type to long press when adding KEY_POWER2 by evdev event to empty trigger`() { + val trigger = Trigger() + val device = EvdevDeviceInfo( + name = "Power Button", + bus = 0, + vendor = 1, + product = 2, + ) + + val newTrigger = delegate.addEvdevTriggerKey( + trigger, + keyCode = KeyEvent.KEYCODE_POWER, + scanCode = Scancode.KEY_POWER2, + device = device, + ) + + assertThat(newTrigger.keys, hasSize(1)) + assertThat(newTrigger.keys[0].clickType, `is`(ClickType.LONG_PRESS)) + } + + @Test + fun `set click type to long press when adding TV power button by evdev event to empty trigger`() { + val trigger = Trigger() + val device = EvdevDeviceInfo( + name = "TV Remote", + bus = 0, + vendor = 1, + product = 2, + ) + + val newTrigger = delegate.addEvdevTriggerKey( + trigger, + keyCode = KeyEvent.KEYCODE_TV_POWER, + scanCode = Scancode.KEY_POWER, + device = device, + ) + + assertThat(newTrigger.keys, hasSize(1)) + assertThat(newTrigger.keys[0].clickType, `is`(ClickType.LONG_PRESS)) + } + + @Test + fun `set click type to long press when adding power button to parallel trigger`() { + val trigger = parallelTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ), + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = Scancode.KEY_VOLUMEUP, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ), + ) + + val newTrigger = delegate.addKeyEventTriggerKey( + trigger, + keyCode = KeyEvent.KEYCODE_POWER, + scanCode = Scancode.KEY_POWER, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + assertThat(newTrigger.keys, hasSize(3)) + assertThat((newTrigger.mode as TriggerMode.Parallel).clickType, `is`(ClickType.LONG_PRESS)) + assertThat(newTrigger.keys[2].clickType, `is`(ClickType.LONG_PRESS)) + } + + @Test + fun `set click type to long press when adding power button to sequence trigger`() { + val trigger = sequenceTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ), + AssistantTriggerKey( + type = AssistantTriggerType.ANY, + clickType = ClickType.SHORT_PRESS, + ), + ) + + val newTrigger = delegate.addKeyEventTriggerKey( + trigger, + keyCode = KeyEvent.KEYCODE_POWER, + scanCode = Scancode.KEY_POWER, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + assertThat(newTrigger.keys, hasSize(3)) + assertThat(newTrigger.mode, `is`(TriggerMode.Sequence)) + assertThat(newTrigger.keys[2].clickType, `is`(ClickType.LONG_PRESS)) + } + + @Test + fun `Remove keys with the same scan code if scan code detection is enabled when switching to a parallel trigger`() { + val key = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ) + + val trigger = sequenceTrigger( + key, + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ), + ) + + val newTrigger = delegate.setParallelTriggerMode(trigger) + + assertThat(newTrigger.mode, `is`(TriggerMode.Undefined)) + assertThat(newTrigger.keys, hasSize(1)) + assertThat(newTrigger.keys, contains(key)) + } + + @Test + fun `Convert to sequence trigger when enabling scan code detection when scan codes are the same`() { + val key = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ) + val trigger = parallelTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ), + key, + ) + + val newTrigger = delegate.setScanCodeDetectionEnabled(trigger, key.uid, true) + assertThat(newTrigger.mode, `is`(TriggerMode.Sequence)) + assertThat(newTrigger.keys, hasSize(2)) + assertThat(newTrigger.keys[1], `is`(key.copy(detectWithScanCodeUserSetting = true))) + } + + @Test + fun `Do not remove other keys with the same scan code when enabling scan code detection for sequence triggers`() { + val key = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ) + val trigger = sequenceTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + key, + ) + + val newTrigger = delegate.setScanCodeDetectionEnabled(trigger, key.uid, true) + assertThat(newTrigger.keys, hasSize(2)) + assertThat( + newTrigger.keys, + contains(trigger.keys[0], key.copy(detectWithScanCodeUserSetting = true)), + ) + } + + @Test + fun `Convert to sequence trigger when disabling scan code detection and other keys with same key code`() { + val key = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEUP, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ) + + val trigger = parallelTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + key, + ) + + val newTrigger = delegate.setScanCodeDetectionEnabled(trigger, key.uid, false) + assertThat(newTrigger.mode, `is`(TriggerMode.Sequence)) + assertThat(newTrigger.keys, hasSize(2)) + assertThat(newTrigger.keys[1], `is`(key.copy(detectWithScanCodeUserSetting = false))) + } + + @Test + fun `Do not remove other keys from different devices when enabling scan code detection`() { + val key = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.External(descriptor = "keyboard0", name = "Keyboard"), + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ) + val trigger = parallelTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + key, + ) + + val newTrigger = delegate.setScanCodeDetectionEnabled(trigger, key.uid, true) + assertThat(newTrigger.keys, hasSize(2)) + assertThat( + newTrigger.keys, + contains(trigger.keys[0], key.copy(detectWithScanCodeUserSetting = true)), + ) + } + + /** + * Issue #761 + */ + @Test + fun `Do not enable scan code detection if a key in another key map has the same key code, different scan code and is from a different device`() { + val device1 = KeyEventTriggerDevice.External( + descriptor = "keyboard0", + name = "Keyboard", + ) + + val device2 = KeyEventTriggerDevice.External( + descriptor = "keyboard1", + name = "Other Keyboard", + ) + + val otherTriggers = listOf( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 123, + device = device1, + clickType = ClickType.SHORT_PRESS, + ), + ) + + val newTrigger = delegate.addKeyEventTriggerKey( + trigger = Trigger(), + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 124, + // Different device + device = device2, + requiresIme = false, + otherTriggers, + ) + + assertThat( + (newTrigger.keys[0] as KeyEventTriggerKey).detectWithScanCodeUserSetting, + `is`(false), + ) + } + + /** + * Issue #761 + */ + @Test + fun `Do not enable scan code detection if a key in the trigger has the same key code, different scan code and is from a different device`() { + val device1 = KeyEventTriggerDevice.External( + descriptor = "keyboard0", + name = "Keyboard", + ) + + val device2 = KeyEventTriggerDevice.External( + descriptor = "keyboard1", + name = "Other Keyboard", + ) + + val trigger = singleKeyTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 123, + device = device1, + clickType = ClickType.SHORT_PRESS, + ), + ) + + val newTrigger = delegate.addKeyEventTriggerKey( + trigger = trigger, + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 124, + // Different device + device = device2, + requiresIme = false, + ) + + assertThat( + (newTrigger.keys[1] as KeyEventTriggerKey).detectWithScanCodeUserSetting, + `is`(false), + ) + } + + /** + * Issue #761 + */ + @Test + fun `Enable scan code detection for an evdev trigger if a key in another key map has the same key code but different scan code`() { + val device = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, + ) + + val otherTriggers = listOf( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 123, + device = device, + ), + ) + + val newTrigger = delegate.addEvdevTriggerKey( + trigger = Trigger(), + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 124, + device = device, + otherTriggers, + ) + + assertThat( + (newTrigger.keys[0] as EvdevTriggerKey).detectWithScanCodeUserSetting, + `is`(true), + ) + } + + /** + * Issue #761 + */ + @Test + fun `Enable scan code detection for a key event trigger if a key in another key map has the same key code but different scan code`() { + val device = KeyEventTriggerDevice.External( + descriptor = "keyboard0", + name = "Keyboard", + ) + + val otherTriggers = listOf( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 123, + device = device, + clickType = ClickType.SHORT_PRESS, + ), + ) + + val newTrigger = delegate.addKeyEventTriggerKey( + trigger = Trigger(), + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 124, + device = device, + requiresIme = false, + otherTriggers, + ) + + assertThat( + (newTrigger.keys[0] as KeyEventTriggerKey).detectWithScanCodeUserSetting, + `is`(true), + ) + } + + /** + * Issue #761 + */ + @Test + fun `Enable scan code detection if another key event key exists in the trigger with the same key code but different scan code`() { + val device = KeyEventTriggerDevice.External( + descriptor = "keyboard0", + name = "Keyboard", + ) + + val trigger = singleKeyTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 123, + device = device, + clickType = ClickType.SHORT_PRESS, + ), + ) + + val newTrigger = delegate.addKeyEventTriggerKey( + trigger = trigger, + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 124, + device = device, + requiresIme = false, + ) + + assertThat( + (newTrigger.keys[1] as KeyEventTriggerKey).detectWithScanCodeUserSetting, + `is`(true), + ) + } + + /** + * Issue #761 + */ + @Test + fun `Enable scan code detection if another evdev key exists in the trigger with the same key code but different scan code`() { + val device = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, + ) + + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 123, + device = device, + ), + ) + + val newTrigger = delegate.addEvdevTriggerKey( + trigger = trigger, + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 124, + device = device, + ) + + assertThat( + (newTrigger.keys[1] as EvdevTriggerKey).detectWithScanCodeUserSetting, + `is`(true), + ) + } + + @Test + fun `Adding a non evdev key deletes all evdev keys in the trigger`() { + val trigger = parallelTrigger( + FloatingButtonKey( + buttonUid = "floating_button", + button = null, + clickType = ClickType.SHORT_PRESS, + ), + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 123, + device = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, + ), + ), + AssistantTriggerKey( + type = AssistantTriggerType.ANY, + clickType = ClickType.SHORT_PRESS, + ), + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 100, + device = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, + ), + ), + ) + + val newTrigger = delegate.addKeyEventTriggerKey( + trigger, + KeyEvent.KEYCODE_VOLUME_DOWN, + 0, + KeyEventTriggerDevice.Internal, + false, + ) + + assertThat(newTrigger.keys, hasSize(3)) + assertThat(newTrigger.keys[0], instanceOf(FloatingButtonKey::class.java)) + assertThat(newTrigger.keys[1], instanceOf(AssistantTriggerKey::class.java)) + assertThat(newTrigger.keys[2], instanceOf(KeyEventTriggerKey::class.java)) + assertThat( + (newTrigger.keys[2] as KeyEventTriggerKey).requiresIme, + `is`(false), + ) + } + + @Test + fun `Adding an evdev key deletes all non evdev keys in the trigger`() { + val trigger = parallelTrigger( + FloatingButtonKey( + buttonUid = "floating_button", + button = null, + clickType = ClickType.SHORT_PRESS, + ), + triggerKey( + KeyEvent.KEYCODE_VOLUME_UP, + KeyEventTriggerDevice.Internal, + ), + AssistantTriggerKey( + type = AssistantTriggerType.ANY, + clickType = ClickType.SHORT_PRESS, + ), + triggerKey( + KeyEvent.KEYCODE_VOLUME_DOWN, + KeyEventTriggerDevice.Internal, + ), + ) + + val evdevDevice = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, + ) + + val newTrigger = delegate.addEvdevTriggerKey( + trigger = trigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = evdevDevice, + ) + + assertThat(newTrigger.keys, hasSize(3)) + assertThat(newTrigger.keys[0], instanceOf(FloatingButtonKey::class.java)) + assertThat(newTrigger.keys[1], instanceOf(AssistantTriggerKey::class.java)) + assertThat(newTrigger.keys[2], instanceOf(EvdevTriggerKey::class.java)) + assertThat( + (newTrigger.keys[2] as EvdevTriggerKey).device, + `is`(evdevDevice), + ) + } + + @Test + fun `Converting a sequence trigger to parallel trigger removes duplicate evdev keys`() { + val trigger = sequenceTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, + ), + ), + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, + ), + ), + ) + + val newTrigger = delegate.setParallelTriggerMode(trigger) + + assertThat(newTrigger.keys, hasSize(1)) + assertThat(newTrigger.keys[0], instanceOf(EvdevTriggerKey::class.java)) + assertThat( + (newTrigger.keys[0] as EvdevTriggerKey).keyCode, + `is`(KeyEvent.KEYCODE_VOLUME_DOWN), + ) + } + + @Test + fun `Adding the same evdev trigger key from same device makes the trigger a sequence`() { + val emptyTrigger = Trigger() + val device = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, + ) + + val triggerWithFirstKey = delegate.addEvdevTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = device, + ) + + val triggerWithSecondKey = delegate.addEvdevTriggerKey( + trigger = triggerWithFirstKey, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = device, + ) + + assertThat(triggerWithSecondKey.mode, `is`(TriggerMode.Sequence)) + } + + @Test + fun `Adding a key which has the same scan code as another key with scan code detection enabled makes the trigger a sequence`() { + val device = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, + ) + + val trigger = parallelTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + clickType = ClickType.SHORT_PRESS, + device = device, + detectWithScanCodeUserSetting = true, + ), + AssistantTriggerKey(type = AssistantTriggerType.ANY, clickType = ClickType.SHORT_PRESS), + ) + + val newTrigger = delegate.addEvdevTriggerKey( + trigger = trigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = device, + ) + + assertThat(newTrigger.mode, `is`(TriggerMode.Sequence)) + } + + @Test + fun `Adding a key which has the same scan code as the only other key with scan code detection enabled makes the trigger a sequence`() { + val device = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, + ) + + val trigger = singleKeyTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + clickType = ClickType.SHORT_PRESS, + device = device, + detectWithScanCodeUserSetting = true, + ), + ) + + val newTrigger = delegate.addEvdevTriggerKey( + trigger = trigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = device, + ) + + assertThat(newTrigger.mode, `is`(TriggerMode.Sequence)) + } + + @Test + fun `Adding an evdev trigger key to a sequence trigger keeps it sequence`() { + val device = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, + ) + + val trigger = sequenceTrigger( + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = device, + ), + EvdevTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = device, + ), + ) + + // Add a third key and it should still be a sequence trigger now + val newTrigger = delegate.addEvdevTriggerKey( + trigger = trigger, + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 0, + device = device, + ) + + assertThat(newTrigger.mode, `is`(TriggerMode.Sequence)) + } + + @Test + fun `Adding the same evdev trigger key code from different devices keeps the trigger parallel`() { + val emptyTrigger = Trigger() + val device1 = EvdevDeviceInfo( + name = "Volume Keys", + bus = 0, + vendor = 1, + product = 2, + ) + val device2 = EvdevDeviceInfo( + name = "Fake Controller", + bus = 1, + vendor = 2, + product = 1, + ) + + val triggerWithFirstKey = delegate.addEvdevTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = device1, + ) + + val triggerWithSecondKey = delegate.addEvdevTriggerKey( + trigger = triggerWithFirstKey, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = device2, + ) + + assertThat(triggerWithSecondKey.mode, `is`(TriggerMode.Parallel(ClickType.SHORT_PRESS))) + } + + @Test + fun `Do not allow setting double press for parallel trigger with side key`() { + val emptyTrigger = Trigger() + + val triggerWithKeyEvent = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + val triggerWithAssistant = delegate.addAssistantTriggerKey( + trigger = triggerWithKeyEvent, + type = AssistantTriggerType.ANY, + ) + + val finalTrigger = delegate.setTriggerDoublePress(triggerWithAssistant) + + assertThat(finalTrigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + assertThat(finalTrigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) + assertThat(finalTrigger.keys[1].clickType, `is`(ClickType.SHORT_PRESS)) + } + + @Test + fun `Do not allow setting long press for parallel trigger with side key`() { + val emptyTrigger = Trigger() + + val triggerWithKeyEvent = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + val triggerWithAssistant = delegate.addAssistantTriggerKey( + trigger = triggerWithKeyEvent, + type = AssistantTriggerType.ANY, + ) + + val finalTrigger = delegate.setTriggerLongPress(triggerWithAssistant) + + assertThat(finalTrigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + assertThat(finalTrigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) + assertThat(finalTrigger.keys[1].clickType, `is`(ClickType.SHORT_PRESS)) + } + + @Test + fun `Do not allow setting double press for side key`() { + val emptyTrigger = Trigger() + + val triggerWithAssistant = delegate.addAssistantTriggerKey( + trigger = emptyTrigger, + type = AssistantTriggerType.ANY, + ) + + val finalTrigger = delegate.setTriggerDoublePress(triggerWithAssistant) + + assertThat(finalTrigger.mode, `is`(TriggerMode.Undefined)) + assertThat(finalTrigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) + } + + @Test + fun `Do not allow setting long press for side key`() { + val emptyTrigger = Trigger() + + val triggerWithAssistant = delegate.addAssistantTriggerKey( + trigger = emptyTrigger, + type = AssistantTriggerType.ANY, + ) + + val finalTrigger = delegate.setTriggerLongPress(triggerWithAssistant) + + assertThat(finalTrigger.mode, `is`(TriggerMode.Undefined)) + assertThat(finalTrigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) + } + + @Test + fun `Set click type to short press if side key added to double press volume button`() { + val emptyTrigger = Trigger() + + val triggerWithKeyEvent = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + val triggerWithDoublePress = delegate.setTriggerDoublePress(triggerWithKeyEvent) + + val finalTrigger = delegate.addAssistantTriggerKey( + trigger = triggerWithDoublePress, + type = AssistantTriggerType.ANY, + ) + + assertThat(finalTrigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + assertThat(finalTrigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) + assertThat(finalTrigger.keys[1].clickType, `is`(ClickType.SHORT_PRESS)) + } + + @Test + fun `Set click type to short press if fingerprint gestures added to double press volume button`() { + val emptyTrigger = Trigger() + + val triggerWithKeyEvent = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + val triggerWithDoublePress = delegate.setTriggerDoublePress(triggerWithKeyEvent) + + val finalTrigger = delegate.addFingerprintGesture( + trigger = triggerWithDoublePress, + type = FingerprintGestureType.SWIPE_UP, + ) + + assertThat(finalTrigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + assertThat(finalTrigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) + assertThat(finalTrigger.keys[1].clickType, `is`(ClickType.SHORT_PRESS)) + } + + @Test + fun `Set click type to short press if side key added to long press volume button`() { + val emptyTrigger = Trigger() + + val triggerWithKeyEvent = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + val triggerWithLongPress = delegate.setTriggerLongPress(triggerWithKeyEvent) + + val finalTrigger = delegate.addAssistantTriggerKey( + trigger = triggerWithLongPress, + type = AssistantTriggerType.ANY, + ) + + assertThat(finalTrigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + assertThat(finalTrigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) + assertThat(finalTrigger.keys[1].clickType, `is`(ClickType.SHORT_PRESS)) + } + + @Test + fun `Set click type to short press if fingerprint gestures added to long press volume button`() { + val emptyTrigger = Trigger() + + val triggerWithKeyEvent = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + val triggerWithLongPress = delegate.setTriggerLongPress(triggerWithKeyEvent) + + val finalTrigger = delegate.addFingerprintGesture( + trigger = triggerWithLongPress, + type = FingerprintGestureType.SWIPE_UP, + ) + + assertThat(finalTrigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + assertThat(finalTrigger.keys[0].clickType, `is`(ClickType.SHORT_PRESS)) + assertThat(finalTrigger.keys[1].clickType, `is`(ClickType.SHORT_PRESS)) + } + + /** + * This ensures that it isn't possible to have two or more assistant triggers when the mode is parallel. + */ + @Test + fun `Remove device assistant trigger if setting mode to parallel and voice assistant already exists`() { + val emptyTrigger = Trigger() + + val triggerWithKeyEvent = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + val triggerWithVoiceAssistant = delegate.addAssistantTriggerKey( + trigger = triggerWithKeyEvent, + type = AssistantTriggerType.VOICE, + ) + + val triggerWithDeviceAssistant = delegate.addAssistantTriggerKey( + trigger = triggerWithVoiceAssistant, + type = AssistantTriggerType.DEVICE, + ) + + val finalTrigger = delegate.setParallelTriggerMode(triggerWithDeviceAssistant) + + assertThat(finalTrigger.keys, hasSize(2)) + assertThat( + finalTrigger.keys[0], + instanceOf(KeyEventTriggerKey::class.java), + ) + assertThat(finalTrigger.keys[1], instanceOf(AssistantTriggerKey::class.java)) + } + + @Test + fun `Remove voice assistant trigger if setting mode to parallel and device assistant already exists`() { + val emptyTrigger = Trigger() + + val triggerWithKeyEvent = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + val triggerWithDeviceAssistant = delegate.addAssistantTriggerKey( + trigger = triggerWithKeyEvent, + type = AssistantTriggerType.DEVICE, + ) + + val triggerWithVoiceAssistant = delegate.addAssistantTriggerKey( + trigger = triggerWithDeviceAssistant, + type = AssistantTriggerType.VOICE, + ) + + val finalTrigger = delegate.setParallelTriggerMode(triggerWithVoiceAssistant) + + assertThat(finalTrigger.keys, hasSize(2)) + assertThat( + finalTrigger.keys[0], + instanceOf(KeyEventTriggerKey::class.java), + ) + assertThat(finalTrigger.keys[1], instanceOf(AssistantTriggerKey::class.java)) + } + + @Test + fun `Set click type to short press when adding assistant key to multiple long press trigger keys`() { + val emptyTrigger = Trigger() + + val triggerWithFirstKey = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + val triggerWithSecondKey = delegate.addKeyEventTriggerKey( + trigger = triggerWithFirstKey, + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + val triggerWithLongPress = delegate.setTriggerLongPress(triggerWithSecondKey) + + val finalTrigger = delegate.addAssistantTriggerKey( + trigger = triggerWithLongPress, + type = AssistantTriggerType.ANY, + ) + + assertThat(finalTrigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + } + + @Test + fun `Set click type to short press when adding assistant key to double press trigger key`() { + val emptyTrigger = Trigger() + + val triggerWithKeyEvent = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + val triggerWithDoublePress = delegate.setTriggerDoublePress(triggerWithKeyEvent) + + val finalTrigger = delegate.addAssistantTriggerKey( + trigger = triggerWithDoublePress, + type = AssistantTriggerType.ANY, + ) + + assertThat(finalTrigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + } + + @Test + fun `Set click type to short press when adding assistant key to long press trigger key`() { + val emptyTrigger = Trigger() + + val triggerWithKeyEvent = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + val triggerWithLongPress = delegate.setTriggerLongPress(triggerWithKeyEvent) + + val finalTrigger = delegate.addAssistantTriggerKey( + trigger = triggerWithLongPress, + type = AssistantTriggerType.ANY, + ) + + assertThat(finalTrigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + } + + @Test + fun `Do not allow long press for parallel trigger with assistant key`() { + val trigger = Trigger( + mode = TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS), + keys = listOf( + triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), + AssistantTriggerKey( + type = AssistantTriggerType.ANY, + clickType = ClickType.SHORT_PRESS, + ), + ), + ) + + val finalTrigger = delegate.setTriggerLongPress(trigger) + + assertThat(finalTrigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + } + + /** + * Issue #753. If a modifier key is used as a trigger then it the + * option to not override the default action must be chosen so that the modifier + * key can still be used normally. + */ + @Test + fun `when add modifier key trigger, enable do not remap option`() { + val modifierKeys = setOf( + KeyEvent.KEYCODE_SHIFT_LEFT, + KeyEvent.KEYCODE_SHIFT_RIGHT, + KeyEvent.KEYCODE_ALT_LEFT, + KeyEvent.KEYCODE_ALT_RIGHT, + KeyEvent.KEYCODE_CTRL_LEFT, + KeyEvent.KEYCODE_CTRL_RIGHT, + KeyEvent.KEYCODE_META_LEFT, + KeyEvent.KEYCODE_META_RIGHT, + KeyEvent.KEYCODE_SYM, + KeyEvent.KEYCODE_NUM, + KeyEvent.KEYCODE_FUNCTION, + ) + + for (modifierKeyCode in modifierKeys) { + // GIVEN + val emptyTrigger = Trigger() + + // WHEN + val trigger = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = modifierKeyCode, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + // THEN + assertThat((trigger.keys[0] as KeyEventTriggerKey).consumeEvent, `is`(false)) + } + } + + /** + * Issue #753. + */ + @Test + fun `when add non-modifier key trigger, do not enable do not remap option`() { + // GIVEN + val emptyTrigger = Trigger() + + // WHEN + val trigger = delegate.addKeyEventTriggerKey( + trigger = emptyTrigger, + keyCode = KeyEvent.KEYCODE_A, + scanCode = 0, + device = KeyEventTriggerDevice.Internal, + requiresIme = false, + ) + + // THEN + assertThat((trigger.keys[0] as KeyEventTriggerKey).consumeEvent, `is`(true)) + } + + @Test + fun `Remove keys with same key code from the same internal device when converting to a parallel trigger`() { + val key = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ) + + val trigger = sequenceTrigger( + key, + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + ) + + val newTrigger = delegate.setParallelTriggerMode(trigger) + assertThat(newTrigger.keys, hasSize(1)) + assertThat(newTrigger.keys, contains(key)) + } + + @Test + fun `Do not remove keys with same key code from different devices when converting to a parallel trigger`() { + val trigger = sequenceTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.External( + descriptor = "keyboard0", + name = "Keyboard", + ), + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + ) + + val newTrigger = delegate.setParallelTriggerMode(trigger) + assertThat(newTrigger.keys, hasSize(2)) + assertThat(newTrigger.keys, `is`(trigger.keys)) + } + + @Test + fun `Do not remove keys with different key code from the same device when converting to a parallel trigger`() { + val trigger = sequenceTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_UP, + scanCode = Scancode.KEY_VOLUMEUP, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + ) + + val newTrigger = delegate.setParallelTriggerMode(trigger) + assertThat(newTrigger.keys, hasSize(2)) + assertThat(newTrigger.keys, `is`(trigger.keys)) + } + + @Test + fun `Remove keys from an internal device if it conflicts with any-device key when converting to a parallel trigger`() { + val internalKey = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ) + + val anyDeviceKey = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Any, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ) + + val trigger = sequenceTrigger(internalKey, anyDeviceKey) + + val newTrigger = delegate.setParallelTriggerMode(trigger) + assertThat(newTrigger.keys, hasSize(1)) + assertThat(newTrigger.keys, contains(internalKey)) + } + + @Test + fun `Remove keys with the same key code from the same external device when converting to a parallel trigger`() { + val key = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.External( + descriptor = "keyboard0", + name = "Keyboard", + ), + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ) + + val trigger = sequenceTrigger( + key, + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.External( + descriptor = "keyboard0", + name = "Keyboard", + ), + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + ) + + val newTrigger = delegate.setParallelTriggerMode(trigger) + assertThat(newTrigger.keys, hasSize(1)) + assertThat(newTrigger.keys, contains(key)) + } + + @Test + fun `Remove conflicting keys that are all any-device or internal when converting to a parallel trigger`() { + val trigger = sequenceTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Any, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Any, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.External( + descriptor = "keyboard0", + name = "Keyboard", + ), + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Any, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ), + ) + + val newTrigger = delegate.setParallelTriggerMode(trigger) + assertThat(newTrigger.keys, hasSize(1)) + assertThat(newTrigger.keys, contains(trigger.keys[0])) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerKeyDeviceTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerKeyDeviceTest.kt new file mode 100644 index 0000000000..2543222291 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerKeyDeviceTest.kt @@ -0,0 +1,63 @@ +package io.github.sds100.keymapper.base.trigger + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.junit.Test + +class TriggerKeyDeviceTest { + @Test + fun `external device is same as another external device`() { + val device1 = KeyEventTriggerDevice.External("keyboard0", "Keyboard 0") + val device2 = KeyEventTriggerDevice.External("keyboard0", "Keyboard 0") + assertThat(device1.isSameDevice(device2), `is`(true)) + } + + @Test + fun `external device is not the same as a different external device`() { + val device1 = KeyEventTriggerDevice.External("keyboard0", "Keyboard 0") + val device2 = KeyEventTriggerDevice.External("keyboard1", "Keyboard 1") + assertThat(device1.isSameDevice(device2), `is`(false)) + } + + @Test + fun `external device is not the same as a different external device with the same name`() { + val device1 = KeyEventTriggerDevice.External("keyboard0", "Keyboard 0") + val device2 = KeyEventTriggerDevice.External("keyboard1", "Keyboard 0") + assertThat(device1.isSameDevice(device2), `is`(false)) + } + + @Test + fun `internal device is not the same as a an external`() { + val device1 = KeyEventTriggerDevice.Internal + val device2 = KeyEventTriggerDevice.External("keyboard1", "Keyboard 0") + assertThat(device1.isSameDevice(device2), `is`(false)) + } + + @Test + fun `internal device is the same as an internal device`() { + val device1 = KeyEventTriggerDevice.Internal + val device2 = KeyEventTriggerDevice.Internal + assertThat(device1.isSameDevice(device2), `is`(true)) + } + + @Test + fun `any device is the same as an internal device`() { + val device1 = KeyEventTriggerDevice.Any + val device2 = KeyEventTriggerDevice.Internal + assertThat(device1.isSameDevice(device2), `is`(true)) + } + + @Test + fun `any device is the same as any device`() { + val device1 = KeyEventTriggerDevice.Any + val device2 = KeyEventTriggerDevice.Any + assertThat(device1.isSameDevice(device2), `is`(true)) + } + + @Test + fun `any device is the same as an external device`() { + val device1 = KeyEventTriggerDevice.Any + val device2 = KeyEventTriggerDevice.External("keyboard1", "Keyboard 0") + assertThat(device1.isSameDevice(device2), `is`(true)) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerKeyTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerKeyTest.kt new file mode 100644 index 0000000000..060313fb27 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerKeyTest.kt @@ -0,0 +1,124 @@ +package io.github.sds100.keymapper.base.trigger + +import android.view.KeyEvent +import io.github.sds100.keymapper.base.keymaps.ClickType +import io.github.sds100.keymapper.system.inputevents.Scancode +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic + +class TriggerKeyTest { + + private lateinit var mockedKeyEvent: MockedStatic + + @Before + fun setUp() { + mockedKeyEvent = mockStatic(KeyEvent::class.java) + mockedKeyEvent.`when` { KeyEvent.getMaxKeyCode() }.thenReturn(1000) + } + + @After + fun tearDown() { + mockedKeyEvent.close() + } + + @Test + fun `User can not change scan code detection if the scan code is null`() { + val triggerKey = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = null, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ) + assertThat(triggerKey.isScanCodeDetectionUserConfigurable(), `is`(false)) + } + + @Test + fun `User can not change scan code detection if the key code is unknown and scan code is non null`() { + val triggerKey = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_UNKNOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ) + assertThat(triggerKey.isScanCodeDetectionUserConfigurable(), `is`(false)) + } + + @Test + fun `User can change scan code detection if the key code is known and scan code is non null`() { + val triggerKey = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ) + assertThat(triggerKey.isScanCodeDetectionUserConfigurable(), `is`(true)) + } + + @Test + fun `detect with scan code if key code is unknown and user setting enabled`() { + val triggerKey = KeyEventTriggerKey( + keyCode = 0, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ) + assertThat(triggerKey.detectWithScancode(), `is`(true)) + } + + @Test + fun `detect with scan code if key code is unknown and user setting disabled`() { + val triggerKey = KeyEventTriggerKey( + keyCode = 0, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ) + assertThat(triggerKey.detectWithScancode(), `is`(true)) + } + + @Test + fun `detect with scan code if user setting enabled and scan code non null`() { + val triggerKey = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ) + assertThat(triggerKey.detectWithScancode(), `is`(true)) + } + + @Test + fun `detect with key code if user setting enabled and scan code is null`() { + val triggerKey = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = null, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = true, + ) + assertThat(triggerKey.detectWithScancode(), `is`(false)) + } + + @Test + fun `detect with key code if user setting false and key code is known`() { + val triggerKey = KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + scanCode = Scancode.KEY_VOLUMEDOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + detectWithScanCodeUserSetting = false, + ) + assertThat(triggerKey.detectWithScancode(), `is`(false)) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/utils/JsonTestUtils.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/JsonTestUtils.kt index d3754fcc80..d6b7639b50 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/utils/JsonTestUtils.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/utils/JsonTestUtils.kt @@ -14,12 +14,23 @@ import org.junit.Assert object JsonTestUtils { private const val NAME_SEPARATOR = '/' - fun compareBothWays(element: JsonElement, elementName: String, other: JsonElement, otherName: String) { + fun compareBothWays( + element: JsonElement, + elementName: String, + other: JsonElement, + otherName: String, + ) { compare("", element, elementName, other, otherName) compare("", other, elementName, element, elementName) } - private fun compare(parentNamePath: String = "", element: JsonElement, elementName: String, rootToCompare: JsonElement, rootName: String) { + private fun compare( + parentNamePath: String = "", + element: JsonElement, + elementName: String, + rootToCompare: JsonElement, + rootName: String, + ) { when (element) { is JsonObject -> { element.forEach { name, jsonElement -> @@ -48,15 +59,27 @@ object JsonTestUtils { arrayToCompare = parentElement as JsonArray } - Assert.assertNotNull("can't find array $elementName/$parentNamePath in $rootName", arrayToCompare) + Assert.assertNotNull( + "can't find array $elementName/$parentNamePath in $rootName", + arrayToCompare, + ) arrayToCompare ?: return element.forEachIndexed { index, arrayElement -> val validIndex = index <= arrayToCompare.toList().lastIndex - assertThat("$rootName/${pathToArrayToCompare.last()} doesn't contain $arrayElement at $index index", validIndex) - - compare("", arrayElement, "$elementName/${pathToArrayToCompare.last()}", arrayToCompare[index]!!, "$rootName/${pathToArrayToCompare.last()}") + assertThat( + "$rootName/${pathToArrayToCompare.last()} doesn't contain $arrayElement at $index index", + validIndex, + ) + + compare( + "", + arrayElement, + "$elementName/${pathToArrayToCompare.last()}", + arrayToCompare[index]!!, + "$rootName/${pathToArrayToCompare.last()}", + ) } } @@ -65,17 +88,28 @@ object JsonTestUtils { var parentElement: JsonElement = rootToCompare if (names == listOf("")) { - assertThat("$elementName/:$element doesn't match $rootName/:$parentElement", (parentElement), `is`(element)) + assertThat( + "$elementName/:$element doesn't match $rootName/:$parentElement", + (parentElement), + `is`(element), + ) } else { names.forEachIndexed { index, name -> if (parentElement is JsonObject) { - assertThat("$elementName/$parentNamePath not found in $rootName", (parentElement as JsonObject).contains(name)) + assertThat( + "$elementName/$parentNamePath not found in $rootName", + (parentElement as JsonObject).contains(name), + ) } parentElement = parentElement[name] if (index == names.lastIndex) { - assertThat("$elementName/$parentNamePath:$element doesn't match $rootName/$parentNamePath:$parentElement", (parentElement as JsonPrimitive), `is`(element)) + assertThat( + "$elementName/$parentNamePath:$element doesn't match $rootName/$parentNamePath:$parentElement", + (parentElement as JsonPrimitive), + `is`(element), + ) } } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/utils/KeyMapUtils.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/KeyMapUtils.kt index cacadd68b9..a7aaf7c89e 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/utils/KeyMapUtils.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/utils/KeyMapUtils.kt @@ -1,11 +1,10 @@ package io.github.sds100.keymapper.base.utils import io.github.sds100.keymapper.base.keymaps.ClickType -import io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey -import io.github.sds100.keymapper.base.trigger.KeyEventDetectionSource +import io.github.sds100.keymapper.base.trigger.KeyEventTriggerDevice +import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey import io.github.sds100.keymapper.base.trigger.Trigger import io.github.sds100.keymapper.base.trigger.TriggerKey -import io.github.sds100.keymapper.base.trigger.TriggerKeyDevice import io.github.sds100.keymapper.base.trigger.TriggerMode fun singleKeyTrigger(key: TriggerKey): Trigger = Trigger( @@ -25,14 +24,14 @@ fun sequenceTrigger(vararg keys: TriggerKey): Trigger = Trigger( fun triggerKey( keyCode: Int, - device: TriggerKeyDevice = TriggerKeyDevice.Internal, + device: KeyEventTriggerDevice = KeyEventTriggerDevice.Internal, clickType: ClickType = ClickType.SHORT_PRESS, consume: Boolean = true, - detectionSource: KeyEventDetectionSource = KeyEventDetectionSource.ACCESSIBILITY_SERVICE, -): KeyCodeTriggerKey = KeyCodeTriggerKey( + requiresIme: Boolean = false, +): KeyEventTriggerKey = KeyEventTriggerKey( keyCode = keyCode, device = device, clickType = clickType, consumeEvent = consume, - detectionSource = detectionSource, + requiresIme = requiresIme, ) diff --git a/base/src/test/java/io/github/sds100/keymapper/base/utils/TestBuildConfigProvider.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestBuildConfigProvider.kt index 0bce5ac12c..67393a19b2 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/utils/TestBuildConfigProvider.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestBuildConfigProvider.kt @@ -4,7 +4,7 @@ import android.os.Build import io.github.sds100.keymapper.base.BuildConfig import io.github.sds100.keymapper.common.BuildConfigProvider -class TestBuildConfigProvider : BuildConfigProvider { +class TestBuildConfigProvider(override val sdkInt: Int) : BuildConfigProvider { override val minApi: Int = Build.VERSION_CODES.LOLLIPOP override val maxApi: Int = 1000 override val packageName: String = BuildConfig.LIBRARY_PACKAGE_NAME diff --git a/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt index a01eaf2edb..69708cf609 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt @@ -1,13 +1,17 @@ package io.github.sds100.keymapper.base.utils import io.github.sds100.keymapper.base.constraints.Constraint +import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.constraints.ConstraintSnapshot import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.system.bluetooth.BluetoothDeviceInfo import io.github.sds100.keymapper.system.camera.CameraLens +import io.github.sds100.keymapper.system.foldable.HingeState +import io.github.sds100.keymapper.system.foldable.isClosed +import io.github.sds100.keymapper.system.foldable.isOpen import io.github.sds100.keymapper.system.phone.CallState -import timber.log.Timber import java.time.LocalTime +import timber.log.Timber class TestConstraintSnapshot( val appInForeground: String? = null, @@ -25,80 +29,83 @@ class TestConstraintSnapshot( val isFrontFlashlightOn: Boolean = false, val isLockscreenShowing: Boolean = false, val localTime: LocalTime = LocalTime.now(), + val hingeState: HingeState = HingeState.Unavailable, ) : ConstraintSnapshot { override fun isSatisfied(constraint: Constraint): Boolean { - val isSatisfied = when (constraint) { - is Constraint.AppInForeground -> appInForeground == constraint.packageName - is Constraint.AppNotInForeground -> appInForeground != constraint.packageName - is Constraint.AppPlayingMedia -> - appsPlayingMedia.contains(constraint.packageName) - - is Constraint.AppNotPlayingMedia -> - appsPlayingMedia.none { it == constraint.packageName } - - is Constraint.MediaPlaying -> appsPlayingMedia.isNotEmpty() - is Constraint.NoMediaPlaying -> appsPlayingMedia.isEmpty() - is Constraint.BtDeviceConnected -> { - connectedBluetoothDevices.any { it.address == constraint.bluetoothAddress } + val isSatisfied = when (val data = constraint.data) { + is ConstraintData.AppInForeground -> appInForeground == data.packageName + is ConstraintData.AppNotInForeground -> appInForeground != data.packageName + is ConstraintData.AppPlayingMedia -> + appsPlayingMedia.contains(data.packageName) + + is ConstraintData.AppNotPlayingMedia -> + appsPlayingMedia.none { it == data.packageName } + + is ConstraintData.MediaPlaying -> appsPlayingMedia.isNotEmpty() + is ConstraintData.NoMediaPlaying -> appsPlayingMedia.isEmpty() + is ConstraintData.BtDeviceConnected -> { + connectedBluetoothDevices.any { it.address == data.bluetoothAddress } } - is Constraint.BtDeviceDisconnected -> { - connectedBluetoothDevices.none { it.address == constraint.bluetoothAddress } + is ConstraintData.BtDeviceDisconnected -> { + connectedBluetoothDevices.none { it.address == data.bluetoothAddress } } - is Constraint.OrientationCustom -> orientation == constraint.orientation - is Constraint.OrientationLandscape -> - orientation == Orientation.ORIENTATION_90 || orientation == Orientation.ORIENTATION_270 + is ConstraintData.OrientationCustom -> orientation == data.orientation + is ConstraintData.OrientationLandscape -> + orientation == Orientation.ORIENTATION_90 || + orientation == Orientation.ORIENTATION_270 - is Constraint.OrientationPortrait -> - orientation == Orientation.ORIENTATION_0 || orientation == Orientation.ORIENTATION_180 + is ConstraintData.OrientationPortrait -> + orientation == Orientation.ORIENTATION_0 || + orientation == Orientation.ORIENTATION_180 - is Constraint.ScreenOff -> !isScreenOn - is Constraint.ScreenOn -> isScreenOn - is Constraint.FlashlightOff -> when (constraint.lens) { + is ConstraintData.ScreenOff -> !isScreenOn + is ConstraintData.ScreenOn -> isScreenOn + is ConstraintData.FlashlightOff -> when (data.lens) { CameraLens.BACK -> !isBackFlashlightOn CameraLens.FRONT -> !isFrontFlashlightOn } - is Constraint.FlashlightOn -> when (constraint.lens) { + is ConstraintData.FlashlightOn -> when (data.lens) { CameraLens.BACK -> isBackFlashlightOn CameraLens.FRONT -> isFrontFlashlightOn } - is Constraint.WifiConnected -> { - if (constraint.ssid == null) { + is ConstraintData.WifiConnected -> { + if (data.ssid == null) { // connected to any network connectedWifiSSID != null } else { - connectedWifiSSID == constraint.ssid + connectedWifiSSID == data.ssid } } - is Constraint.WifiDisconnected -> - if (constraint.ssid == null) { + is ConstraintData.WifiDisconnected -> + if (data.ssid == null) { // connected to no network connectedWifiSSID == null } else { - connectedWifiSSID != constraint.ssid + connectedWifiSSID != data.ssid } - is Constraint.WifiOff -> !isWifiEnabled - is Constraint.WifiOn -> isWifiEnabled - is Constraint.ImeChosen -> chosenImeId == constraint.imeId - is Constraint.ImeNotChosen -> chosenImeId != constraint.imeId - is Constraint.DeviceIsLocked -> isLocked - is Constraint.DeviceIsUnlocked -> !isLocked - is Constraint.InPhoneCall -> callState == CallState.IN_PHONE_CALL - is Constraint.NotInPhoneCall -> callState == CallState.NONE - is Constraint.PhoneRinging -> callState == CallState.RINGING - is Constraint.Charging -> isCharging - is Constraint.Discharging -> !isCharging - is Constraint.LockScreenShowing -> isLockscreenShowing - is Constraint.LockScreenNotShowing -> !isLockscreenShowing - is Constraint.Time -> { - val startTime = constraint.startTime - val endTime = constraint.endTime + is ConstraintData.WifiOff -> !isWifiEnabled + is ConstraintData.WifiOn -> isWifiEnabled + is ConstraintData.ImeChosen -> chosenImeId == data.imeId + is ConstraintData.ImeNotChosen -> chosenImeId != data.imeId + is ConstraintData.DeviceIsLocked -> isLocked + is ConstraintData.DeviceIsUnlocked -> !isLocked + is ConstraintData.InPhoneCall -> callState == CallState.IN_PHONE_CALL + is ConstraintData.NotInPhoneCall -> callState == CallState.NONE + is ConstraintData.PhoneRinging -> callState == CallState.RINGING + is ConstraintData.Charging -> isCharging + is ConstraintData.Discharging -> !isCharging + is ConstraintData.LockScreenShowing -> isLockscreenShowing + is ConstraintData.LockScreenNotShowing -> !isLockscreenShowing + is ConstraintData.Time -> { + val startTime = data.startTime + val endTime = data.endTime if (startTime.isAfter(endTime)) { localTime.isAfter(startTime) || localTime.isBefore(endTime) @@ -106,6 +113,11 @@ class TestConstraintSnapshot( localTime.isAfter(startTime) && localTime.isBefore(endTime) } } + + ConstraintData.HingeClosed -> + hingeState is HingeState.Available && hingeState.isClosed() + ConstraintData.HingeOpen -> + hingeState is HingeState.Available && hingeState.isOpen() } if (isSatisfied) { diff --git a/base/src/test/java/io/github/sds100/keymapper/base/utils/ui/FakeResourceProvider.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/ui/FakeResourceProvider.kt index d289db839d..fbf7c7b132 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/utils/ui/FakeResourceProvider.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/utils/ui/FakeResourceProvider.kt @@ -1,12 +1,9 @@ package io.github.sds100.keymapper.base.utils.ui import android.graphics.drawable.Drawable -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow class FakeResourceProvider : ResourceProvider { val stringResourceMap: MutableMap = mutableMapOf() - override val onThemeChange: Flow = MutableSharedFlow() override fun getString(resId: Int, args: Array): String { return stringResourceMap[resId] ?: "" diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 4ca3da941b..820f93c8fc 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin.serialization) alias(libs.plugins.google.devtools.ksp) alias(libs.plugins.dagger.hilt.android) + alias(libs.plugins.kotlin.parcelize) } android { @@ -25,6 +26,11 @@ android { ) } } + + buildFeatures { + aidl = true + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -43,6 +49,7 @@ dependencies { // kotlin stuff implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization.json) + implementation(libs.jakewharton.timber) implementation(libs.androidx.core.ktx) implementation(libs.dagger.hilt.android) diff --git a/common/src/main/aidl/io/github/sds100/keymapper/common/models/EvdevDeviceHandle.aidl b/common/src/main/aidl/io/github/sds100/keymapper/common/models/EvdevDeviceHandle.aidl new file mode 100644 index 0000000000..8ea6d7f489 --- /dev/null +++ b/common/src/main/aidl/io/github/sds100/keymapper/common/models/EvdevDeviceHandle.aidl @@ -0,0 +1,3 @@ +package io.github.sds100.keymapper.common.models; + +parcelable EvdevDeviceHandle; \ No newline at end of file diff --git a/common/src/main/java/io/github/sds100/keymapper/common/BuildConfigProvider.kt b/common/src/main/java/io/github/sds100/keymapper/common/BuildConfigProvider.kt index c52f9e2116..104c3e6602 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/BuildConfigProvider.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/BuildConfigProvider.kt @@ -6,4 +6,5 @@ interface BuildConfigProvider { val packageName: String val version: String val versionCode: Int + val sdkInt: Int } diff --git a/common/src/main/java/io/github/sds100/keymapper/common/models/EvdevDeviceHandle.kt b/common/src/main/java/io/github/sds100/keymapper/common/models/EvdevDeviceHandle.kt new file mode 100644 index 0000000000..1b14c03b86 --- /dev/null +++ b/common/src/main/java/io/github/sds100/keymapper/common/models/EvdevDeviceHandle.kt @@ -0,0 +1,16 @@ +package io.github.sds100.keymapper.common.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class EvdevDeviceHandle( + /** + * The path to the device. E.g /dev/input/event1 + */ + val path: String, + val name: String, + val bus: Int, + val vendor: Int, + val product: Int, +) : Parcelable diff --git a/common/src/main/java/io/github/sds100/keymapper/common/models/EvdevDeviceInfo.kt b/common/src/main/java/io/github/sds100/keymapper/common/models/EvdevDeviceInfo.kt new file mode 100644 index 0000000000..b37d5ea6dd --- /dev/null +++ b/common/src/main/java/io/github/sds100/keymapper/common/models/EvdevDeviceInfo.kt @@ -0,0 +1,8 @@ +package io.github.sds100.keymapper.common.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class EvdevDeviceInfo(val name: String, val bus: Int, val vendor: Int, val product: Int) : + Parcelable diff --git a/common/src/main/java/io/github/sds100/keymapper/common/models/ShellExecutionMode.kt b/common/src/main/java/io/github/sds100/keymapper/common/models/ShellExecutionMode.kt new file mode 100644 index 0000000000..5a6ba5a743 --- /dev/null +++ b/common/src/main/java/io/github/sds100/keymapper/common/models/ShellExecutionMode.kt @@ -0,0 +1,21 @@ +package io.github.sds100.keymapper.common.models + +/** + * Represents the execution mode for shell commands. + */ +enum class ShellExecutionMode { + /** + * Execute using the standard shell (non-root) + */ + STANDARD, + + /** + * Execute using root privileges (su) + */ + ROOT, + + /** + * Execute using ADB/system bridge (Pro mode) + */ + ADB, +} diff --git a/common/src/main/java/io/github/sds100/keymapper/common/models/ShellResult.kt b/common/src/main/java/io/github/sds100/keymapper/common/models/ShellResult.kt new file mode 100644 index 0000000000..ff93cb00e9 --- /dev/null +++ b/common/src/main/java/io/github/sds100/keymapper/common/models/ShellResult.kt @@ -0,0 +1,24 @@ +package io.github.sds100.keymapper.common.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** + * Represents the result of a shell command execution. + * Contains both stdout and stderr output along with exit code information. + * + * @param stdout The stdout output from the command + * @param exitCode The exit code of the command (0 typically means success) + */ +@Parcelize +data class ShellResult( + val stdout: String, + /** + * Null if it is still executing. + */ + val exitCode: Int?, +) : Parcelable + +fun ShellResult.isExecuting(): Boolean = exitCode == null +fun ShellResult.isSuccess(): Boolean = exitCode == 0 +fun ShellResult.isError(): Boolean = exitCode != null && exitCode != 0 diff --git a/common/src/main/java/io/github/sds100/keymapper/common/notifications/KMNotificationAction.kt b/common/src/main/java/io/github/sds100/keymapper/common/notifications/KMNotificationAction.kt new file mode 100644 index 0000000000..f3e82620ca --- /dev/null +++ b/common/src/main/java/io/github/sds100/keymapper/common/notifications/KMNotificationAction.kt @@ -0,0 +1,44 @@ +package io.github.sds100.keymapper.common.notifications + +sealed class KMNotificationAction { + enum class IntentAction { + RESUME_KEY_MAPS, + PAUSE_KEY_MAPS, + PAIRING_CODE_REPLY, + DISMISS_TOGGLE_KEY_MAPS_NOTIFICATION, + STOP_ACCESSIBILITY_SERVICE, + START_ACCESSIBILITY_SERVICE, + RESTART_ACCESSIBILITY_SERVICE, + TOGGLE_KEY_MAPPER_IME, + SHOW_KEYBOARD, + } + + sealed class Broadcast(val intentAction: IntentAction) : KMNotificationAction() { + data object ResumeKeyMaps : Broadcast(IntentAction.RESUME_KEY_MAPS) + data object PauseKeyMaps : Broadcast(IntentAction.PAUSE_KEY_MAPS) + data object DismissToggleKeyMapsNotification : + Broadcast(IntentAction.DISMISS_TOGGLE_KEY_MAPS_NOTIFICATION) + + data object StopAccessibilityService : Broadcast(IntentAction.STOP_ACCESSIBILITY_SERVICE) + data object StartAccessibilityService : Broadcast(IntentAction.START_ACCESSIBILITY_SERVICE) + data object RestartAccessibilityService : + Broadcast(IntentAction.RESTART_ACCESSIBILITY_SERVICE) + + data object TogglerKeyMapperIme : Broadcast(IntentAction.TOGGLE_KEY_MAPPER_IME) + data object ShowKeyboard : Broadcast(IntentAction.SHOW_KEYBOARD) + } + + sealed class RemoteInput(val key: String, val intentAction: IntentAction) : + KMNotificationAction() { + + data object PairingCode : RemoteInput( + key = "pairing_code", + intentAction = IntentAction.PAIRING_CODE_REPLY, + ) + } + + sealed class Activity : KMNotificationAction() { + data object AccessibilitySettings : Activity() + data class MainActivity(val action: String? = null) : Activity() + } +} diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/AccessibilityServiceError.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/AccessibilityServiceError.kt new file mode 100644 index 0000000000..d4b4aceaf0 --- /dev/null +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/AccessibilityServiceError.kt @@ -0,0 +1,6 @@ +package io.github.sds100.keymapper.common.utils + +sealed class AccessibilityServiceError : KMError() { + data object Disabled : AccessibilityServiceError() + data object Crashed : AccessibilityServiceError() +} diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/BundleUtils.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/BundleUtils.kt index da327c98d2..f4b34ab5ea 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/BundleUtils.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/BundleUtils.kt @@ -3,8 +3,9 @@ package io.github.sds100.keymapper.common.utils import android.os.Bundle import kotlinx.serialization.json.Json -inline fun Bundle.getJsonSerializable(key: String): T? = - getString(key)?.let { Json.decodeFromString(it) } +inline fun Bundle.getJsonSerializable(key: String): T? = getString(key)?.let { + Json.decodeFromString(it) +} inline fun Bundle.putJsonSerializable(key: String, value: T) = putString(key, Json.encodeToString(value)) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/Constants.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/Constants.kt similarity index 56% rename from base/src/main/java/io/github/sds100/keymapper/base/Constants.kt rename to common/src/main/java/io/github/sds100/keymapper/common/utils/Constants.kt index d5f8c15086..9ba395cf9c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/Constants.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/Constants.kt @@ -1,8 +1,9 @@ -package io.github.sds100.keymapper.base +package io.github.sds100.keymapper.common.utils import android.os.Build object Constants { const val MIN_API: Int = Build.VERSION_CODES.LOLLIPOP const val MAX_API: Int = 1000 + const val SYSTEM_BRIDGE_MIN_API = Build.VERSION_CODES.Q } diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/CoroutineUtils.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/CoroutineUtils.kt index de93311481..700e7107a9 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/CoroutineUtils.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/CoroutineUtils.kt @@ -1,10 +1,10 @@ package io.github.sds100.keymapper.common.utils +import kotlin.coroutines.resume import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import kotlin.coroutines.resume fun CancellableContinuation.resumeIfNotCompleted(value: T) { if (!this.isCompleted) { diff --git a/system/src/main/java/io/github/sds100/keymapper/system/devices/InputDeviceInfo.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/InputDeviceInfo.kt similarity index 61% rename from system/src/main/java/io/github/sds100/keymapper/system/devices/InputDeviceInfo.kt rename to common/src/main/java/io/github/sds100/keymapper/common/utils/InputDeviceInfo.kt index c66b126bcc..e5605a3f8e 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/devices/InputDeviceInfo.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/InputDeviceInfo.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.system.devices +package io.github.sds100.keymapper.common.utils import android.os.Parcelable import kotlinx.parcelize.Parcelize @@ -12,4 +12,9 @@ data class InputDeviceInfo( val id: Int, val isExternal: Boolean, val isGameController: Boolean, -) : Parcelable + val sources: Int, +) : Parcelable { + fun supportsSource(source: Int): Boolean { + return sources and source == source + } +} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/devices/InputDeviceUtils.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/InputDeviceUtils.kt similarity index 87% rename from system/src/main/java/io/github/sds100/keymapper/system/devices/InputDeviceUtils.kt rename to common/src/main/java/io/github/sds100/keymapper/common/utils/InputDeviceUtils.kt index b81d75f2ea..d3e14f269f 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/devices/InputDeviceUtils.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/InputDeviceUtils.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.system.devices +package io.github.sds100.keymapper.common.utils import android.os.Build import android.view.InputDevice @@ -7,7 +7,9 @@ import java.lang.reflect.Method object InputDeviceUtils { fun appendDeviceDescriptorToName(descriptor: String, name: String): String = - "$name ${descriptor.substring(0..4)}" + "$name ${descriptor.substring( + 0..4, + )}" fun createInputDeviceInfo(inputDevice: InputDevice): InputDeviceInfo = InputDeviceInfo( inputDevice.descriptor, @@ -15,6 +17,7 @@ object InputDeviceUtils { inputDevice.id, inputDevice.isExternalCompat, isGameController = inputDevice.controllerNumber != 0, + sources = inputDevice.sources, ) } diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/InputEventType.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/InputEventAction.kt similarity index 73% rename from common/src/main/java/io/github/sds100/keymapper/common/utils/InputEventType.kt rename to common/src/main/java/io/github/sds100/keymapper/common/utils/InputEventAction.kt index e3805172d5..0eab15976d 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/InputEventType.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/InputEventAction.kt @@ -1,6 +1,6 @@ package io.github.sds100.keymapper.common.utils -enum class InputEventType { +enum class InputEventAction { DOWN_UP, DOWN, UP, diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt index ac7493b303..7b12c77b24 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt @@ -37,9 +37,6 @@ abstract class KMError : KMResult() { data object NoCompatibleImeEnabled : KMError() data object NoCompatibleImeChosen : KMError() - data object AccessibilityServiceDisabled : KMError() - data object AccessibilityServiceCrashed : KMError() - data object CantShowImePickerInBackground : KMError() data object CantFindImeSettings : KMError() data object GestureStrokeCountTooHigh : KMError() @@ -57,9 +54,13 @@ abstract class KMError : KMResult() { data object CameraVariableFlashlightStrengthUnsupported : KMError() data class FailedToModifySystemSetting(val setting: String) : KMError() - data object FailedToChangeIme : KMError() + data object SwitchImeFailed : KMError() + data object EnableImeFailed : KMError() data object NoAppToOpenUrl : KMError() data object NoAppToPhoneCall : KMError() + data object NoAppToSendSms : KMError() + data class SendSmsError(val resultCode: Int) : KMError() + data object KeyMapperSmsRateLimit : KMError() data class NotAFile(val uri: String) : KMError() data class NotADirectory(val uri: String) : KMError() @@ -89,6 +90,8 @@ abstract class KMError : KMResult() { data object MalformedUrl : KMError() data object UiElementNotFound : KMError() + data class KeyEventActionError(val baseError: KMError) : KMError() + data class ShellCommandTimeout(val timeoutMillis: Long, val stdout: String? = null) : KMError() } inline fun KMResult.onSuccess(f: (T) -> Unit): KMResult { @@ -159,9 +162,10 @@ val KMResult.isError: Boolean val KMResult.isSuccess: Boolean get() = this is Success -fun KMResult.handle(onSuccess: (value: T) -> U, onError: (error: KMError) -> U): U = when (this) { - is Success -> onSuccess(value) - is KMError -> onError(this) -} +fun KMResult.handle(onSuccess: (value: T) -> U, onError: (error: KMError) -> U): U = + when (this) { + is Success -> onSuccess(value) + is KMError -> onError(this) + } fun T.success() = Success(this) diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/MapUtils.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/MapUtils.kt index a3874745a9..1ecc9fdbfd 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/MapUtils.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/MapUtils.kt @@ -3,5 +3,4 @@ package io.github.sds100.keymapper.common.utils /** * Not for high speed stuff. */ -fun Map.getKey(value: V) = - entries.firstOrNull { it.value == value }?.key +fun Map.getKey(value: V) = entries.firstOrNull { it.value == value }?.key diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/MathUtils.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/MathUtils.kt index ad24560739..265225fb7a 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/MathUtils.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/MathUtils.kt @@ -6,22 +6,14 @@ import kotlin.math.cos import kotlin.math.hypot import kotlin.math.sin -data class Line( - val start: Point, - val end: Point, -) +data class Line(val start: Point, val end: Point) object MathUtils { fun deg2rad(degrees: Double): Double = degrees * Math.PI / 180 fun rad2deg(radians: Double): Double = radians * 180 / Math.PI - fun getPerpendicularOfLine( - p1: Point, - p2: Point, - length: Int, - reverse: Boolean = false, - ): Line { + fun getPerpendicularOfLine(p1: Point, p2: Point, length: Int, reverse: Boolean = false): Line { var px = p1.y - p2.y var py = p2.x - p1.x diff --git a/system/src/main/java/io/github/sds100/keymapper/system/SettingsUtils.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/SettingsUtils.kt similarity index 73% rename from system/src/main/java/io/github/sds100/keymapper/system/SettingsUtils.kt rename to common/src/main/java/io/github/sds100/keymapper/common/utils/SettingsUtils.kt index 6fbe786ccd..c38e7e074f 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/SettingsUtils.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/SettingsUtils.kt @@ -1,10 +1,20 @@ -package io.github.sds100.keymapper.system +package io.github.sds100.keymapper.common.utils import android.Manifest +import android.content.ActivityNotFoundException import android.content.Context +import android.content.Intent +import android.database.ContentObserver +import android.net.Uri +import android.os.Handler +import android.os.Looper import android.provider.Settings import androidx.annotation.RequiresPermission - +import androidx.core.os.bundleOf +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import timber.log.Timber object SettingsUtils { @@ -130,4 +140,44 @@ object SettingsUtils { } } } + + fun launchSettingsScreen(ctx: Context, action: String, fragmentArg: String? = null) { + val intent = Intent(action).apply { + if (fragmentArg != null) { + val fragmentArgKey = ":settings:fragment_args_key" + val showFragmentArgsKey = ":settings:show_fragment_args" + + putExtra(fragmentArgKey, fragmentArg) + + val bundle = bundleOf(fragmentArgKey to fragmentArg) + putExtra(showFragmentArgsKey, bundle) + } + + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_NO_HISTORY or + Intent.FLAG_ACTIVITY_CLEAR_TASK or + Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS + } + + try { + ctx.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Timber.e("Failed to start Settings activity: $e") + } + } + + fun settingsCallbackFlow(ctx: Context, uri: Uri): Flow = callbackFlow { + val observer = + object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean, uri: Uri?) { + super.onChange(selfChange, uri) + + trySend(Unit) + } + } + + ctx.contentResolver.registerContentObserver(uri, false, observer) + + this.awaitClose { ctx.contentResolver.unregisterContentObserver(observer) } + } } diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/StringUtils.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/StringUtils.kt index e593631591..d9d37ff9db 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/StringUtils.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/StringUtils.kt @@ -7,7 +7,9 @@ fun String.getWordBoundaries(cursorPosition: Int): Pair? { // return null if there is just whitespace around the position - if (getOrNull(cursorPosition - 1)?.isWhitespace() == true && getOrNull(cursorPosition)?.isWhitespace() == true) { + if (getOrNull(cursorPosition - 1)?.isWhitespace() == true && + getOrNull(cursorPosition)?.isWhitespace() == true + ) { return null } diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/TreeNode.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/TreeNode.kt index 1165dfb6fc..b2767cf23e 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/TreeNode.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/TreeNode.kt @@ -2,9 +2,7 @@ package io.github.sds100.keymapper.common.utils data class TreeNode(val value: T, val children: MutableList> = mutableListOf()) -inline fun TreeNode.breadFirstTraversal( - action: (T) -> Unit, -) { +inline fun TreeNode.breadthFirstTraversal(action: (T) -> Unit) { val queue = ArrayDeque>() queue.add(this) diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/UserHandleUtils.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/UserHandleUtils.kt index 43880737a5..59032ad5fa 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/UserHandleUtils.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/UserHandleUtils.kt @@ -2,8 +2,12 @@ package io.github.sds100.keymapper.common.utils import android.os.UserHandle -fun UserHandle.getIdentifier(): Int { - val getIdentifierMethod = UserHandle::class.java.getMethod("getIdentifier") +object UserHandleUtils { + fun getCallingUserId(): Int { + return UserHandle::class.java.getMethod("getCallingUserId").invoke(null) as Int + } +} - return getIdentifierMethod.invoke(this) as Int +fun UserHandle.getIdentifier(): Int { + return UserHandle::class.java.getMethod("getIdentifier").invoke(this) as Int } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index ca419f4ad6..422f713ae5 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -36,7 +36,6 @@ android { } room { - schemaDirectory("$projectDir/schemas") } } diff --git a/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/21.json b/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/21.json new file mode 100644 index 0000000000..7268deb293 --- /dev/null +++ b/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/21.json @@ -0,0 +1,470 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "4755acc0c863decaac7156915d27b297", + "entities": [ + { + "tableName": "keymaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trigger` TEXT NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, `uid` TEXT NOT NULL, `group_uid` TEXT, FOREIGN KEY(`group_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupUid", + "columnName": "group_uid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keymaps_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_keymaps_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "group_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "fingerprintmaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `extras` TEXT NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `severity` INTEGER NOT NULL, `message` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "severity", + "columnName": "severity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "floating_layouts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_layouts_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_floating_layouts_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "floating_buttons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `layout_uid` TEXT NOT NULL, `text` TEXT NOT NULL, `button_size` INTEGER NOT NULL, `x` INTEGER NOT NULL, `y` INTEGER NOT NULL, `orientation` TEXT NOT NULL, `display_width` INTEGER NOT NULL, `display_height` INTEGER NOT NULL, `border_opacity` REAL, `background_opacity` REAL, `show_over_status_bar` INTEGER, `show_over_input_method` INTEGER, PRIMARY KEY(`uid`), FOREIGN KEY(`layout_uid`) REFERENCES `floating_layouts`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutUid", + "columnName": "layout_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "buttonSize", + "columnName": "button_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orientation", + "columnName": "orientation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayWidth", + "columnName": "display_width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayHeight", + "columnName": "display_height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "borderOpacity", + "columnName": "border_opacity", + "affinity": "REAL" + }, + { + "fieldPath": "backgroundOpacity", + "columnName": "background_opacity", + "affinity": "REAL" + }, + { + "fieldPath": "showOverStatusBar", + "columnName": "show_over_status_bar", + "affinity": "INTEGER" + }, + { + "fieldPath": "showOverInputMethod", + "columnName": "show_over_input_method", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_buttons_layout_uid", + "unique": false, + "columnNames": [ + "layout_uid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_floating_buttons_layout_uid` ON `${TABLE_NAME}` (`layout_uid`)" + } + ], + "foreignKeys": [ + { + "table": "floating_layouts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "layout_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `constraints` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `parent_uid` TEXT, `last_opened_date` INTEGER, PRIMARY KEY(`uid`), FOREIGN KEY(`parent_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraints", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentUid", + "columnName": "parent_uid", + "affinity": "TEXT" + }, + { + "fieldPath": "lastOpenedDate", + "columnName": "last_opened_date", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parent_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "accessibility_nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `package_name` TEXT NOT NULL, `text` TEXT, `content_description` TEXT, `class_name` TEXT, `view_resource_id` TEXT, `unique_id` TEXT, `actions` INTEGER NOT NULL, `interacted` INTEGER NOT NULL DEFAULT false, `tooltip` TEXT DEFAULT NULL, `hint` TEXT DEFAULT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "contentDescription", + "columnName": "content_description", + "affinity": "TEXT" + }, + { + "fieldPath": "className", + "columnName": "class_name", + "affinity": "TEXT" + }, + { + "fieldPath": "viewResourceId", + "columnName": "view_resource_id", + "affinity": "TEXT" + }, + { + "fieldPath": "uniqueId", + "columnName": "unique_id", + "affinity": "TEXT" + }, + { + "fieldPath": "actions", + "columnName": "actions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "interacted", + "columnName": "interacted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "tooltip", + "columnName": "tooltip", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "hint", + "columnName": "hint", + "affinity": "TEXT", + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4755acc0c863decaac7156915d27b297')" + ] + } +} \ No newline at end of file diff --git a/data/src/main/java/io/github/sds100/keymapper/data/DataHiltModule.kt b/data/src/main/java/io/github/sds100/keymapper/data/DataHiltModule.kt index 0dd446dbfe..7276456806 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/DataHiltModule.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/DataHiltModule.kt @@ -11,13 +11,13 @@ import io.github.sds100.keymapper.data.repositories.GroupRepository import io.github.sds100.keymapper.data.repositories.KeyMapRepository import io.github.sds100.keymapper.data.repositories.LogRepository import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.data.repositories.PreferenceRepositoryImpl import io.github.sds100.keymapper.data.repositories.RoomAccessibilityNodeRepository import io.github.sds100.keymapper.data.repositories.RoomFloatingButtonRepository import io.github.sds100.keymapper.data.repositories.RoomFloatingLayoutRepository import io.github.sds100.keymapper.data.repositories.RoomGroupRepository import io.github.sds100.keymapper.data.repositories.RoomKeyMapRepository import io.github.sds100.keymapper.data.repositories.RoomLogRepository -import io.github.sds100.keymapper.data.repositories.SettingsPreferenceRepository import javax.inject.Singleton @Module @@ -25,7 +25,7 @@ import javax.inject.Singleton abstract class DataHiltModule { @Singleton @Binds - abstract fun providePreferenceRepository(impl: SettingsPreferenceRepository): PreferenceRepository + abstract fun providePreferenceRepository(impl: PreferenceRepositoryImpl): PreferenceRepository @Singleton @Binds @@ -37,7 +37,9 @@ abstract class DataHiltModule { @Singleton @Binds - abstract fun provideAccessibilityNodeRepository(impl: RoomAccessibilityNodeRepository): AccessibilityNodeRepository + abstract fun provideAccessibilityNodeRepository( + impl: RoomAccessibilityNodeRepository, + ): AccessibilityNodeRepository @Singleton @Binds @@ -45,9 +47,13 @@ abstract class DataHiltModule { @Singleton @Binds - abstract fun provideFloatingButtonRepository(impl: RoomFloatingButtonRepository): FloatingButtonRepository + abstract fun provideFloatingButtonRepository( + impl: RoomFloatingButtonRepository, + ): FloatingButtonRepository @Singleton @Binds - abstract fun provideFloatingLayoutRepository(impl: RoomFloatingLayoutRepository): FloatingLayoutRepository + abstract fun provideFloatingLayoutRepository( + impl: RoomFloatingLayoutRepository, + ): FloatingLayoutRepository } 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 681c7eaba9..013df385f9 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 @@ -7,12 +7,15 @@ import androidx.datastore.preferences.core.stringSetPreferencesKey object Keys { val darkTheme = stringPreferencesKey("pref_dark_theme_mode") + + @Deprecated("Now use the libsu library to detect whether the device is rooted.") val hasRootPermission = booleanPreferencesKey("pref_allow_root_features") + val shownAppIntro = booleanPreferencesKey("pref_first_time") - val showImePickerNotification = booleanPreferencesKey("pref_show_ime_notification") - val showToggleKeyMapsNotification = booleanPreferencesKey("pref_show_remappings_notification") - val showToggleKeyboardNotification = - booleanPreferencesKey("pref_toggle_key_mapper_keyboard_notification") + +// val showToggleKeyMapsNotification = booleanPreferencesKey("pref_show_remappings_notification") +// val showToggleKeyboardNotification = +// booleanPreferencesKey("pref_toggle_key_mapper_keyboard_notification") val devicesThatChangeIme = stringSetPreferencesKey("pref_devices_that_change_ime") val changeImeOnDeviceConnect = @@ -21,8 +24,8 @@ object Keys { val showToastWhenAutoChangingIme = booleanPreferencesKey("pref_show_toast_when_auto_changing_ime") - val devicesThatShowImePicker = stringSetPreferencesKey("pref_devices_show_ime_picker") - val showImePickerOnDeviceConnect = booleanPreferencesKey("pref_auto_show_ime_picker") +// val devicesThatShowImePicker = stringSetPreferencesKey("pref_devices_show_ime_picker") +// val showImePickerOnDeviceConnect = booleanPreferencesKey("pref_auto_show_ime_picker") val forceVibrate = booleanPreferencesKey("pref_force_vibrate") val defaultLongPressDelay = intPreferencesKey("pref_long_press_delay") @@ -37,7 +40,8 @@ object Keys { val automaticBackupLocation = stringPreferencesKey("pref_automatic_backup_location") val mappingsPaused = booleanPreferencesKey("pref_keymaps_paused") val hideHomeScreenAlerts = booleanPreferencesKey("pref_hide_home_screen_alerts") - val acknowledgedGuiKeyboard = booleanPreferencesKey("pref_acknowledged_gui_keyboard") + + // val acknowledgedGuiKeyboard = booleanPreferencesKey("pref_acknowledged_gui_keyboard") val showDeviceDescriptors = booleanPreferencesKey("pref_show_device_descriptors") // val approvedAssistantTriggerFeaturePrompt = @@ -49,8 +53,16 @@ object Keys { booleanPreferencesKey("key_shown_parallel_trigger_order_warning") val shownSequenceTriggerExplanation = booleanPreferencesKey("key_shown_sequence_trigger_explanation_dialog") - val shownKeyCodeToScanCodeTriggerExplanation = - booleanPreferencesKey("key_shown_keycode_to_scancode_trigger_explanation_dialog") + val shownTriggerConstraintsTip = + booleanPreferencesKey("key_shown_trigger_constraints_tip") + val shownCapsLockProModeTip = + booleanPreferencesKey("key_shown_caps_lock_pro_mode_compatibility_tip") + val shownVolumeButtonsProModeTip = + booleanPreferencesKey("key_shown_volume_buttons_pro_mode_tip") + val shownScreenPinningTip = + booleanPreferencesKey("key_shown_screen_pinning_tip") + val shownRingerModeTip = + booleanPreferencesKey("key_shown_ringer_mode_tip") val lastInstalledVersionCodeHomeScreen = intPreferencesKey("last_installed_version_home_screen") val lastInstalledVersionCodeBackground = @@ -59,22 +71,23 @@ object Keys { val fingerprintGesturesAvailable = booleanPreferencesKey("fingerprint_gestures_available") - val rerouteKeyEvents = booleanPreferencesKey("key_reroute_key_events_from_specified_devices") - val devicesToRerouteKeyEvents = - stringSetPreferencesKey("key_devices_to_reroute_key_events") +// val rerouteKeyEvents = booleanPreferencesKey("key_reroute_key_events_from_specified_devices") +// val devicesToRerouteKeyEvents = stringSetPreferencesKey("key_devices_to_reroute_key_events") val log = booleanPreferencesKey("key_log") val shownShizukuPermissionPrompt = booleanPreferencesKey("key_shown_shizuku_permission_prompt") val savedWifiSSIDs = stringSetPreferencesKey("key_saved_wifi_ssids") val neverShowDndAccessError = booleanPreferencesKey("key_never_show_dnd_error") - val neverShowTriggerKeyboardIconExplanation = - booleanPreferencesKey("key_never_show_keyboard_icon_explanation") + val shownTriggerKeyboardIconExplanation = + booleanPreferencesKey("key_shown_keyboard_icon_explanation") val neverShowDpadImeTriggerError = booleanPreferencesKey("key_never_show_dpad_ime_trigger_error") - val neverShowNoKeysRecordedError = - booleanPreferencesKey("key_never_show_no_keys_recorded_error") + +// val neverShowNoKeysRecordedError = +// booleanPreferencesKey("key_never_show_no_keys_recorded_error") + val sortOrderJson = stringPreferencesKey("key_keymaps_sort_order_json") val sortShowHelp = booleanPreferencesKey("key_keymaps_sort_show_help") @@ -95,18 +108,45 @@ object Keys { val shownTapTargetCreateKeyMap = booleanPreferencesKey("key_shown_tap_target_create_key_map") - val shownTapTargetRecordTrigger = - booleanPreferencesKey("key_shown_tap_target_record_trigger") - - val shownTapTargetAdvancedTriggers = - booleanPreferencesKey("key_shown_tap_target_advanced_triggers") - val shownTapTargetChooseAction = booleanPreferencesKey("key_shown_tap_target_choose_action") - val shownTapTargetChooseConstraint = - booleanPreferencesKey("key_shown_tap_target_choose_constraint") +// val shownTapTargetChooseConstraint = +// booleanPreferencesKey("key_shown_tap_target_choose_constraint") + +// val skipTapTargetTutorial = +// booleanPreferencesKey("key_skip_tap_target_tutorial") + + val isProModeWarningUnderstood = + booleanPreferencesKey("key_is_pro_mode_warning_understood") + + val isProModeInteractiveSetupAssistantEnabled = + booleanPreferencesKey("key_is_pro_mode_setup_assistant_enabled") - val skipTapTargetTutorial = - booleanPreferencesKey("key_skip_tap_target_tutorial") + val isProModeInfoDismissed = + booleanPreferencesKey("key_is_pro_mode_info_dismissed") + + val isProModeAutoStartBootEnabled = + booleanPreferencesKey("key_is_pro_mode_auto_start_boot_enabled") + + val isSystemBridgeEmergencyKilled = + booleanPreferencesKey("key_is_system_bridge_emergency_killed") + + /** + * Whether the user has started the system bridge before. + */ + val isSystemBridgeUsed = booleanPreferencesKey("key_is_system_bridge_used") + + val isCleanShutdown = booleanPreferencesKey("key_is_clean_shutdown") + + val keyEventActionsUseSystemBridge = + booleanPreferencesKey("key_key_event_actions_use_system_bridge") + + val shellCommandScriptText = stringPreferencesKey("key_shell_command_script_text") + + /** + * This is stored as true when PRO Mode has been auto started after upgrading + * to 4.0 on a rooted device. + */ + val handledRootToProModeUpgrade = booleanPreferencesKey("key_handled_root_to_pro_mode_upgrade") } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/PreferenceDefaults.kt b/data/src/main/java/io/github/sds100/keymapper/data/PreferenceDefaults.kt index 187f4ef34e..ae803a96b4 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/PreferenceDefaults.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/PreferenceDefaults.kt @@ -3,7 +3,7 @@ package io.github.sds100.keymapper.data object PreferenceDefaults { const val DARK_THEME = "2" - const val SHOW_TOAST_WHEN_AUTO_CHANGE_IME = true + const val SHOW_TOAST_WHEN_AUTO_CHANGE_IME = false const val CHANGE_IME_ON_INPUT_FOCUS = false const val FORCE_VIBRATE = false @@ -14,4 +14,14 @@ object PreferenceDefaults { const val REPEAT_RATE = 50 const val SEQUENCE_TRIGGER_TIMEOUT = 1000 const val HOLD_DOWN_DURATION = 1000 + + const val PRO_MODE_INTERACTIVE_SETUP_ASSISTANT = true + + // Enable this by default so that key maps will still work for root users + // who upgrade to version 4.0. + const val PRO_MODE_AUTOSTART_BOOT = true + + // It is false by default and the first time they turn on the system bridge, + // the preference will be set to true. + const val KEY_EVENT_ACTIONS_USE_SYSTEM_BRIDGE = false } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt index cd64a24ece..eddf9302b4 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt @@ -33,6 +33,7 @@ import io.github.sds100.keymapper.data.migration.AutoMigration15To16 import io.github.sds100.keymapper.data.migration.AutoMigration16To17 import io.github.sds100.keymapper.data.migration.AutoMigration18To19 import io.github.sds100.keymapper.data.migration.AutoMigration19To20 +import io.github.sds100.keymapper.data.migration.AutoMigration20To21 import io.github.sds100.keymapper.data.migration.Migration10To11 import io.github.sds100.keymapper.data.migration.Migration11To12 import io.github.sds100.keymapper.data.migration.Migration13To14 @@ -46,7 +47,11 @@ import io.github.sds100.keymapper.data.migration.Migration8To9 import io.github.sds100.keymapper.data.migration.Migration9To10 @Database( - entities = [KeyMapEntity::class, FingerprintMapEntity::class, LogEntryEntity::class, FloatingLayoutEntity::class, FloatingButtonEntity::class, GroupEntity::class, AccessibilityNodeEntity::class], + entities = [ + KeyMapEntity::class, FingerprintMapEntity::class, + LogEntryEntity::class, FloatingLayoutEntity::class, + FloatingButtonEntity::class, GroupEntity::class, AccessibilityNodeEntity::class, + ], version = DATABASE_VERSION, exportSchema = true, autoMigrations = [ @@ -60,6 +65,8 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 AutoMigration(from = 18, to = 19, spec = AutoMigration18To19::class), // Adds interacted, tooltip, and hint fields to accessibility node entity AutoMigration(from = 19, to = 20, spec = AutoMigration19To20::class), + // Adds floating button settings to show over status bar, and show over input method + AutoMigration(from = 20, to = 21, spec = AutoMigration20To21::class), ], ) @TypeConverters( @@ -72,7 +79,7 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 abstract class AppDatabase : RoomDatabase() { companion object { const val DATABASE_NAME = "key_map_database" - const val DATABASE_VERSION = 20 + const val DATABASE_VERSION = 21 val MIGRATION_1_2 = object : Migration(1, 2) { @@ -138,7 +145,9 @@ abstract class AppDatabase : RoomDatabase() { val MIGRATION_12_13 = object : Migration(12, 13) { override fun migrate(database: SupportSQLiteDatabase) { - database.execSQL("CREATE TABLE `log` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `severity` INTEGER NOT NULL, `message` TEXT NOT NULL)") + database.execSQL( + "CREATE TABLE `log` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `severity` INTEGER NOT NULL, `message` TEXT NOT NULL)", + ) } } @@ -155,9 +164,8 @@ abstract class AppDatabase : RoomDatabase() { } } - class RoomMigration11To12( - private val fingerprintMapDataStore: DataStore, - ) : Migration(11, 12) { + class RoomMigration11To12(private val fingerprintMapDataStore: DataStore) : + Migration(11, 12) { override fun migrate(database: SupportSQLiteDatabase) { Migration11To12.migrateDatabase(database, fingerprintMapDataStore) } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabaseModule.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabaseModule.kt index 3b1ae3b73f..e37ffd373b 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabaseModule.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabaseModule.kt @@ -22,9 +22,7 @@ import javax.inject.Singleton internal class AppDatabaseModule { @Singleton @Provides - fun provideAppDatabase( - @ApplicationContext ctx: Context, - ): AppDatabase { + fun provideAppDatabase(@ApplicationContext ctx: Context): AppDatabase { return createDatabase(ctx) } @@ -84,5 +82,7 @@ internal class AppDatabaseModule { AppDatabase.MIGRATION_17_18, ).build() - private val Context.legacyFingerprintMapDataStore by preferencesDataStore("fingerprint_gestures") + private val Context.legacyFingerprintMapDataStore by preferencesDataStore( + "fingerprint_gestures", + ) } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/FloatingButtonDao.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/FloatingButtonDao.kt index a1449f92ba..1161082f34 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/FloatingButtonDao.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/FloatingButtonDao.kt @@ -25,6 +25,8 @@ interface FloatingButtonDao { const val KEY_DISPLAY_HEIGHT = "display_height" const val KEY_BORDER_OPACITY = "border_opacity" const val KEY_BACKGROUND_OPACITY = "background_opacity" + const val KEY_SHOW_OVER_STATUS_BAR = "show_over_status_bar" + const val KEY_SHOW_OVER_INPUT_METHOD = "show_over_input_method" } @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_UID = (:uid)") diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt index b463a63f37..1fa1a15137 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/GroupDao.kt @@ -56,6 +56,8 @@ interface GroupDao { @Query("DELETE FROM $TABLE_NAME WHERE $KEY_UID IN (:uid)") suspend fun deleteByUid(vararg uid: String) - @Query("UPDATE $TABLE_NAME SET $KEY_LAST_OPENED_DATE = (:timestamp) WHERE $KEY_UID IS (:groupUid)") + @Query( + "UPDATE $TABLE_NAME SET $KEY_LAST_OPENED_DATE = (:timestamp) WHERE $KEY_UID IS (:groupUid)", + ) suspend fun setLastOpenedDate(groupUid: String, timestamp: Long) } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt index 12db4e7752..52a213c074 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt @@ -46,9 +46,18 @@ interface KeyMapDao { @Query("UPDATE $TABLE_NAME SET $KEY_ENABLED=1 WHERE $KEY_UID in (:uid)") suspend fun enableKeyMapByUid(vararg uid: String) + @Query("UPDATE $TABLE_NAME SET $KEY_ENABLED=1 WHERE $KEY_GROUP_UID IS (:groupUid)") + suspend fun enableKeyMapByGroup(groupUid: String?) + + @Query("UPDATE $TABLE_NAME SET $KEY_ENABLED=0 WHERE $KEY_GROUP_UID IS (:groupUid)") + suspend fun disableKeyMapByGroup(groupUid: String?) + @Query("UPDATE $TABLE_NAME SET $KEY_ENABLED=0 WHERE $KEY_UID in (:uid)") suspend fun disableKeyMapByUid(vararg uid: String) + @Query("UPDATE $TABLE_NAME SET $KEY_ENABLED=NOT $KEY_ENABLED WHERE $KEY_UID in (:uid)") + suspend fun toggleKeyMapByUid(vararg uid: String) + @Query("UPDATE $TABLE_NAME SET $KEY_GROUP_UID=(:groupUid) WHERE $KEY_UID in (:uid)") suspend fun setKeyMapGroup(groupUid: String?, vararg uid: String) 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 d358daeee6..5d33801ee2 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 @@ -7,8 +7,8 @@ import com.github.salomonbrys.kotson.byNullableString import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName -import kotlinx.parcelize.Parcelize import java.util.UUID +import kotlinx.parcelize.Parcelize /** * @property [data] The information required to perform the action. E.g if the type is [Type.APP], @@ -61,7 +61,8 @@ data class ActionEntity( const val EXTRA_KEY_EVENT_META_STATE = "extra_meta_state" const val EXTRA_KEY_EVENT_DEVICE_DESCRIPTOR = "extra_device_descriptor" const val EXTRA_KEY_EVENT_DEVICE_NAME = "extra_device_name" - const val EXTRA_KEY_EVENT_USE_SHELL = "extra_key_event_use_shell" + +// const val EXTRA_KEY_EVENT_USE_SHELL = "extra_key_event_use_shell" const val EXTRA_IME_ID = "extra_ime_id" const val EXTRA_IME_NAME = "extra_ime_name" @@ -84,6 +85,10 @@ data class ActionEntity( const val EXTRA_HTTP_BODY = "extra_http_body" const val EXTRA_HTTP_DESCRIPTION = "extra_http_description" const val EXTRA_HTTP_AUTHORIZATION_HEADER = "extra_http_authorization_header" + const val EXTRA_SMS_MESSAGE = "extra_sms_message" + const val EXTRA_SHELL_COMMAND_USE_ROOT = "extra_shell_command_use_root" + const val EXTRA_SHELL_COMMAND_DESCRIPTION = "extra_shell_command_description" + const val EXTRA_SHELL_COMMAND_TIMEOUT = "extra_shell_command_timeout" // Accessibility node extras const val EXTRA_ACCESSIBILITY_PACKAGE_NAME = "extra_accessibility_package_name" @@ -124,6 +129,8 @@ data class ActionEntity( const val ACTION_FLAG_SHOW_VOLUME_UI = 1 const val ACTION_FLAG_REPEAT = 4 const val ACTION_FLAG_HOLD_DOWN = 8 + const val ACTION_FLAG_SHELL_COMMAND_USE_ROOT = 16 + const val ACTION_FLAG_SHELL_COMMAND_USE_ADB = 32 const val EXTRA_CUSTOM_STOP_REPEAT_BEHAVIOUR = "extra_custom_stop_repeat_behaviour" const val EXTRA_CUSTOM_HOLD_DOWN_BEHAVIOUR = "extra_custom_hold_down_behaviour" @@ -171,8 +178,11 @@ data class ActionEntity( PINCH_COORDINATE, INTENT, PHONE_CALL, + SEND_SMS, + COMPOSE_SMS, SOUND, INTERACT_UI_ELEMENT, + SHELL_COMMAND, } constructor( diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/AssistantTriggerKeyEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/AssistantTriggerKeyEntity.kt index 6c55705467..98077ab600 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/AssistantTriggerKeyEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/AssistantTriggerKeyEntity.kt @@ -2,8 +2,8 @@ package io.github.sds100.keymapper.data.entities import android.os.Parcelable import com.google.gson.annotations.SerializedName -import kotlinx.parcelize.Parcelize import java.util.UUID +import kotlinx.parcelize.Parcelize @Parcelize data class AssistantTriggerKeyEntity( diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt index 6690875809..5af0dd3fa3 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt @@ -6,8 +6,8 @@ import com.github.salomonbrys.kotson.byNullableString import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName -import kotlinx.parcelize.Parcelize import java.util.UUID +import kotlinx.parcelize.Parcelize @Parcelize data class ConstraintEntity( @@ -81,6 +81,9 @@ data class ConstraintEntity( const val CHARGING = "charging" const val DISCHARGING = "discharging" + const val HINGE_CLOSED = "hinge_closed" + const val HINGE_OPEN = "hinge_open" + const val TIME = "time" const val EXTRA_PACKAGE_NAME = "extra_package_name" diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/EvdevTriggerKeyEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/EvdevTriggerKeyEntity.kt new file mode 100644 index 0000000000..840b20e67d --- /dev/null +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/EvdevTriggerKeyEntity.kt @@ -0,0 +1,52 @@ +package io.github.sds100.keymapper.data.entities + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import java.util.UUID +import kotlinx.parcelize.Parcelize + +@Parcelize +data class EvdevTriggerKeyEntity( + @SerializedName(NAME_KEYCODE) + val keyCode: Int, + + @SerializedName(NAME_SCANCODE) + val scanCode: Int, + + @SerializedName(NAME_DEVICE_NAME) + val deviceName: String, + + @SerializedName(NAME_DEVICE_BUS) + val deviceBus: Int, + + @SerializedName(NAME_DEVICE_VENDOR) + val deviceVendor: Int, + + @SerializedName(NAME_DEVICE_PRODUCT) + val deviceProduct: Int, + + @SerializedName(NAME_CLICK_TYPE) + override val clickType: Int = SHORT_PRESS, + + @SerializedName(NAME_FLAGS) + val flags: Int = 0, + + @SerializedName(NAME_UID) + override val uid: String = UUID.randomUUID().toString(), +) : TriggerKeyEntity(), + Parcelable { + + companion object { + // DON'T CHANGE THESE. Used for JSON serialization and parsing. + const val NAME_KEYCODE = "keyCode" + const val NAME_SCANCODE = "scanCode" + const val NAME_DEVICE_NAME = "deviceName" + const val NAME_DEVICE_BUS = "deviceBus" + const val NAME_DEVICE_VENDOR = "deviceVendor" + const val NAME_DEVICE_PRODUCT = "deviceProduct" + const val NAME_FLAGS = "flags" + + const val FLAG_DO_NOT_CONSUME_KEY_EVENT = 1 + const val FLAG_DETECT_WITH_SCAN_CODE = 2 + } +} diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/FingerprintTriggerKeyEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/FingerprintTriggerKeyEntity.kt index 968ae087b5..55afa614cc 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/FingerprintTriggerKeyEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/FingerprintTriggerKeyEntity.kt @@ -2,8 +2,8 @@ package io.github.sds100.keymapper.data.entities import android.os.Parcelable import com.google.gson.annotations.SerializedName -import kotlinx.parcelize.Parcelize import java.util.UUID +import kotlinx.parcelize.Parcelize @Parcelize data class FingerprintTriggerKeyEntity( diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt index 1c1589053e..65e62fa465 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonEntity.kt @@ -6,6 +6,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey import com.github.salomonbrys.kotson.byInt +import com.github.salomonbrys.kotson.byNullableBool import com.github.salomonbrys.kotson.byNullableFloat import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer @@ -17,6 +18,8 @@ import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_DI import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_DISPLAY_WIDTH import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_LAYOUT_UID import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_ORIENTATION +import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_SHOW_OVER_INPUT_METHOD +import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_SHOW_OVER_STATUS_BAR import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_TEXT import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_UID import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao.Companion.KEY_X @@ -82,6 +85,14 @@ data class FloatingButtonEntity( @SerializedName(NAME_BACKGROUND_OPACITY) val backgroundOpacity: Float?, + @ColumnInfo(name = KEY_SHOW_OVER_STATUS_BAR) + @SerializedName(NAME_SHOW_OVER_STATUS_BAR) + val showOverStatusBar: Boolean?, + + @ColumnInfo(name = KEY_SHOW_OVER_INPUT_METHOD) + @SerializedName(NAME_SHOW_OVER_INPUT_METHOD) + val showOverInputMethod: Boolean?, + ) : Parcelable { companion object { // DON'T CHANGE THESE. Used for JSON serialization and parsing. @@ -96,6 +107,8 @@ data class FloatingButtonEntity( const val NAME_DISPLAY_HEIGHT = "displayHeight" const val NAME_BORDER_OPACITY = "border_opacity" const val NAME_BACKGROUND_OPACITY = "background_opacity" + const val NAME_SHOW_OVER_STATUS_BAR = "show_over_status_bar" + const val NAME_SHOW_OVER_INPUT_METHOD = "show_over_input_method" val DESERIALIZER = jsonDeserializer { val uid by it.json.byString(NAME_UID) @@ -109,6 +122,8 @@ data class FloatingButtonEntity( val displayHeight by it.json.byInt(NAME_DISPLAY_HEIGHT) val borderOpacity by it.json.byNullableFloat(NAME_BORDER_OPACITY) val backgroundOpacity by it.json.byNullableFloat(NAME_BACKGROUND_OPACITY) + val showOverStatusBar by it.json.byNullableBool(NAME_SHOW_OVER_STATUS_BAR) + val showOverInputMethod by it.json.byNullableBool(NAME_SHOW_OVER_INPUT_METHOD) FloatingButtonEntity( uid = uid, @@ -122,6 +137,8 @@ data class FloatingButtonEntity( displayHeight = displayHeight, borderOpacity = borderOpacity, backgroundOpacity = backgroundOpacity, + showOverStatusBar = showOverStatusBar, + showOverInputMethod = showOverInputMethod, ) } } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonKeyEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonKeyEntity.kt index c2761acfb9..d6df5db985 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonKeyEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/FloatingButtonKeyEntity.kt @@ -2,8 +2,8 @@ package io.github.sds100.keymapper.data.entities import android.os.Parcelable import com.google.gson.annotations.SerializedName -import kotlinx.parcelize.Parcelize import java.util.UUID +import kotlinx.parcelize.Parcelize @Parcelize data class FloatingButtonKeyEntity( diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt index 18e97c7f66..ba3fe12bf5 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/GroupEntity.kt @@ -13,8 +13,8 @@ import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName import io.github.sds100.keymapper.data.db.dao.GroupDao -import kotlinx.parcelize.Parcelize import java.util.UUID +import kotlinx.parcelize.Parcelize @Entity( tableName = GroupDao.TABLE_NAME, diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyCodeTriggerKeyEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyEventTriggerKeyEntity.kt similarity index 86% rename from data/src/main/java/io/github/sds100/keymapper/data/entities/KeyCodeTriggerKeyEntity.kt rename to data/src/main/java/io/github/sds100/keymapper/data/entities/KeyEventTriggerKeyEntity.kt index e26132c25a..6bb9f934d4 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyCodeTriggerKeyEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyEventTriggerKeyEntity.kt @@ -2,11 +2,11 @@ package io.github.sds100.keymapper.data.entities import android.os.Parcelable import com.google.gson.annotations.SerializedName -import kotlinx.parcelize.Parcelize import java.util.UUID +import kotlinx.parcelize.Parcelize @Parcelize -data class KeyCodeTriggerKeyEntity( +data class KeyEventTriggerKeyEntity( @SerializedName(NAME_KEYCODE) val keyCode: Int, @@ -24,12 +24,16 @@ data class KeyCodeTriggerKeyEntity( @SerializedName(NAME_UID) override val uid: String = UUID.randomUUID().toString(), + + @SerializedName(NAME_SCANCODE) + val scanCode: Int? = null, ) : TriggerKeyEntity(), Parcelable { companion object { // DON'T CHANGE THESE. Used for JSON serialization and parsing. const val NAME_KEYCODE = "keyCode" + const val NAME_SCANCODE = "scanCode" const val NAME_DEVICE_ID = "deviceId" const val NAME_DEVICE_NAME = "deviceName" const val NAME_FLAGS = "flags" @@ -40,5 +44,6 @@ data class KeyCodeTriggerKeyEntity( const val FLAG_DO_NOT_CONSUME_KEY_EVENT = 1 const val FLAG_DETECTION_SOURCE_INPUT_METHOD = 2 + const val FLAG_DETECT_WITH_SCAN_CODE = 4 } } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt index 5b5e94ba04..d94ae2d09f 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt @@ -16,8 +16,8 @@ import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName import io.github.sds100.keymapper.data.db.dao.GroupDao import io.github.sds100.keymapper.data.db.dao.KeyMapDao -import kotlinx.parcelize.Parcelize import java.util.UUID +import kotlinx.parcelize.Parcelize @Entity( tableName = KeyMapDao.TABLE_NAME, diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/LogEntryEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/LogEntryEntity.kt index bd42e17c29..7e3d8901e0 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/LogEntryEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/LogEntryEntity.kt @@ -20,5 +20,6 @@ data class LogEntryEntity( const val SEVERITY_ERROR = 0 const val SEVERITY_DEBUG = 1 const val SEVERITY_INFO = 2 + const val SEVERITY_WARNING = 3 } } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt index 1a2d114c70..ec2e77603d 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt @@ -39,7 +39,10 @@ data class TriggerEntity( // DON'T CHANGE THESE AND THEY MUST BE POWERS OF 2!! const val TRIGGER_FLAG_VIBRATE = 1 const val TRIGGER_FLAG_LONG_PRESS_DOUBLE_VIBRATION = 2 + + @Deprecated("This is now on by default for evdev trigger keys") const val TRIGGER_FLAG_SCREEN_OFF_TRIGGERS = 4 + const val TRIGGER_FLAG_FROM_OTHER_APPS = 8 const val TRIGGER_FLAG_SHOW_TOAST = 16 diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt index ce98690420..e294f7c795 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt @@ -35,7 +35,8 @@ sealed class TriggerKeyEntity : Parcelable { val SERIALIZER: JsonSerializer = jsonSerializer { (key) -> when (key) { is AssistantTriggerKeyEntity -> Gson().toJsonTree(key) - is KeyCodeTriggerKeyEntity -> Gson().toJsonTree(key) + is KeyEventTriggerKeyEntity -> Gson().toJsonTree(key) + is EvdevTriggerKeyEntity -> Gson().toJsonTree(key) is FloatingButtonKeyEntity -> Gson().toJsonTree(key) is FingerprintTriggerKeyEntity -> Gson().toJsonTree(key) } @@ -60,6 +61,10 @@ sealed class TriggerKeyEntity : Parcelable { return@jsonDeserializer deserializeFingerprintTriggerKey(json, uid!!) } + json.obj.has(EvdevTriggerKeyEntity.NAME_DEVICE_PRODUCT) -> { + return@jsonDeserializer deserializeEvdevTriggerKey(json, uid!!) + } + else -> { return@jsonDeserializer deserializeKeyCodeTriggerKey(json, uid!!) } @@ -96,23 +101,51 @@ sealed class TriggerKeyEntity : Parcelable { return FingerprintTriggerKeyEntity(type, clickType, uid) } + private fun deserializeEvdevTriggerKey( + json: JsonElement, + uid: String, + ): EvdevTriggerKeyEntity { + val keyCode by json.byInt(EvdevTriggerKeyEntity.NAME_KEYCODE) + val scanCode by json.byInt(EvdevTriggerKeyEntity.NAME_SCANCODE) + val deviceName by json.byString(EvdevTriggerKeyEntity.NAME_DEVICE_NAME) + val deviceVendor by json.byInt(EvdevTriggerKeyEntity.NAME_DEVICE_VENDOR) + val deviceProduct by json.byInt(EvdevTriggerKeyEntity.NAME_DEVICE_PRODUCT) + val deviceBus by json.byInt(EvdevTriggerKeyEntity.NAME_DEVICE_BUS) + val clickType by json.byInt(NAME_CLICK_TYPE) + val flags by json.byNullableInt(EvdevTriggerKeyEntity.NAME_FLAGS) + + return EvdevTriggerKeyEntity( + keyCode, + scanCode, + deviceName, + deviceBus, + deviceVendor, + deviceProduct, + clickType, + flags ?: 0, + uid, + ) + } + private fun deserializeKeyCodeTriggerKey( json: JsonElement, uid: String, - ): KeyCodeTriggerKeyEntity { - val keyCode by json.byInt(KeyCodeTriggerKeyEntity.NAME_KEYCODE) - val deviceId by json.byString(KeyCodeTriggerKeyEntity.NAME_DEVICE_ID) - val deviceName by json.byNullableString(KeyCodeTriggerKeyEntity.NAME_DEVICE_NAME) + ): KeyEventTriggerKeyEntity { + val keyCode by json.byInt(KeyEventTriggerKeyEntity.NAME_KEYCODE) + val scanCode by json.byNullableInt(KeyEventTriggerKeyEntity.NAME_SCANCODE) + val deviceId by json.byString(KeyEventTriggerKeyEntity.NAME_DEVICE_ID) + val deviceName by json.byNullableString(KeyEventTriggerKeyEntity.NAME_DEVICE_NAME) val clickType by json.byInt(NAME_CLICK_TYPE) - val flags by json.byNullableInt(KeyCodeTriggerKeyEntity.NAME_FLAGS) + val flags by json.byNullableInt(KeyEventTriggerKeyEntity.NAME_FLAGS) - return KeyCodeTriggerKeyEntity( + return KeyEventTriggerKeyEntity( keyCode, deviceId, deviceName, clickType, flags ?: 0, uid, + scanCode, ) } } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration20To21.kt b/data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration20To21.kt new file mode 100644 index 0000000000..97e9acc56b --- /dev/null +++ b/data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration20To21.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.data.migration + +import androidx.room.migration.AutoMigrationSpec + +class AutoMigration20To21 : AutoMigrationSpec diff --git a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration11To12.kt b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration11To12.kt index edc4a961c9..de8d71d139 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration11To12.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration11To12.kt @@ -30,7 +30,9 @@ object Migration11To12 { val parser = JsonParser() val gson = Gson() - database.execSQL("CREATE TABLE IF NOT EXISTS `fingerprintmaps` (`id` INTEGER NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `extras` TEXT NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))") + database.execSQL( + "CREATE TABLE IF NOT EXISTS `fingerprintmaps` (`id` INTEGER NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `extras` TEXT NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) val legacyFingerprintIdMap = mapOf( "swipe_down" to 0, @@ -86,7 +88,9 @@ object Migration11To12 { val isEnabled = rootElement.convertValueToJson(gson, "enabled").convertJsonValueToSqlValue() - database.execSQL("INSERT INTO fingerprintmaps (id, action_list, constraint_list, constraint_mode, extras, flags, is_enabled) VALUES('$newId', '$sqlActionList', '$constraintList', '$constraintMode', '$extras', '$flags', '$isEnabled')") + database.execSQL( + "INSERT INTO fingerprintmaps (id, action_list, constraint_list, constraint_mode, extras, flags, is_enabled) VALUES('$newId', '$sqlActionList', '$constraintList', '$constraintMode', '$extras', '$flags', '$isEnabled')", + ) } } @@ -115,7 +119,9 @@ object Migration11To12 { val sqlTrigger = gson.toJson(newTriggerJson).convertJsonValueToSqlValue() val sqlActionList = gson.toJson(newActionListJson).convertJsonValueToSqlValue() - database.execSQL("UPDATE keymaps SET trigger='$sqlTrigger', action_list='$sqlActionList' WHERE id=$id") + database.execSQL( + "UPDATE keymaps SET trigger='$sqlTrigger', action_list='$sqlActionList' WHERE id=$id", + ) } database.execSQL("DROP TABLE deviceinfo") @@ -151,10 +157,7 @@ object Migration11To12 { return keyMap } - private fun migrateKeyMapTrigger( - trigger: JsonElement, - deviceInfoList: JsonArray, - ): JsonElement { + private fun migrateKeyMapTrigger(trigger: JsonElement, deviceInfoList: JsonArray): JsonElement { val oldTriggerKeys = trigger["keys"].asJsonArray val newTriggerKeys = oldTriggerKeys.map { triggerKey -> @@ -182,10 +185,7 @@ object Migration11To12 { return trigger } - private fun migrateActionList( - actionList: JsonArray, - deviceInfoList: JsonArray, - ): JsonArray { + private fun migrateActionList(actionList: JsonArray, deviceInfoList: JsonArray): JsonArray { return actionList.map { action -> if (action["type"].asString == "KEY_EVENT") { val extras = action["extras"].asJsonArray @@ -212,14 +212,13 @@ object Migration11To12 { }.toJsonArray() } - private fun String?.convertJsonValueToSqlValue() = - when { - this == null -> "NULL" - this == "true" -> 1 - this == "false" -> 0 - this.toIntOrNull() != null -> this.toInt() - else -> this - } + private fun String?.convertJsonValueToSqlValue() = when { + this == null -> "NULL" + this == "true" -> 1 + this == "false" -> 0 + this.toIntOrNull() != null -> this.toInt() + else -> this + } private fun JsonElement.convertValueToJson(gson: Gson, key: String) = try { gson.toJson(this[key]) diff --git a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration13To14.kt b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration13To14.kt index 3e448dd338..fc23a69ce9 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration13To14.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration13To14.kt @@ -9,17 +9,23 @@ object Migration13To14 { """CREATE TABLE IF NOT EXISTS `floating_layouts` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))""", ) - database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_floating_layouts_name` ON `floating_layouts` (`name`)") + database.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_floating_layouts_name` ON `floating_layouts` (`name`)", + ) // Create floating button table database.execSQL( """CREATE TABLE IF NOT EXISTS `floating_buttons` (`uid` TEXT NOT NULL, `layout_uid` TEXT NOT NULL, `text` TEXT NOT NULL, `button_size` INTEGER NOT NULL, `x` INTEGER NOT NULL, `y` INTEGER NOT NULL, `orientation` TEXT NOT NULL, `display_width` INTEGER NOT NULL, `display_height` INTEGER NOT NULL, PRIMARY KEY(`uid`), FOREIGN KEY(`layout_uid`) REFERENCES `floating_layouts`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )""", ) - database.execSQL("CREATE INDEX IF NOT EXISTS `index_floating_buttons_layout_uid` ON `floating_buttons` (`layout_uid`)") + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_floating_buttons_layout_uid` ON `floating_buttons` (`layout_uid`)", + ) // Create index for key maps to ensure all key maps have unique UIDs - database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_keymaps_uid` ON `keymaps` (`uid`)") + database.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS `index_keymaps_uid` ON `keymaps` (`uid`)", + ) } } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration1To2.kt b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration1To2.kt index 41037750a4..9752a14812 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration1To2.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration1To2.kt @@ -14,7 +14,7 @@ import com.google.gson.JsonObject import com.google.gson.JsonParser import io.github.sds100.keymapper.common.utils.hasFlag import io.github.sds100.keymapper.data.entities.ActionEntity -import io.github.sds100.keymapper.data.entities.KeyCodeTriggerKeyEntity +import io.github.sds100.keymapper.data.entities.KeyEventTriggerKeyEntity import timber.log.Timber /** @@ -103,7 +103,7 @@ object Migration1To2 { createTriggerKey2( it.asInt, - KeyCodeTriggerKeyEntity.DEVICE_ID_ANY_DEVICE, + KeyEventTriggerKeyEntity.DEVICE_ID_ANY_DEVICE, clickType, ) } @@ -153,7 +153,11 @@ object Migration1To2 { execSQL( """ INSERT INTO 'new_keymaps' ('id', 'trigger', 'action_list', 'constraint_list', 'constraint_mode', 'flags', 'folder_name', 'is_enabled') - VALUES ($id, '${gson.toJson(it)}', '${gson.toJson(actionListNew)}', '[]', 1, '$flagsNew', 'NULL', $isEnabledOld) + VALUES ($id, '${gson.toJson( + it, + )}', '${gson.toJson( + actionListNew, + )}', '[]', 1, '$flagsNew', 'NULL', $isEnabledOld) """.trimIndent(), ) id++ @@ -168,7 +172,9 @@ object Migration1To2 { execSQL("DROP TABLE keymaps") execSQL("ALTER TABLE new_keymaps RENAME TO keymaps") - execSQL("CREATE TABLE IF NOT EXISTS `deviceinfo` (`descriptor` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`descriptor`))") + execSQL( + "CREATE TABLE IF NOT EXISTS `deviceinfo` (`descriptor` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`descriptor`))", + ) } private fun createTriggerKey2(keyCode: Int, deviceId: String, clickType: Int) = @@ -180,28 +186,22 @@ object Migration1To2 { ) } - private fun createTrigger2( - keys: JsonArray = JsonArray(), - mode: Int = MODE_SEQUENCE, - ) = JsonObject().apply { - putAll( - "keys" to keys, - "extras" to JsonArray(), - "mode" to mode, - ) - } + private fun createTrigger2(keys: JsonArray = JsonArray(), mode: Int = MODE_SEQUENCE) = + JsonObject().apply { + putAll( + "keys" to keys, + "extras" to JsonArray(), + "mode" to mode, + ) + } - private fun createAction2( - type: String, - data: String, - extras: JsonArray, - flags: Int, - ) = JsonObject().apply { - putAll( - "type" to type, - "data" to data, - "extras" to extras, - "flags" to flags, - ) - } + private fun createAction2(type: String, data: String, extras: JsonArray, flags: Int) = + JsonObject().apply { + putAll( + "type" to type, + "data" to data, + "extras" to extras, + "flags" to flags, + ) + } } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration4To5.kt b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration4To5.kt index 6f4e4aeb4d..c8730e94b4 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration4To5.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration4To5.kt @@ -120,8 +120,9 @@ object Migration4To5 { } } - private fun JsonArray.getExtraData(id: String): String? = - singleOrNull { it["id"].asString == id }?.get("data")?.asString + private fun JsonArray.getExtraData(id: String): String? = singleOrNull { + it["id"].asString == id + }?.get("data")?.asString private fun JsonArray.putExtra(id: String, data: String) { val obj = JsonObject().apply { diff --git a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration5To6.kt b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration5To6.kt index 4a2de3f013..422d765c90 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration5To6.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration5To6.kt @@ -33,7 +33,9 @@ object Migration5To6 { trigger["flags"] = keymapFlags - execSQL("UPDATE keymaps SET trigger='${gson.toJson(trigger)}', flags=0 WHERE id=$id") + execSQL( + "UPDATE keymaps SET trigger='${gson.toJson(trigger)}', flags=0 WHERE id=$id", + ) } close() diff --git a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration6To7.kt b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration6To7.kt index a137888a53..a648117ade 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration6To7.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration6To7.kt @@ -11,7 +11,7 @@ import com.google.gson.GsonBuilder import io.github.sds100.keymapper.common.utils.hasFlag import io.github.sds100.keymapper.common.utils.minusFlag import io.github.sds100.keymapper.common.utils.withFlag -import io.github.sds100.keymapper.data.entities.KeyCodeTriggerKeyEntity +import io.github.sds100.keymapper.data.entities.KeyEventTriggerKeyEntity import io.github.sds100.keymapper.data.entities.TriggerEntity import io.github.sds100.keymapper.data.entities.TriggerKeyEntity @@ -40,10 +40,14 @@ object Migration6To7 { val trigger = gson.fromJson(getString(triggerColumnIndex)) val newTriggerKeys = trigger.keys - .mapNotNull { it as? KeyCodeTriggerKeyEntity } + .mapNotNull { it as? KeyEventTriggerKeyEntity } .map { key -> if (trigger.flags.hasFlag(TRIGGER_FLAG_DONT_OVERRIDE_DEFAULT_ACTION)) { - key.copy(flags = key.flags.withFlag(KeyCodeTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT)) + key.copy( + flags = key.flags.withFlag( + KeyEventTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT, + ), + ) } else { key } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration9To10.kt b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration9To10.kt index f0c53e47c0..3e9eeb2c6b 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration9To10.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/migration/Migration9To10.kt @@ -54,7 +54,9 @@ object Migration9To10 { val newTriggerJson = gson.toJson(newTrigger) val newActionListJson = gson.toJson(newActionList) - execSQL("UPDATE keymaps SET trigger='$newTriggerJson', action_list='$newActionListJson' WHERE id=$id") + execSQL( + "UPDATE keymaps SET trigger='$newTriggerJson', action_list='$newActionListJson' WHERE id=$id", + ) } close() @@ -73,10 +75,7 @@ object Migration9To10 { return keyMap } - private fun migrate( - trigger: JsonElement, - actionList: JsonArray, - ): MigrateModel { + private fun migrate(trigger: JsonElement, actionList: JsonArray): MigrateModel { var showToast = false actionList.forEach { diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/KeyMapRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/KeyMapRepository.kt index c83d5c79f4..122941dce2 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/KeyMapRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/KeyMapRepository.kt @@ -19,5 +19,8 @@ interface KeyMapRepository { fun duplicate(vararg uid: String) fun enableById(vararg uid: String) fun disableById(vararg uid: String) + fun enableByGroup(groupUid: String?) + fun disableByGroup(groupUid: String?) + fun toggleById(vararg uid: String) fun moveToGroup(groupUid: String?, vararg uid: String) } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/LogRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/LogRepository.kt index 1df1d3479a..bdfc30164e 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/LogRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/LogRepository.kt @@ -1,11 +1,10 @@ package io.github.sds100.keymapper.data.repositories -import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.data.entities.LogEntryEntity import kotlinx.coroutines.flow.Flow interface LogRepository { - val log: Flow>> + val log: Flow> fun insert(entry: LogEntryEntity) suspend fun insertSuspend(entry: LogEntryEntity) fun deleteAll() diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/SettingsPreferenceRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/PreferenceRepositoryImpl.kt similarity index 88% rename from data/src/main/java/io/github/sds100/keymapper/data/repositories/SettingsPreferenceRepository.kt rename to data/src/main/java/io/github/sds100/keymapper/data/repositories/PreferenceRepositoryImpl.kt index 8d7f330d20..2a1418a906 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/SettingsPreferenceRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/PreferenceRepositoryImpl.kt @@ -5,16 +5,17 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class SettingsPreferenceRepository @Inject constructor( +class PreferenceRepositoryImpl @Inject constructor( @ApplicationContext context: Context, private val coroutineScope: CoroutineScope, ) : PreferenceRepository { @@ -25,10 +26,12 @@ class SettingsPreferenceRepository @Inject constructor( private val dataStore = ctx.dataStore - override fun get(key: Preferences.Key): Flow = dataStore.data.map { it[key] }.distinctUntilChanged() + override fun get(key: Preferences.Key): Flow = dataStore.data.map { + it[key] + }.distinctUntilChanged() override fun set(key: Preferences.Key, value: T?) { - coroutineScope.launch { + coroutineScope.launch(Dispatchers.IO) { dataStore.updateData { val prefs = it.toMutablePreferences() @@ -50,7 +53,7 @@ class SettingsPreferenceRepository @Inject constructor( } override fun update(key: Preferences.Key, update: suspend (T?) -> T?) { - coroutineScope.launch { + coroutineScope.launch(Dispatchers.IO) { dataStore.updateData { val prefs = it.toMutablePreferences() diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomAccessibilityNodeRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomAccessibilityNodeRepository.kt index 5bb8eb668e..39708aab0f 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomAccessibilityNodeRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomAccessibilityNodeRepository.kt @@ -4,6 +4,8 @@ import android.database.sqlite.SQLiteConstraintException import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao import io.github.sds100.keymapper.data.entities.AccessibilityNodeEntity +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -13,8 +15,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject -import javax.inject.Singleton @Singleton class RoomAccessibilityNodeRepository @Inject constructor( diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomFloatingButtonRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomFloatingButtonRepository.kt index b22e8938ae..85924a069a 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomFloatingButtonRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomFloatingButtonRepository.kt @@ -6,14 +6,14 @@ import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao import io.github.sds100.keymapper.data.entities.FloatingButtonEntity import io.github.sds100.keymapper.data.entities.FloatingButtonEntityWithLayout +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton @Singleton class RoomFloatingButtonRepository @Inject constructor( @@ -33,25 +33,12 @@ class RoomFloatingButtonRepository @Inject constructor( override fun update(button: FloatingButtonEntity) { coroutineScope.launch(dispatchers.default()) { - dao.update( - FloatingButtonEntity( - uid = button.uid, - layoutUid = button.layoutUid, - text = button.text, - buttonSize = button.buttonSize, - borderOpacity = button.borderOpacity, - backgroundOpacity = button.backgroundOpacity, - x = button.x, - y = button.y, - orientation = button.orientation, - displayWidth = button.displayWidth, - displayHeight = button.displayHeight, - ), - ) + dao.update(button) } } - override suspend fun get(uid: String): FloatingButtonEntityWithLayout? = dao.getByUidWithLayout(uid) + override suspend fun get(uid: String): FloatingButtonEntityWithLayout? = + dao.getByUidWithLayout(uid) override fun delete(vararg uid: String) { coroutineScope.launch(dispatchers.default()) { diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomFloatingLayoutRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomFloatingLayoutRepository.kt index bea863f9b8..ec9056926e 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomFloatingLayoutRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomFloatingLayoutRepository.kt @@ -7,6 +7,8 @@ import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.data.db.dao.FloatingLayoutDao import io.github.sds100.keymapper.data.entities.FloatingLayoutEntity import io.github.sds100.keymapper.data.entities.FloatingLayoutEntityWithButtons +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -15,8 +17,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject -import javax.inject.Singleton @Singleton class RoomFloatingLayoutRepository @Inject constructor( diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomGroupRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomGroupRepository.kt index 8bef8186d4..5e4b86bf3d 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomGroupRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomGroupRepository.kt @@ -6,6 +6,8 @@ import io.github.sds100.keymapper.data.db.dao.GroupDao import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.entities.GroupEntityWithChildren import io.github.sds100.keymapper.data.entities.KeyMapEntitiesWithGroup +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted @@ -14,8 +16,6 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import javax.inject.Inject -import javax.inject.Singleton @Singleton class RoomGroupRepository @Inject constructor( diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt index dd05f6d82d..571aea111b 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt @@ -9,18 +9,19 @@ import io.github.sds100.keymapper.data.db.dao.KeyMapDao import io.github.sds100.keymapper.data.entities.FingerprintMapEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.migration.fingerprintmaps.FingerprintToKeyMapMigration +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton @Singleton class RoomKeyMapRepository @Inject constructor( @@ -34,7 +35,7 @@ class RoomKeyMapRepository @Inject constructor( private const val MAX_KEY_MAP_BATCH_SIZE = 200 } - override val keyMapList = keyMapDao.getAll() + override val keyMapList: StateFlow>> = keyMapDao.getAll() .map { State.Data(it) } .flowOn(dispatchers.io()) .stateIn(coroutineScope, SharingStarted.Eagerly, State.Loading) @@ -108,6 +109,18 @@ class RoomKeyMapRepository @Inject constructor( } } + override fun enableByGroup(groupUid: String?) { + coroutineScope.launch(dispatchers.io()) { + keyMapDao.enableKeyMapByGroup(groupUid) + } + } + + override fun disableByGroup(groupUid: String?) { + coroutineScope.launch(dispatchers.io()) { + keyMapDao.disableKeyMapByGroup(groupUid) + } + } + override fun disableById(vararg uid: String) { coroutineScope.launch(dispatchers.io()) { for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { @@ -116,6 +129,14 @@ class RoomKeyMapRepository @Inject constructor( } } + override fun toggleById(vararg uid: String) { + coroutineScope.launch(dispatchers.io()) { + for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { + keyMapDao.toggleKeyMapByUid(*it) + } + } + } + override fun moveToGroup(groupUid: String?, vararg uid: String) { coroutineScope.launch { for (it in uid.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE)) { diff --git a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomLogRepository.kt b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomLogRepository.kt index 083a8034b9..412b904710 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomLogRepository.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/repositories/RoomLogRepository.kt @@ -1,36 +1,25 @@ package io.github.sds100.keymapper.data.repositories -import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.data.db.dao.LogEntryDao import io.github.sds100.keymapper.data.entities.LogEntryEntity +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton @Singleton class RoomLogRepository @Inject constructor( private val coroutineScope: CoroutineScope, private val dao: LogEntryDao, ) : LogRepository { - override val log: Flow>> = dao.getAll() - .map { entityList -> State.Data(entityList) } - .flowOn(Dispatchers.Default) - .stateIn( - coroutineScope, - // save memory by only caching the log when necessary - SharingStarted.WhileSubscribed(replayExpirationMillis = 1000L), - State.Loading, - ) + override val log: Flow> = dao.getAll() + .flowOn(Dispatchers.IO) init { dao.getIds() @@ -38,18 +27,18 @@ class RoomLogRepository @Inject constructor( .onEach { log -> val middleId = log.getOrNull(500) ?: return@onEach dao.deleteRowsWithIdLessThan(middleId) - }.flowOn(Dispatchers.Default) + }.flowOn(Dispatchers.IO) .launchIn(coroutineScope) } override fun deleteAll() { - coroutineScope.launch(Dispatchers.Default) { + coroutineScope.launch(Dispatchers.IO) { dao.deleteAll() } } override fun insert(entry: LogEntryEntity) { - coroutineScope.launch(Dispatchers.Default) { + coroutineScope.launch(Dispatchers.IO) { dao.insert(entry) } } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/utils/PreferenceDelegate.kt b/data/src/main/java/io/github/sds100/keymapper/data/utils/PreferenceDelegate.kt index f8fe4539c1..316627ab03 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/utils/PreferenceDelegate.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/utils/PreferenceDelegate.kt @@ -3,23 +3,20 @@ package io.github.sds100.keymapper.data.utils import androidx.datastore.preferences.core.Preferences import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import kotlin.reflect.KProperty import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlin.reflect.KProperty -class FlowPrefDelegate( - private val key: Preferences.Key, - private val defaultValue: T, -) { +class FlowPrefDelegate(private val key: Preferences.Key, private val defaultValue: T) { operator fun getValue(thisRef: PreferenceRepository, prop: KProperty<*>?): Flow = - thisRef.get(key).map { it ?: defaultValue } + thisRef.get(key).map { + it + ?: defaultValue + } } -class PrefDelegate( - private val key: Preferences.Key, - private val defaultValue: T, -) { +class PrefDelegate(private val key: Preferences.Key, private val defaultValue: T) { operator fun getValue(thisRef: PreferenceRepository, prop: KProperty<*>?): T = thisRef.get(key).map { it ?: defaultValue }.firstBlocking() diff --git a/data/src/main/java/io/github/sds100/keymapper/data/utils/SharedPrefsDataStoreWrapper.kt b/data/src/main/java/io/github/sds100/keymapper/data/utils/SharedPrefsDataStoreWrapper.kt index 44b5eb8eee..9e2abdc82f 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/utils/SharedPrefsDataStoreWrapper.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/utils/SharedPrefsDataStoreWrapper.kt @@ -9,9 +9,9 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.preference.PreferenceDataStore import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import javax.inject.Inject import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import javax.inject.Inject class SharedPrefsDataStoreWrapper @Inject constructor( private val preferenceRepository: PreferenceRepository, @@ -26,9 +26,14 @@ class SharedPrefsDataStoreWrapper @Inject constructor( override fun getInt(key: String, defValue: Int) = getFromSharedPrefs(key, defValue) override fun putInt(key: String, value: Int) = setFromSharedPrefs(key, value) - override fun getStringSet(key: String, defValues: MutableSet?) = getStringSetFromSharedPrefs(key, defValues ?: emptySet()) + override fun getStringSet(key: String, defValues: MutableSet?) = + getStringSetFromSharedPrefs( + key, + defValues ?: emptySet(), + ) - override fun putStringSet(key: String, defValues: MutableSet?) = setStringSetFromSharedPrefs(key, defValues) + override fun putStringSet(key: String, defValues: MutableSet?) = + setStringSetFromSharedPrefs(key, defValues) private inline fun getFromSharedPrefs(key: String, default: T): T = runBlocking { when (default) { @@ -52,7 +57,9 @@ class SharedPrefsDataStoreWrapper @Inject constructor( else -> { val type = T::class.java.name - throw IllegalArgumentException("Don't know how to set a value in shared preferences for this type $type") + throw IllegalArgumentException( + "Don't know how to set a value in shared preferences for this type $type", + ) } } as T } @@ -69,14 +76,17 @@ class SharedPrefsDataStoreWrapper @Inject constructor( is Double -> preferenceRepository.set(doublePreferencesKey(key), value) else -> { val type = value?.let { it::class.java.name } - throw IllegalArgumentException("Don't know how to set a value in shared preferences for this type $type") + throw IllegalArgumentException( + "Don't know how to set a value in shared preferences for this type $type", + ) } } } - private fun getStringSetFromSharedPrefs(key: String, default: Set?): Set = runBlocking { - preferenceRepository.get(stringSetPreferencesKey(key)).first() ?: emptySet() - } + private fun getStringSetFromSharedPrefs(key: String, default: Set?): Set = + runBlocking { + preferenceRepository.get(stringSetPreferencesKey(key)).first() ?: emptySet() + } private fun setStringSetFromSharedPrefs(key: String?, value: Set?) { key ?: return diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c4942c902..67a55b1535 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] compile-sdk = "36" -hilt-navigation-compose = "1.2.0" -min-sdk = "21" +min-sdk = "26" build-tools = "36.0.0" target-sdk = "36" android-gradle-plugin = "8.9.1" androidx-activity = "1.10.1" +androidx-annotation = "1.9.1" androidx-appcompat = "1.7.0" androidx-arch-core-testing = "2.2.0" androidx-constraintlayout = "2.2.1" @@ -29,6 +29,7 @@ androidx-test-core = "1.6.1" androidx-viewpager2 = "1.1.0" dagger-hilt-android = "2.56.2" +hilt-navigation-compose = "1.2.0" compose-bom = "2025.05.01" compose-compiler = "1.5.10" # kotlinCompilerExtensionVersion @@ -38,7 +39,13 @@ epoxy = "4.6.2" flexbox = "3.0.0" google-accompanist-drawablepainter = "0.35.0-alpha" hiddenapibypass = "4.3" +hiddenapibypass-lsposed = "6.1" introshowcaseview = "2.0.2" +conscrypt-android = "2.5.3" +boringssl-ndk = "20250114" +bouncycastle-bcpkix = "1.70" +appiconloader = "1.5.0" +rikkax-core = "1.4.1" junit = "4.13.2" junit-params = "1.1.1" @@ -48,7 +55,7 @@ coroutines = "1.9.0" kotson = "2.5.0" ksp-gradle-plugin = "2.1.0-1.0.28" -ktlint-gradle = "12.1.0" +ktlint-gradle = "13.1.0" #leakcanary = "2.6" # Commented out in original file lingala-zip4j = "2.8.0" material = "1.13.0-alpha13" @@ -69,6 +76,9 @@ room-testing-legacy = "1.1.1" espresso-core = "3.6.1" ui-tooling = "1.8.1" # android.arch.persistence.room:testing +libsu-core = "6.0.0" +rikka-hidden = "4.4.0" + [libraries] # Kotlin androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" } @@ -82,6 +92,7 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx- # AndroidX androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "androidx-activity" } +androidx-annotation-jvm = { group = "androidx.annotation", name = "annotation-jvm", version.ref = "androidx-annotation" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "androidx-arch-core-testing" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "androidx-constraintlayout" } @@ -149,6 +160,8 @@ splitties-toast = { group = "com.louiscad.splitties", name = "splitties-toast", # Rikka Shizuku rikka-shizuku-api = { group = "dev.rikka.shizuku", name = "api", version.ref = "shizuku" } rikka-shizuku-provider = { group = "dev.rikka.shizuku", name = "provider", version.ref = "shizuku" } +rikka-hidden-compat = { group = "dev.rikka.hidden", name = "compat", version.ref = "rikka-hidden" } +rikka-hidden-stub = { group = "dev.rikka.hidden", name = "stub", version.ref = "rikka-hidden" } # Testing junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -168,7 +181,15 @@ github-mflisar-dragselectrecyclerview = { group = "com.github.MFlisar", name = " jakewharton-timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } kotson = { group = "com.github.salomonbrys.kotson", name = "kotson", version.ref = "kotson" } lsposed-hiddenapibypass = { group = "org.lsposed.hiddenapibypass", name = "hiddenapibypass", version.ref = "hiddenapibypass" } +lsposed-hiddenapibypass-updated = { group = "org.lsposed.hiddenapibypass", name = "hiddenapibypass", version.ref = "hiddenapibypass-lsposed" } net-lingala-zip4j = { group = "net.lingala.zip4j", name = "zip4j", version.ref = "lingala-zip4j" } +github-topjohnwu-libsu = { group = "com.github.topjohnwu.libsu", name = "core", version.ref = "libsu-core" } +conscrypt-android = { group = "org.conscrypt", name = "conscrypt-android", version.ref = "conscrypt-android" } +vvb2060-ndk-boringssl = { group = "io.github.vvb2060.ndk", name = "boringssl", version.ref = "boringssl-ndk" } +bouncycastle-bcpkix = { group = "org.bouncycastle", name = "bcpkix-jdk15on", version.ref = "bouncycastle-bcpkix" } +zhanghai-appiconloader = { group = "me.zhanghai.android.appiconloader", name = "appiconloader", version.ref = "appiconloader" } +rikka-rikkax-core = { group = "dev.rikka.rikkax.core", name = "core-ktx", version.ref = "rikkax-core" } + # Gradle Plugins - Aliases for buildscript dependencies / plugins block # These are referenced in build.gradle.kts files' plugins blocks by their ID. diff --git a/settings.gradle.kts b/settings.gradle.kts index 266e977f2e..f035ff415c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,4 +30,4 @@ include(":api") include(":system") include(":common") include(":data") -include(":shizuku") +include(":sysbridge") diff --git a/shizuku/.gitignore b/shizuku/.gitignore deleted file mode 100644 index 42afabfd2a..0000000000 --- a/shizuku/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/shizuku/build.gradle.kts b/shizuku/build.gradle.kts deleted file mode 100644 index 3b8d3dfb26..0000000000 --- a/shizuku/build.gradle.kts +++ /dev/null @@ -1,42 +0,0 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) -} - -android { - namespace = "io.github.sds100.keymapper.shizuku" - compileSdk = 35 - - defaultConfig { - minSdk = 21 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - kotlinOptions { - jvmTarget = "11" - } -} - -dependencies { - - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.appcompat) - implementation(libs.google.android.material) - testImplementation(libs.junit) - androidTestImplementation(libs.androidx.test.ext.junit) - androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file diff --git a/shizuku/src/androidTest/java/io/github/sds100/keymapper/shizuku/ExampleInstrumentedTest.kt b/shizuku/src/androidTest/java/io/github/sds100/keymapper/shizuku/ExampleInstrumentedTest.kt deleted file mode 100644 index 44e338cfc3..0000000000 --- a/shizuku/src/androidTest/java/io/github/sds100/keymapper/shizuku/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.sds100.keymapper.shizuku - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("io.github.sds100.keymapper.shizuku.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/shizuku/src/main/AndroidManifest.xml b/shizuku/src/main/AndroidManifest.xml deleted file mode 100644 index a5918e68ab..0000000000 --- a/shizuku/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/shizuku/src/test/java/io/github/sds100/keymapper/shizuku/ExampleUnitTest.kt b/shizuku/src/test/java/io/github/sds100/keymapper/shizuku/ExampleUnitTest.kt deleted file mode 100644 index c372bb7be5..0000000000 --- a/shizuku/src/test/java/io/github/sds100/keymapper/shizuku/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.github.sds100.keymapper.shizuku - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/sysbridge/.gitignore b/sysbridge/.gitignore new file mode 100644 index 0000000000..a20eae71c6 --- /dev/null +++ b/sysbridge/.gitignore @@ -0,0 +1,4 @@ +/build +.cxx +/src/main/cpp/libevdev/event-names.h +/src/main/cpp/aidl/* \ No newline at end of file diff --git a/sysbridge/build.gradle.kts b/sysbridge/build.gradle.kts new file mode 100644 index 0000000000..758bc153e9 --- /dev/null +++ b/sysbridge/build.gradle.kts @@ -0,0 +1,209 @@ +import kotlin.io.path.absolutePathString + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.google.devtools.ksp) + alias(libs.plugins.dagger.hilt.android) + alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.jlleitschuh.gradle.ktlint) +} + +android { + namespace = "io.github.sds100.keymapper.sysbridge" + compileSdk = libs.versions.compile.sdk.get().toInt() + + defaultConfig { + // Must be API 29 so that the binder-ndk library can be found. + minSdk = 29 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + + externalNativeBuild { + cmake { + val aidlSrcDir = project.file("src/main/cpp/aidl") + + // -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON is required to get the app running on the Android 15. This is related to the new 16kB page size support. + // -DANDROID_WEAK_API_DEFS=ON is required so the libevdev_jni file can run code depending on the SDK. https://developer.android.com/ndk/guides/using-newer-apis + arguments( + "-DANDROID_STL=c++_static", + "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON", + "-DANDROID_WEAK_API_DEFS=ON", + "-Daidl_src_dir=${aidlSrcDir.absolutePath}", + ) + } + } + } + + buildTypes { + release { + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + externalNativeBuild { + cmake { + path("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } + + buildFeatures { + aidl = true + prefab = true + buildConfig = true + } + + packaging { + jniLibs { + // This replaces extractNativeLibs option in the manifest. This is needed so the + // libraries are extracted to a location on disk where the system bridge process + // can access them. Start in Android 6.0, they are no longer extracted by default. + useLegacyPackaging = true + + // This is required on Android 15. Otherwise a java.lang.UnsatisfiedLinkError: dlopen failed: empty/missing DT_HASH/DT_GNU_HASH error is thrown. + keepDebugSymbols.add("**/*.so") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + compileOnly(project(":systemstubs")) + implementation(project(":common")) + + implementation(libs.jakewharton.timber) + + implementation(libs.conscrypt.android) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.dagger.hilt.android) + ksp(libs.dagger.hilt.android.compiler) + implementation(libs.github.topjohnwu.libsu) + implementation(libs.rikka.shizuku.api) + implementation(libs.rikka.shizuku.provider) + implementation(libs.rikka.hidden.compat) + compileOnly(libs.rikka.hidden.stub) + + // From Shizuku :manager module build.gradle file. + implementation(libs.vvb2060.ndk.boringssl) + implementation(libs.lsposed.hiddenapibypass.updated) + implementation(libs.bouncycastle.bcpkix) + implementation(libs.zhanghai.appiconloader) + implementation(libs.rikka.rikkax.core) +} + +tasks.named("preBuild") { + dependsOn(generateLibEvDevEventNames) + dependsOn(compileAidlNdk) +} + +// The list of event names needs to be parsed from the input.h file in the NDK. +// input.h can be found in the Android/sdk/ndk/27.0.12077973/toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/include/linux/input.h +// folder on macOS. +val generateLibEvDevEventNames by tasks.registering(Exec::class) { + dependsOn(compileAidlNdk) + + group = "build" + description = "Generates event names header from input.h" + + val prebuiltDir = File(android.ndkDirectory, "toolchains/llvm/prebuilt") + + // The "darwin-x86_64" part of the path is different on each operating system but it seems like + // the SDK Manager only downloads the NDK specific to the local operating system. So, just + // go into the only directory that the "prebuilt" directory contains. + val hostDirs = prebuiltDir.listFiles { file -> file.isDirectory } + ?: throw GradleException("No prebuilt toolchain directories found in $prebuiltDir") + + if (hostDirs.size != 1) { + throw GradleException( + "Expected exactly one prebuilt toolchain directory in $prebuiltDir, found ${hostDirs.size}", + ) + } + val toolchainDir = hostDirs[0].absolutePath + + val inputHeader = "$toolchainDir/sysroot/usr/include/linux/input.h" + val inputEventCodesHeader = "$toolchainDir/sysroot/usr/include/linux/input-event-codes.h" + val outputHeader = "$projectDir/src/main/cpp/libevdev/event-names.h" + val pythonScript = "$projectDir/src/main/cpp/libevdev/make-event-names.py" + + commandLine("python3", pythonScript, inputHeader, inputEventCodesHeader) + + standardOutput = File(outputHeader).outputStream() + + inputs.file(pythonScript) + inputs.file(inputHeader) + inputs.file(inputEventCodesHeader) + outputs.file(outputHeader) +} + +// Task to compile AIDL files for NDK. +// Taken from https://github.com/lakinduboteju/AndroidNdkBinderExamples +val compileAidlNdk by tasks.registering(Exec::class) { + group = "build" + description = "Compiles AIDL files in src/main/aidl to NDK C++ headers and sources." + + val aidlSrcDir = project.file("src/main/aidl") + // Find all .aidl files. Using fileTree ensures it's dynamic. + val aidlFiles = project.fileTree(aidlSrcDir) { + include("**/IEvdevCallback.aidl") + include("**/InputDeviceIdentifier.aidl") + } + + inputs.files(aidlFiles) + .withPathSensitivity(PathSensitivity.RELATIVE) + .withPropertyName("aidlInputFiles") + + val cppOutDir = project.file("src/main/cpp/aidl") + val cppHeaderOutDir = project.file("src/main/cpp") + + outputs.dir(cppOutDir).withPropertyName("cppOutputDir") + outputs.dir(cppHeaderOutDir).withPropertyName("cppHeaderOutputDir") + + // Path to the aidl executable in the Android SDK + val aidlToolPath = + android.sdkDirectory.toPath() + .resolve("build-tools") + .resolve(android.buildToolsVersion) + .resolve("aidl") + .absolutePathString() + val importSearchPath = aidlSrcDir.absolutePath + + // Ensure output directories exist before trying to write to them + cppOutDir.mkdirs() + cppHeaderOutDir.mkdirs() + + if (aidlFiles.isEmpty) { + logger.info("No AIDL files found in $aidlSrcDir. Skipping compileAidlNdk task.") + return@registering // Exit doLast if no files to process + } + + for (aidlFile in aidlFiles) { + logger.lifecycle("Compiling AIDL file (NDK): ${aidlFile.path}") + + commandLine( + aidlToolPath, + "--lang=ndk", + "-o", cppOutDir.absolutePath, + "-h", cppHeaderOutDir.absolutePath, + "-I", importSearchPath, + aidlFile.absolutePath, + ) + } + + logger.lifecycle( + "AIDL NDK compilation finished. Check outputs in $cppOutDir and $cppHeaderOutDir", + ) +} diff --git a/shizuku/consumer-rules.pro b/sysbridge/consumer-rules.pro similarity index 100% rename from shizuku/consumer-rules.pro rename to sysbridge/consumer-rules.pro diff --git a/shizuku/proguard-rules.pro b/sysbridge/proguard-rules.pro similarity index 100% rename from shizuku/proguard-rules.pro rename to sysbridge/proguard-rules.pro diff --git a/sysbridge/src/main/AndroidManifest.xml b/sysbridge/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..77951bd794 --- /dev/null +++ b/sysbridge/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sysbridge/src/main/aidl/io/github/sds100/keymapper/common/models/ShellResult.aidl b/sysbridge/src/main/aidl/io/github/sds100/keymapper/common/models/ShellResult.aidl new file mode 100644 index 0000000000..3c2ce000c6 --- /dev/null +++ b/sysbridge/src/main/aidl/io/github/sds100/keymapper/common/models/ShellResult.aidl @@ -0,0 +1,3 @@ +package io.github.sds100.keymapper.common.models; + +parcelable ShellResult; diff --git a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/IEvdevCallback.aidl b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/IEvdevCallback.aidl new file mode 100644 index 0000000000..f6b4039a6d --- /dev/null +++ b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/IEvdevCallback.aidl @@ -0,0 +1,7 @@ +package io.github.sds100.keymapper.sysbridge; + +interface IEvdevCallback { + oneway void onEvdevEventLoopStarted(); + boolean onEvdevEvent(String devicePath, long timeSec, long timeUsec, int type, int code, int value, int androidCode); + void onEmergencyKillSystemBridge(); +} \ No newline at end of file diff --git a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/IShizukuStarterService.aidl b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/IShizukuStarterService.aidl new file mode 100644 index 0000000000..14e94f98ce --- /dev/null +++ b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/IShizukuStarterService.aidl @@ -0,0 +1,7 @@ +package io.github.sds100.keymapper.sysbridge; + +interface IShizukuStarterService { + void destroy() = 16777114; // Destroy method defined by Shizuku server + + String executeCommand(String command) = 1; +} \ No newline at end of file diff --git a/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl new file mode 100644 index 0000000000..602310a084 --- /dev/null +++ b/sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl @@ -0,0 +1,45 @@ +package io.github.sds100.keymapper.sysbridge; + +import io.github.sds100.keymapper.sysbridge.IEvdevCallback; +import io.github.sds100.keymapper.common.models.EvdevDeviceHandle; +import io.github.sds100.keymapper.common.models.ShellResult; +import android.view.InputEvent; + +interface ISystemBridge { + void destroy() = 16777114; + int getProcessUid() = 16777113; + int getVersionCode() = 16777112; + ShellResult executeCommand(String command, long timeoutMillis) = 16777111; + + boolean grabEvdevDevice(String devicePath) = 1; + boolean grabEvdevDeviceArray(in String[] devicePath) = 2; + + boolean ungrabEvdevDevice(String devicePath) = 3; + boolean ungrabAllEvdevDevices() = 4; + + void registerEvdevCallback(IEvdevCallback callback) = 5; + void unregisterEvdevCallback() = 6; + + boolean writeEvdevEvent(String devicePath, int type, int code, int value) = 7; + boolean injectInputEvent(in InputEvent event, int mode) = 8; + + EvdevDeviceHandle[] getEvdevInputDevices() = 9; + + boolean setWifiEnabled(boolean enable) = 10; + + void grantPermission(String permission, int deviceId) = 11; + + void setDataEnabled(int subId, boolean enable) = 12; + + void setBluetoothEnabled(boolean enable) = 13; + + void setNfcEnabled(boolean enable) = 14; + + void setAirplaneMode(boolean enable) = 15; + + void forceStopPackage(String packageName) = 16; + + void removeTasks(String packageName) = 17; + + void setRingerMode(int ringerMode) = 18; +} \ No newline at end of file diff --git a/sysbridge/src/main/cpp/CMakeLists.txt b/sysbridge/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000000..f87842b840 --- /dev/null +++ b/sysbridge/src/main/cpp/CMakeLists.txt @@ -0,0 +1,119 @@ +# For more information about using CMake with Android Studio, read the +# documentation: https://d.android.com/studio/projects/add-native-code.html. +# For more examples on how to use CMake, see https://github.com/android/ndk-samples. + +# Sets the minimum CMake version required for this project. +cmake_minimum_required(VERSION 3.22.1) + +# Declares the project name. The project name can be accessed via ${ PROJECT_NAME}, +# Since this is the top level CMakeLists.txt, the project name is also accessible +# with ${CMAKE_PROJECT_NAME} (both CMake variables are in-sync within the top level +# build script scope). +project("sysbridge") + +set(CMAKE_CXX_STANDARD 20) + +set(C_FLAGS "-Werror=format -fdata-sections -ffunction-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics") +set(LINKER_FLAGS "-Wl,--hash-style=both") + +if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug") + message("Building Release...") + + set(C_FLAGS "${C_FLAGS} -O2 -fvisibility=hidden -fvisibility-inlines-hidden") + set(LINKER_FLAGS "${LINKER_FLAGS} -Wl,-exclude-libs,ALL -Wl,--gc-sections") +else () + message("Building Debug...") + + add_definitions(-DDEBUG) +endif () + +set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${C_FLAGS}") +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${C_FLAGS}") + +set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} ${LINKER_FLAGS}") +set(CMAKE_MODULE_LINKER_FLAGS "${CMAKE_MODULE_LINKER_FLAGS} ${LINKER_FLAGS}") + +find_library(log-lib log) +find_package(boringssl REQUIRED CONFIG) + +add_executable(libsysbridge.so + starter.cpp + misc.cpp + selinux.cpp + cgroup.cpp + android.cpp + adb_pairing.cpp) + +target_link_libraries(libsysbridge.so ${log-lib} boringssl::crypto_static) + +if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug") + add_custom_command(TARGET libsysbridge.so POST_BUILD + COMMAND ${CMAKE_STRIP} --remove-section=.comment "${CMAKE_LIBRARY_OUTPUT_DIRECTORY}/libsysbridge.so") +endif () + +add_library(adb SHARED + adb_pairing.cpp misc.cpp) + +target_link_libraries(adb ${log-lib} boringssl::crypto_static) + +if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug") + add_custom_command(TARGET adb POST_BUILD + COMMAND ${CMAKE_STRIP} --remove-section=.comment "${CMAKE_LIBRARY_OUTPUT_DIRECTORY}/libadb.so") +endif () + +# Creates and names a library, sets it as either STATIC +# or SHARED, and provides the relative paths to its source code. +# You can define multiple libraries, and CMake builds them for you. +# Gradle automatically packages shared libraries with your APK. +# +# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define +# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME} +# is preferred for the same purpose. +# +# In order to load a library into your app from Java/Kotlin, you must call +# System.loadLibrary() and pass the name of the library defined here; +# for GameActivity/NativeActivity derived applications, the same library name must be +# used in the AndroidManifest.xml file. +add_library(evdev SHARED + # List C/C++ source files with relative paths to this CMakeLists.txt. + libevdev_jni.cpp + libevdev/libevdev.c + libevdev/libevdev-names.c + libevdev/libevdev-uinput.c + android/input/KeyLayoutMap.cpp + android/input/InputEventLabels.cpp + android/libbase/result.cpp + android/utils/Tokenizer.cpp + android/utils/String16.cpp + android/utils/String8.cpp + android/utils/SharedBuffer.cpp + android/utils/FileMap.cpp + android/utils/Unicode.cpp + android/input/InputDevice.cpp + android/input/Input.cpp + android/libbase/stringprintf.cpp + ${aidl_src_dir}/io/github/sds100/keymapper/sysbridge/IEvdevCallback.cpp) + +find_library( + binder_ndk-lib + binder_ndk +) + +# Specifies libraries CMake should link to your target library. You +# can link libraries from various origins, such as libraries defined in this +# build script, prebuilt third-party libraries, or Android system libraries. +target_link_libraries(evdev + # List libraries link to the target library + android + log + ${binder_ndk-lib}) + +# Add include directories for header files +target_include_directories(evdev PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/android + ${CMAKE_CURRENT_SOURCE_DIR}/android/input + ${CMAKE_CURRENT_SOURCE_DIR}/android/libbase + ${CMAKE_CURRENT_SOURCE_DIR}/android/utils + ${CMAKE_CURRENT_SOURCE_DIR}/libevdev) + diff --git a/sysbridge/src/main/cpp/adb_pairing.cpp b/sysbridge/src/main/cpp/adb_pairing.cpp new file mode 100644 index 0000000000..a7651671c0 --- /dev/null +++ b/sysbridge/src/main/cpp/adb_pairing.cpp @@ -0,0 +1,231 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "adb_pairing.h" + +#define LOG_TAG "AdbPairClient" + +#include "logging.h" + +// --------------------------------------------------------- + +static constexpr spake2_role_t kClientRole = spake2_role_alice; +static constexpr spake2_role_t kServerRole = spake2_role_bob; + +static const uint8_t kClientName[] = "adb pair client"; +static const uint8_t kServerName[] = "adb pair server"; + +static constexpr size_t kHkdfKeyLength = 16; + +struct PairingContextNative { + SPAKE2_CTX *spake2_ctx; + uint8_t key[SPAKE2_MAX_MSG_SIZE]; + size_t key_size; + + EVP_AEAD_CTX *aes_ctx; + uint64_t dec_sequence; + uint64_t enc_sequence; +}; + +static jlong PairingContext_Constructor(JNIEnv *env, jclass clazz, jboolean isClient, jbyteArray jPassword) { + spake2_role_t spake_role; + const uint8_t *my_name; + const uint8_t *their_name; + size_t my_len; + size_t their_len; + + if (isClient) { + spake_role = kClientRole; + my_name = kClientName; + my_len = sizeof(kClientName); + their_name = kServerName; + their_len = sizeof(kServerName); + } else { + spake_role = kServerRole; + my_name = kServerName; + my_len = sizeof(kServerName); + their_name = kClientName; + their_len = sizeof(kClientName); + } + + auto spake2_ctx = SPAKE2_CTX_new(spake_role, my_name, my_len, their_name, their_len); + if (spake2_ctx == nullptr) { + LOGE("Unable to create a SPAKE2 context."); + return 0; + } + + auto pswd_size = env->GetArrayLength(jPassword); + auto pswd = env->GetByteArrayElements(jPassword, nullptr); + + size_t key_size = 0; + uint8_t key[SPAKE2_MAX_MSG_SIZE]; + int status = SPAKE2_generate_msg(spake2_ctx, key, &key_size, SPAKE2_MAX_MSG_SIZE, (uint8_t *) pswd, pswd_size); + if (status != 1 || key_size == 0) { + LOGE("Unable to generate the SPAKE2 public key."); + + env->ReleaseByteArrayElements(jPassword, pswd, 0); + SPAKE2_CTX_free(spake2_ctx); + return 0; + } + env->ReleaseByteArrayElements(jPassword, pswd, 0); + + auto ctx = (PairingContextNative *) malloc(sizeof(PairingContextNative)); + memset(ctx, 0, sizeof(PairingContextNative)); + ctx->spake2_ctx = spake2_ctx; + memcpy(ctx->key, key, SPAKE2_MAX_MSG_SIZE); + ctx->key_size = key_size; + return (jlong) ctx; +} + +static jbyteArray PairingContext_Msg(JNIEnv *env, jobject obj, jlong ptr) { + auto ctx = (PairingContextNative *) ptr; + jbyteArray our_msg = env->NewByteArray(ctx->key_size); + env->SetByteArrayRegion(our_msg, 0, ctx->key_size, (jbyte *) ctx->key); + return our_msg; +} + +static jboolean PairingContext_InitCipher(JNIEnv *env, jobject obj, jlong ptr, jbyteArray jTheirMsg) { + auto res = JNI_TRUE; + + auto ctx = (PairingContextNative *) ptr; + auto spake2_ctx = ctx->spake2_ctx; + auto their_msg_size = env->GetArrayLength(jTheirMsg); + + if (their_msg_size > SPAKE2_MAX_MSG_SIZE) { + LOGE("their_msg size [%d] greater then max size [%d].", their_msg_size, SPAKE2_MAX_MSG_SIZE); + return JNI_FALSE; + } + + auto their_msg = env->GetByteArrayElements(jTheirMsg, nullptr); + + size_t key_material_len = 0; + uint8_t key_material[SPAKE2_MAX_KEY_SIZE]; + int status = SPAKE2_process_msg(spake2_ctx, key_material, &key_material_len, + sizeof(key_material), (uint8_t *) their_msg, their_msg_size); + + env->ReleaseByteArrayElements(jTheirMsg, their_msg, 0); + + if (status != 1) { + LOGE("Unable to process their public key"); + return JNI_FALSE; + } + + // -------- + uint8_t key[kHkdfKeyLength]; + uint8_t info[] = "adb pairing_auth aes-128-gcm key"; + + status = HKDF(key, sizeof(key), EVP_sha256(), key_material, key_material_len, nullptr, 0, info, + sizeof(info) - 1); + if (status != 1) { + LOGE("HKDF"); + return JNI_FALSE; + } + + ctx->aes_ctx = EVP_AEAD_CTX_new(EVP_aead_aes_128_gcm(), key, sizeof(key), EVP_AEAD_DEFAULT_TAG_LENGTH); + + if (!ctx->aes_ctx) { + LOGE("EVP_AEAD_CTX_new"); + return JNI_FALSE; + } + + return res; +} + +static jbyteArray PairingContext_Encrypt(JNIEnv *env, jobject obj, jlong ptr, jbyteArray jIn) { + auto ctx = (PairingContextNative *) ptr; + auto aes_ctx = ctx->aes_ctx; + + auto in = env->GetByteArrayElements(jIn, nullptr); + auto in_size = env->GetArrayLength(jIn); + + auto out_size = (size_t) in_size + EVP_AEAD_max_overhead(EVP_AEAD_CTX_aead(ctx->aes_ctx)); + uint8_t out[out_size]; + + auto nonce_size = EVP_AEAD_nonce_length(EVP_AEAD_CTX_aead(aes_ctx)); + uint8_t nonce[nonce_size]; + memset(nonce, 0, nonce_size); + memcpy(nonce, &ctx->enc_sequence, sizeof(ctx->enc_sequence)); + + size_t written_sz; + int status = EVP_AEAD_CTX_seal(aes_ctx, out, &written_sz, out_size, nonce, nonce_size, (uint8_t *) in, in_size, nullptr, 0); + + env->ReleaseByteArrayElements(jIn, in, 0); + + if (!status) { + LOGE("Failed to encrypt (in_len=%d, out_len=%" PRIuPTR", out_len_needed=%d)", in_size, out_size, in_size); + return nullptr; + } + ++ctx->enc_sequence; + + jbyteArray jOut = env->NewByteArray(written_sz); + env->SetByteArrayRegion(jOut, 0, written_sz, (jbyte *) out); + return jOut; +} + +static jbyteArray PairingContext_Decrypt(JNIEnv *env, jobject obj, jlong ptr, jbyteArray jIn) { + auto ctx = (PairingContextNative *) ptr; + auto aes_ctx = ctx->aes_ctx; + + auto in = env->GetByteArrayElements(jIn, nullptr); + auto in_size = env->GetArrayLength(jIn); + + auto out_size = (size_t) in_size; + uint8_t out[out_size]; + + auto nonce_size = EVP_AEAD_nonce_length(EVP_AEAD_CTX_aead(aes_ctx)); + uint8_t nonce[nonce_size]; + memset(nonce, 0, nonce_size); + memcpy(nonce, &ctx->dec_sequence, sizeof(ctx->dec_sequence)); + + size_t written_sz; + int status = EVP_AEAD_CTX_open(aes_ctx, out, &written_sz, out_size, nonce, nonce_size, (uint8_t *) in, in_size, nullptr, 0); + + env->ReleaseByteArrayElements(jIn, in, 0); + + if (!status) { + LOGE("Failed to decrypt (in_len=%d, out_len=%" PRIuPTR", out_len_needed=%d)", in_size, out_size, in_size); + return nullptr; + } + ++ctx->dec_sequence; + + jbyteArray jOut = env->NewByteArray(written_sz); + env->SetByteArrayRegion(jOut, 0, written_sz, (jbyte *) out); + return jOut; +} + +static void PairingContext_Destroy(JNIEnv *env, jobject obj, jlong ptr) { + auto ctx = (PairingContextNative *) ptr; + SPAKE2_CTX_free(ctx->spake2_ctx); + if (ctx->aes_ctx) EVP_AEAD_CTX_free(ctx->aes_ctx); + free(ctx); +} + +// --------------------------------------------------------- + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { + JNIEnv *env = nullptr; + + if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) + return -1; + + JNINativeMethod methods_PairingContext[] = { + {"nativeConstructor", "(Z[B)J", (void *) PairingContext_Constructor}, + {"nativeMsg", "(J)[B", (void *) PairingContext_Msg}, + {"nativeInitCipher", "(J[B)Z", (void *) PairingContext_InitCipher}, + {"nativeEncrypt", "(J[B)[B", (void *) PairingContext_Encrypt}, + {"nativeDecrypt", "(J[B)[B", (void *) PairingContext_Decrypt}, + {"nativeDestroy", "(J)V", (void *) PairingContext_Destroy}, + }; + + env->RegisterNatives(env->FindClass("io/github/sds100/keymapper/sysbridge/adb/PairingContext"), + methods_PairingContext, + sizeof(methods_PairingContext) / sizeof(JNINativeMethod)); + + return JNI_VERSION_1_6; +} diff --git a/sysbridge/src/main/cpp/adb_pairing.h b/sysbridge/src/main/cpp/adb_pairing.h new file mode 100644 index 0000000000..3f9eb70a66 --- /dev/null +++ b/sysbridge/src/main/cpp/adb_pairing.h @@ -0,0 +1,4 @@ +#ifndef ADB_H +#define ADB_H + +#endif // ADB_H diff --git a/sysbridge/src/main/cpp/android.cpp b/sysbridge/src/main/cpp/android.cpp new file mode 100644 index 0000000000..a24006a45d --- /dev/null +++ b/sysbridge/src/main/cpp/android.cpp @@ -0,0 +1,31 @@ +#include +#include +#include +#include +#include +#include + +namespace android { + + int GetApiLevel() { + static int apiLevel = 0; + if (apiLevel > 0) return apiLevel; + + char buf[PROP_VALUE_MAX + 1]; + if (__system_property_get("ro.build.version.sdk", buf) > 0) + apiLevel = atoi(buf); + + return apiLevel; + } + + int GetPreviewApiLevel() { + static int previewApiLevel = 0; + if (previewApiLevel > 0) return previewApiLevel; + + char buf[PROP_VALUE_MAX + 1]; + if (__system_property_get("ro.build.version.preview_sdk", buf) > 0) + previewApiLevel = atoi(buf); + + return previewApiLevel; + } +} \ No newline at end of file diff --git a/sysbridge/src/main/cpp/android.h b/sysbridge/src/main/cpp/android.h new file mode 100644 index 0000000000..a4b78fba96 --- /dev/null +++ b/sysbridge/src/main/cpp/android.h @@ -0,0 +1,8 @@ +#pragma once + +namespace android { + + int GetApiLevel(); + + int GetPreviewApiLevel(); +} \ No newline at end of file diff --git a/sysbridge/src/main/cpp/android/ftl/algorithm.h b/sysbridge/src/main/cpp/android/ftl/algorithm.h new file mode 100644 index 0000000000..81993de177 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/algorithm.h @@ -0,0 +1,108 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include + +namespace android::ftl { + +// Determines if a container contains a value. This is a simplified version of the C++23 +// std::ranges::contains function. +// +// const ftl::StaticVector vector = {1, 2, 3}; +// assert(ftl::contains(vector, 1)); +// +// TODO: Remove in C++23. + template + auto contains(const Container &container, const Value &value) -> bool { + return std::find(container.begin(), container.end(), value) != container.end(); + } + +// Adapter for std::find_if that converts the return value from iterator to optional. +// +// const ftl::StaticVector vector = {"upside"sv, "down"sv, "cake"sv}; +// assert(ftl::find_if(vector, [](const auto& str) { return str.front() == 'c'; }) == "cake"sv); +// + template + constexpr auto find_if(const Container &container, Predicate &&predicate) + -> Optional> { + const auto it = std::find_if(std::cbegin(container), std::cend(container), + std::forward(predicate)); + if (it == std::cend(container)) return {}; + return std::cref(*it); + } + +// Transformers for ftl::find_if on a map-like `Container` that contains key-value pairs. +// +// const ftl::SmallMap map = ftl::init::map>( +// 12, "snow"sv, "cone"sv)(13, "tiramisu"sv)(14, "upside"sv, "down"sv, "cake"sv); +// +// using Map = decltype(map); +// +// assert(14 == ftl::find_if(map, [](const auto& pair) { +// return pair.second.size() == 3; +// }).transform(ftl::to_key)); +// +// const auto opt = ftl::find_if(map, [](const auto& pair) { +// return pair.second.size() == 1; +// }).transform(ftl::to_mapped_ref); +// +// assert(opt); +// assert(opt->get() == ftl::StaticVector("tiramisu"sv)); +// + template + constexpr auto to_key(const Pair &pair) -> Key { + return pair.first; + } + + template + constexpr auto to_mapped_ref(const Pair &pair) -> std::reference_wrapper { + return std::cref(pair.second); + } + +// Combinator for ftl::Optional::or_else when T is std::reference_wrapper. Given a +// lambda argument that returns a `constexpr` value, ftl::static_ref binds a reference to a +// static T initialized to that constant. +// +// const ftl::SmallMap map = ftl::init::map(13, "tiramisu"sv)(14, "upside-down cake"sv); +// assert("???"sv == +// map.get(20).or_else(ftl::static_ref([] { return "???"sv; }))->get()); +// +// using Map = decltype(map); +// +// assert("snow cone"sv == +// ftl::find_if(map, [](const auto& pair) { return pair.second.front() == 's'; }) +// .transform(ftl::to_mapped_ref) +// .or_else(ftl::static_ref([] { return "snow cone"sv; })) +// ->get()); +// + template + constexpr auto static_ref(F &&f) { + return [f = std::forward(f)] { + constexpr auto kInitializer = f(); + static const T kValue = kInitializer; + return Optional(std::cref(kValue)); + }; + } + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/cast.h b/sysbridge/src/main/cpp/android/ftl/cast.h new file mode 100644 index 0000000000..64129a1a58 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/cast.h @@ -0,0 +1,86 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include + +namespace android::ftl { + + enum class CastSafety { + kSafe, kUnderflow, kOverflow + }; + +// Returns whether static_cast(v) is safe, or would result in underflow or overflow. +// +// static_assert(ftl::cast_safety(-1) == ftl::CastSafety::kUnderflow); +// static_assert(ftl::cast_safety(128u) == ftl::CastSafety::kOverflow); +// +// static_assert(ftl::cast_safety(-.1f) == ftl::CastSafety::kUnderflow); +// static_assert(ftl::cast_safety(static_cast(INT32_MAX)) == +// ftl::CastSafety::kOverflow); +// +// static_assert(ftl::cast_safety(-DBL_MAX) == ftl::CastSafety::kUnderflow); +// + template + constexpr CastSafety cast_safety(T v) { + static_assert(std::is_arithmetic_v); + static_assert(std::is_arithmetic_v); + + constexpr bool kFromSigned = std::is_signed_v; + constexpr bool kToSigned = std::is_signed_v; + + using details::max_exponent; + + // If the R range contains the T range, then casting is always safe. + if constexpr ((kFromSigned == kToSigned && max_exponent >= max_exponent) || + (!kFromSigned && kToSigned && max_exponent > max_exponent)) { + return CastSafety::kSafe; + } + + using C = std::common_type_t; + + if constexpr (kFromSigned) { + using L = details::safe_limits; + + if constexpr (kToSigned) { + // Signed to signed. + if (v < L::lowest()) return CastSafety::kUnderflow; + return v <= L::max() ? CastSafety::kSafe : CastSafety::kOverflow; + } else { + // Signed to unsigned. + if (v < 0) return CastSafety::kUnderflow; + return static_cast(v) <= static_cast(L::max()) ? CastSafety::kSafe + : CastSafety::kOverflow; + } + } else { + using L = std::numeric_limits; + + if constexpr (kToSigned) { + // Unsigned to signed. + return static_cast(v) <= static_cast(L::max()) ? CastSafety::kSafe + : CastSafety::kOverflow; + } else { + // Unsigned to unsigned. + return v <= L::max() ? CastSafety::kSafe : CastSafety::kOverflow; + } + } + } + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/concat.h b/sysbridge/src/main/cpp/android/ftl/concat.h new file mode 100644 index 0000000000..b826a2e858 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/concat.h @@ -0,0 +1,90 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace android::ftl { + +// Lightweight (not allocating nor sprintf-based) concatenation. The variadic arguments can be +// values of integral type (including bool and char), string literals, or strings whose length +// is constrained: +// +// std::string_view name = "Volume"; +// ftl::Concat string(ftl::truncated<3>(name), ": ", -3, " dB"); +// +// assert(string.str() == "Vol: -3 dB"); +// assert(string.c_str()[string.size()] == '\0'); +// + template + struct Concat; + + template + struct Concat : Concat::N, Ts...> { + explicit constexpr Concat(T v, Ts... args) { append(v, args...); } + + protected: + constexpr Concat() = default; + + constexpr void append(T v, Ts... args) { + using Str = details::StaticString; + const Str str(v); + + // TODO: Replace with constexpr std::copy in C++20. + for (auto it = str.view.begin(); it != str.view.end();) { + *this->end_++ = *it++; + } + + using Base = Concat; + this->Base::append(args...); + } + }; + + template + struct Concat { + static constexpr std::size_t max_size() { return N; } + + constexpr std::size_t size() const { return static_cast(end_ - buffer_); } + + constexpr const char *c_str() const { return buffer_; } + + constexpr std::string_view str() const { + // TODO: Replace with {buffer_, end_} in C++20. + return {buffer_, size()}; + } + + protected: + constexpr Concat() : end_(buffer_) {} + + constexpr Concat(const Concat &) = delete; + + constexpr void append() { *end_ = '\0'; } + + char buffer_[N + 1]; + char *end_; + }; + +// Deduction guide. + template + Concat(Ts &&...) -> Concat<0, Ts...>; + + template + constexpr auto truncated(std::string_view v) { + return details::Truncated{v}; + } + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/details/array_traits.h b/sysbridge/src/main/cpp/android/ftl/details/array_traits.h new file mode 100644 index 0000000000..f66742598c --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/details/array_traits.h @@ -0,0 +1,181 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#define FTL_ARRAY_TRAIT(T, U) using U = typename details::ArrayTraits::U + +namespace android::ftl::details { + + template + struct ArrayTraits { + using value_type = T; + using size_type = std::size_t; + using difference_type = std::ptrdiff_t; + + using pointer = value_type *; + using reference = value_type &; + using iterator = pointer; + using reverse_iterator = std::reverse_iterator; + + using const_pointer = const value_type *; + using const_reference = const value_type &; + using const_iterator = const_pointer; + using const_reverse_iterator = std::reverse_iterator; + + template + static constexpr pointer construct_at(const_iterator it, Args &&... args) { + void *const ptr = const_cast(static_cast(it)); + if constexpr (std::is_constructible_v) { + // TODO: Replace with std::construct_at in C++20. + return new(ptr) value_type(std::forward(args)...); + } else { + // Fall back to list initialization. + return new(ptr) value_type{std::forward(args)...}; + } + } + + // TODO: Make constexpr in C++20. + template + static reference replace_at(const_iterator it, Args &&... args) { + value_type element{std::forward(args)...}; + return replace_at(it, std::move(element)); + } + + // TODO: Make constexpr in C++20. + static reference replace_at(const_iterator it, value_type &&value) { + std::destroy_at(it); + // This is only safe because exceptions are disabled. + return *construct_at(it, std::move(value)); + } + + // TODO: Make constexpr in C++20. + static void in_place_swap(reference a, reference b) { + value_type c{std::move(a)}; + replace_at(&a, std::move(b)); + replace_at(&b, std::move(c)); + } + + // TODO: Make constexpr in C++20. + static void in_place_swap_ranges(iterator first1, iterator last1, iterator first2) { + while (first1 != last1) { + in_place_swap(*first1++, *first2++); + } + } + + // TODO: Replace with std::uninitialized_copy in C++20. + template + static void uninitialized_copy(Iterator first, Iterator last, const_iterator out) { + while (first != last) { + construct_at(out++, *first++); + } + } + }; + +// CRTP mixin to define iterator functions in terms of non-const Self::begin and Self::end. + template + class ArrayIterators { + FTL_ARRAY_TRAIT(T, size_type); + + FTL_ARRAY_TRAIT(T, reference); + FTL_ARRAY_TRAIT(T, iterator); + FTL_ARRAY_TRAIT(T, reverse_iterator); + + FTL_ARRAY_TRAIT(T, const_reference); + FTL_ARRAY_TRAIT(T, const_iterator); + FTL_ARRAY_TRAIT(T, const_reverse_iterator); + + Self &self() const { return *const_cast(static_cast(this)); } + + public: + const_iterator begin() const { return cbegin(); } + + const_iterator cbegin() const { return self().begin(); } + + const_iterator end() const { return cend(); } + + const_iterator cend() const { return self().end(); } + + reverse_iterator rbegin() { return std::make_reverse_iterator(self().end()); } + + const_reverse_iterator rbegin() const { return crbegin(); } + + const_reverse_iterator crbegin() const { return self().rbegin(); } + + reverse_iterator rend() { return std::make_reverse_iterator(self().begin()); } + + const_reverse_iterator rend() const { return crend(); } + + const_reverse_iterator crend() const { return self().rend(); } + + iterator last() { return self().end() - 1; } + + const_iterator last() const { return self().last(); } + + reference front() { return *self().begin(); } + + const_reference front() const { return self().front(); } + + reference back() { return *last(); } + + const_reference back() const { return self().back(); } + + reference operator[](size_type i) { return *(self().begin() + i); } + + const_reference operator[](size_type i) const { return self()[i]; } + }; + +// Mixin to define comparison operators for an array-like template. +// TODO: Replace with operator<=> in C++20. + template class Array> + struct ArrayComparators { + template + friend bool operator==(const Array &lhs, const Array &rhs) { + return lhs.size() == rhs.size() && std::equal(lhs.begin(), lhs.end(), rhs.begin()); + } + + template + friend bool operator<(const Array &lhs, const Array &rhs) { + return std::lexicographical_compare(lhs.begin(), lhs.end(), rhs.begin(), rhs.end()); + } + + template + friend bool operator>(const Array &lhs, const Array &rhs) { + return rhs < lhs; + } + + template + friend bool operator!=(const Array &lhs, const Array &rhs) { + return !(lhs == rhs); + } + + template + friend bool operator>=(const Array &lhs, const Array &rhs) { + return !(lhs < rhs); + } + + template + friend bool operator<=(const Array &lhs, const Array &rhs) { + return !(lhs > rhs); + } + }; + +} // namespace android::ftl::details diff --git a/sysbridge/src/main/cpp/android/ftl/details/cast.h b/sysbridge/src/main/cpp/android/ftl/details/cast.h new file mode 100644 index 0000000000..8196ba61a7 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/details/cast.h @@ -0,0 +1,57 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +namespace android::ftl::details { + +// Exponent whose power of 2 is the (exclusive) upper bound of T. + template> + constexpr int max_exponent = std::is_floating_point_v ? L::max_exponent : L::digits; + +// Extension of std::numeric_limits that reduces the maximum for integral types T such that it +// has an exact representation for floating-point types F. For example, the maximum int32_t value +// is 2'147'483'647, but casting it to float commonly rounds up to 2'147'483'650.f, which cannot +// be safely converted back lest the signed overflow invokes undefined behavior. This pitfall is +// avoided by clearing the lower (31 - 24 =) 7 bits of precision to 2'147'483'520. Note that the +// minimum is representable. + template + struct safe_limits : std::numeric_limits { + static constexpr T max() { + using Base = std::numeric_limits; + + if constexpr (std::is_integral_v && std::is_floating_point_v) { + // Assume the mantissa is 24 bits for float, or 53 bits for double. + using Float = std::numeric_limits; + static_assert(Float::is_iec559); + + // If the integer is wider than the mantissa, clear the excess bits of precision. + constexpr int kShift = Base::digits - Float::digits; + if constexpr (kShift > 0) { + using U = std::make_unsigned_t; + constexpr U kOne = static_cast(1); + return static_cast(Base::max()) & ~((kOne << kShift) - kOne); + } + } + + return Base::max(); + } + }; + +} // namespace android::ftl::details diff --git a/sysbridge/src/main/cpp/android/ftl/details/concat.h b/sysbridge/src/main/cpp/android/ftl/details/concat.h new file mode 100644 index 0000000000..c1462a658c --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/details/concat.h @@ -0,0 +1,91 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include +#include + +namespace android::ftl::details { + + template + struct StaticString; + +// Booleans. + template + struct StaticString>> { + static constexpr std::size_t N = 5; // Length of "false". + + explicit constexpr StaticString(bool b) : view(b ? "true" : "false") {} + + const std::string_view view; + }; + +// Characters. + template + struct StaticString>> { + static constexpr std::size_t N = 1; + + explicit constexpr StaticString(char c) : character(c) {} + + const char character; + const std::string_view view{&character, 1u}; + }; + +// Integers, including the integer value of other character types like char32_t. + template + struct StaticString< + T, std::enable_if_t< + std::is_integral_v> && !is_bool_v && !is_char_v>> { + using U = remove_cvref_t; + static constexpr std::size_t N = to_chars_length_v; + + // TODO: Mark this and to_chars as `constexpr` in C++23. + explicit StaticString(U v) : view(to_chars(buffer, v)) {} + + to_chars_buffer_t buffer; + const std::string_view view; + }; + +// Character arrays. + template + struct StaticString { + static constexpr std::size_t N = M - 1; + + explicit constexpr StaticString(const char (&str)[M]) : view(str, N) {} + + const std::string_view view; + }; + + template + struct Truncated { + std::string_view view; + }; + +// Strings with constrained length. + template + struct StaticString, void> { + static constexpr std::size_t N = M; + + explicit constexpr StaticString(Truncated str) : view(str.view.substr(0, N)) {} + + const std::string_view view; + }; + +} // namespace android::ftl::details diff --git a/sysbridge/src/main/cpp/android/ftl/details/function.h b/sysbridge/src/main/cpp/android/ftl/details/function.h new file mode 100644 index 0000000000..8d7fc9bd58 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/details/function.h @@ -0,0 +1,137 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace android::ftl::details { + +// The maximum allowed value for the template argument `N` in +// `ftl::Function`. + constexpr size_t kFunctionMaximumN = 14; + +// Converts a member function pointer type `Ret(Class::*)(Args...)` to an equivalent non-member +// function type `Ret(Args...)`. + + template + struct remove_member_function_pointer; + + template + struct remove_member_function_pointer { + using type = Ret(Args...); + }; + + template + struct remove_member_function_pointer { + using type = Ret(Args...); + }; + + template + using remove_member_function_pointer_t = + typename remove_member_function_pointer::type; + +// Helper functions for binding to the supported targets. + + template + auto bind_opaque_no_op() -> Ret (*)(void *, Args...) { + return [](void *, Args...) -> Ret { + if constexpr (!std::is_void_v) { + return Ret{}; + } + }; + } + + template + auto bind_opaque_function_object(const F &) -> Ret (*)(void *, Args...) { + return [](void *opaque, Args... args) -> Ret { + return std::invoke(*static_cast(opaque), std::forward(args)...); + }; + } + + template + auto bind_member_function(Class *instance, Ret (*)(Args...) = nullptr) { + return [instance](Args... args) -> Ret { + return std::invoke(MemberFunction, instance, std::forward(args)...); + }; + } + + template + auto bind_free_function(Ret (*)(Args...) = nullptr) { + return [](Args... args) -> Ret { + return std::invoke(FreeFunction, std::forward(args)...); + }; + } + +// Traits class for the opaque storage used by Function. + + template + struct function_opaque_storage { + // The actual type used for the opaque storage. An `N` of zero specifies the minimum useful size, + // which allows a lambda with zero or one capture args. + using type = std::array; + + template + static constexpr bool require_trivially_copyable = std::is_trivially_copyable_v; + + template + static constexpr bool require_trivially_destructible = std::is_trivially_destructible_v; + + template + static constexpr bool require_will_fit_in_opaque_storage = sizeof(S) <= sizeof(type); + + template + static constexpr bool require_alignment_compatible = + std::alignment_of_v <= std::alignment_of_v; + + // Copies `src` into the opaque storage, and returns that storage. + template + static type opaque_copy(const S &src) { + // TODO: Replace with C++20 concepts/constraints which can give more details. + static_assert(require_trivially_copyable, + "ftl::Function can only store lambdas that capture trivially copyable data."); + static_assert( + require_trivially_destructible, + "ftl::Function can only store lambdas that capture trivially destructible data."); + static_assert(require_will_fit_in_opaque_storage, + "ftl::Function has limited storage for lambda captured state. Maybe you need to " + "increase N?"); + static_assert(require_alignment_compatible); + + type opaque; + std::memcpy(opaque.data(), &src, sizeof(S)); + return opaque; + } + }; + +// Traits class to help determine the template parameters to use for a ftl::Function, given a +// function object. + + template + struct function_traits { + // The function type `F` with which to instantiate the `Function` template. + using type = remove_member_function_pointer_t<&F::operator()>; + + // The (minimum) size `N` with which to instantiate the `Function` template. + static constexpr std::size_t size = + (std::max(sizeof(std::intptr_t), sizeof(F)) - 1) / sizeof(std::intptr_t); + }; + +} // namespace android::ftl::details diff --git a/sysbridge/src/main/cpp/android/ftl/details/future.h b/sysbridge/src/main/cpp/android/ftl/details/future.h new file mode 100644 index 0000000000..ec3926ba8c --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/details/future.h @@ -0,0 +1,119 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +namespace android::ftl { + + template class> + class Future; + + namespace details { + + template + struct future_result { + using type = T; + }; + + template + struct future_result> { + using type = T; + }; + + template + struct future_result> { + using type = T; +}; + +template class FutureImpl> +struct future_result> { +using type = T; +}; + +template +using future_result_t = typename future_result::type; + +struct ValueTag { +}; + +template class> +class BaseFuture; + +template +class BaseFuture { + using Impl = std::future; + +public: + Future share() { + if (T *value = std::get_if(&self())) { + return {ValueTag{}, std::move(*value)}; + } + + return std::get(self()).share(); + } + +protected: + T get() { + if (T *value = std::get_if(&self())) { + return std::move(*value); + } + + return std::get(self()).get(); + } + + template + std::future_status wait_for(const std::chrono::duration &timeout_duration) const { + if (std::holds_alternative(self())) { + return std::future_status::ready; + } + + return std::get(self()).wait_for(timeout_duration); + } + +private: + auto &self() { return static_cast(*this).future_; } + + const auto &self() const { return static_cast(*this).future_; } +}; + +template +class BaseFuture { + using Impl = std::shared_future; + +protected: + const T &get() const { + if (const T *value = std::get_if(&self())) { + return *value; + } + + return std::get(self()).get(); + } + + template + std::future_status wait_for(const std::chrono::duration &timeout_duration) const { + if (std::holds_alternative(self())) { + return std::future_status::ready; + } + + return std::get(self()).wait_for(timeout_duration); + } + +private: + const auto &self() const { return static_cast(*this).future_; } +}; + +} // namespace details +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/details/hash.h b/sysbridge/src/main/cpp/android/ftl/details/hash.h new file mode 100644 index 0000000000..cb9939036a --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/details/hash.h @@ -0,0 +1,125 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +namespace android::ftl::details { + +// Based on CityHash64 v1.0.1 (http://code.google.com/p/cityhash/), but slightly +// modernized and trimmed for cases with bounded lengths. + + template + inline T read_unaligned(const void *ptr) { + T v; + std::memcpy(&v, ptr, sizeof(T)); + return v; + } + + template + constexpr std::uint64_t rotate(std::uint64_t v, std::uint8_t shift) { + if constexpr (!NonZeroShift) { + if (shift == 0) return v; + } + return (v >> shift) | (v << (64 - shift)); + } + + constexpr std::uint64_t shift_mix(std::uint64_t v) { + return v ^ (v >> 47); + } + + __attribute__((no_sanitize("unsigned-integer-overflow"))) + constexpr std::uint64_t hash_length_16(std::uint64_t u, std::uint64_t v) { + constexpr std::uint64_t kPrime = 0x9ddfea08eb382d69ull; + auto a = (u ^ v) * kPrime; + a ^= (a >> 47); + auto b = (v ^ a) * kPrime; + b ^= (b >> 47); + b *= kPrime; + return b; + } + + constexpr std::uint64_t kPrime0 = 0xc3a5c85c97cb3127ull; + constexpr std::uint64_t kPrime1 = 0xb492b66fbe98f273ull; + constexpr std::uint64_t kPrime2 = 0x9ae16a3b2f90404full; + constexpr std::uint64_t kPrime3 = 0xc949d7c7509e6557ull; + + __attribute__((no_sanitize("unsigned-integer-overflow"))) + inline std::uint64_t hash_length_0_to_16(const char *str, std::uint64_t length) { + if (length > 8) { + const auto a = read_unaligned(str); + const auto b = read_unaligned(str + length - 8); + return hash_length_16(a, rotate(b + length, static_cast(length))) ^ + b; + } + if (length >= 4) { + const auto a = read_unaligned(str); + const auto b = read_unaligned(str + length - 4); + return hash_length_16(length + (a << 3), b); + } + if (length > 0) { + const auto a = static_cast(str[0]); + const auto b = static_cast(str[length >> 1]); + const auto c = static_cast(str[length - 1]); + const auto y = static_cast(a) + (static_cast(b) << 8); + const auto z = + static_cast(length) + (static_cast(c) << 2); + return shift_mix(y * kPrime2 ^ z * kPrime3) * kPrime2; + } + return kPrime2; + } + + __attribute__((no_sanitize("unsigned-integer-overflow"))) + inline std::uint64_t hash_length_17_to_32(const char *str, std::uint64_t length) { + const auto a = read_unaligned(str) * kPrime1; + const auto b = read_unaligned(str + 8); + const auto c = read_unaligned(str + length - 8) * kPrime2; + const auto d = read_unaligned(str + length - 16) * kPrime0; + return hash_length_16(rotate(a - b, 43) + rotate(c, 30) + d, + a + rotate(b ^ kPrime3, 20) - c + length); + } + + __attribute__((no_sanitize("unsigned-integer-overflow"))) + inline std::uint64_t hash_length_33_to_64(const char *str, std::uint64_t length) { + auto z = read_unaligned(str + 24); + auto a = read_unaligned(str) + (length + read_unaligned(str + length - 16)) * kPrime0; + auto b = rotate(a + z, 52); + auto c = rotate(a, 37); + + a += read_unaligned(str + 8); + c += rotate(a, 7); + a += read_unaligned(str + 16); + + const auto vf = a + z; + const auto vs = b + rotate(a, 31) + c; + + a = read_unaligned(str + 16) + read_unaligned(str + length - 32); + z += read_unaligned(str + length - 8); + b = rotate(a + z, 52); + c = rotate(a, 37); + a += read_unaligned(str + length - 24); + c += rotate(a, 7); + a += read_unaligned(str + length - 16); + + const auto wf = a + z; + const auto ws = b + rotate(a, 31) + c; + const auto r = shift_mix((vf + ws) * kPrime2 + (wf + vs) * kPrime0); + return shift_mix(r * kPrime0 + vs) * kPrime2; + } + +} // namespace android::ftl::details diff --git a/sysbridge/src/main/cpp/android/ftl/details/match.h b/sysbridge/src/main/cpp/android/ftl/details/match.h new file mode 100644 index 0000000000..cee77c83ee --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/details/match.h @@ -0,0 +1,59 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +namespace android::ftl::details { + + template + struct Matcher : Ms ... { + using Ms::operator()...; + }; + +// Deduction guide. + template + Matcher(Ms...) -> Matcher; + + template + constexpr bool is_exhaustive_match_v = (std::is_invocable_v && ...); + + template + struct Match; + + template + struct Match { + template + static decltype(auto) match(Variant &variant, const Matcher &matcher) { + if (auto *const ptr = std::get_if(&variant)) { + return matcher(*ptr); + } else { + return Match::match(variant, matcher); + } + } + }; + + template + struct Match { + template + static decltype(auto) match(Variant &variant, const Matcher &matcher) { + return matcher(std::get(variant)); + } + }; + +} // namespace android::ftl::details diff --git a/sysbridge/src/main/cpp/android/ftl/details/mixins.h b/sysbridge/src/main/cpp/android/ftl/details/mixins.h new file mode 100644 index 0000000000..a52d74c504 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/details/mixins.h @@ -0,0 +1,31 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +namespace android::ftl::details { + + template class> + class Mixin { + protected: + constexpr Self &self() { return *static_cast(this); } + + constexpr const Self &self() const { return *static_cast(this); } + + constexpr auto &mut() { return self().value_; } + }; + +} // namespace android::ftl::details diff --git a/sysbridge/src/main/cpp/android/ftl/details/optional.h b/sysbridge/src/main/cpp/android/ftl/details/optional.h new file mode 100644 index 0000000000..bcbd8826c0 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/details/optional.h @@ -0,0 +1,72 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include + +namespace android::ftl { + + template + struct Optional; + + namespace details { + + template + struct is_optional : std::false_type { + }; + + template + struct is_optional> : std::true_type { + }; + + template + struct is_optional> : std::true_type { + }; + + template + struct transform_result { + using type = Optional>>; + }; + + template + using transform_result_t = typename transform_result::type; + + template + struct and_then_result { + using type = remove_cvref_t>; + static_assert(is_optional{}, "and_then function must return an optional"); + }; + + template + using and_then_result_t = typename and_then_result::type; + + template + struct or_else_result { + using type = remove_cvref_t>; + static_assert( + std::is_same_v> || std::is_same_v>, + "or_else function must return an optional T"); + }; + + template + using or_else_result_t = typename or_else_result::type; + + } // namespace details +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/details/type_traits.h b/sysbridge/src/main/cpp/android/ftl/details/type_traits.h new file mode 100644 index 0000000000..70a602c330 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/details/type_traits.h @@ -0,0 +1,33 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace android::ftl::details { + +// TODO: Replace with std::remove_cvref_t in C++20. + template + using remove_cvref_t = std::remove_cv_t>; + + template + constexpr bool is_bool_v = std::is_same_v, bool>; + + template + constexpr bool is_char_v = std::is_same_v, char>; + +} // namespace android::ftl::details diff --git a/sysbridge/src/main/cpp/android/ftl/enum.h b/sysbridge/src/main/cpp/android/ftl/enum.h new file mode 100644 index 0000000000..e31c0a1322 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/enum.h @@ -0,0 +1,369 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +// Returns the name of enumerator E::V and optionally the class (i.e. "E::V" or "V") as +// std::optional by parsing the compiler-generated string literal for the +// signature of this function. The function is defined in the global namespace with a short name +// and inferred return type to reduce bloat in the read-only data segment. +template +constexpr auto ftl_enum_builder() { + static_assert(std::is_enum_v); + + using R = std::optional; + using namespace std::literals; + + // The "pretty" signature has the following format: + // + // auto ftl_enum() [E = android::test::Enum, V = android::test::Enum::kValue] + // + std::string_view view = __PRETTY_FUNCTION__; + const auto template_begin = view.rfind('['); + const auto template_end = view.rfind(']'); + if (template_begin == view.npos || template_end == view.npos) return R{}; + + // Extract the template parameters without the enclosing brackets. Example (cont'd): + // + // E = android::test::Enum, V = android::test::Enum::kValue + // + view = view.substr(template_begin + 1, template_end - template_begin - 1); + const auto value_begin = view.rfind("V = "sv); + if (value_begin == view.npos) return R{}; + + // Example (cont'd): + // + // V = android::test::Enum::kValue + // + view = view.substr(value_begin); + const auto pos = S ? view.rfind("::"sv) - 2 : view.npos; + + const auto name_begin = view.rfind("::"sv, pos); + if (name_begin == view.npos) return R{}; + + // Chop off the leading "::". + const auto name = view.substr(name_begin + 2); + + // A value that is not enumerated has the format "Enum)42". + return name.find(')') == view.npos ? R{name} : R{}; +} + +// Returns the name of enumerator E::V (i.e. "V") as std::optional +template +constexpr auto ftl_enum() { + return ftl_enum_builder(); +} + +// Returns the name of enumerator and class E::V (i.e. "E::V") as std::optional +template +constexpr auto ftl_enum_full() { + return ftl_enum_builder(); +} + +namespace android::ftl { + +// Trait for determining whether a type is specifically a scoped enum or not. By definition, a +// scoped enum is one that is not implicitly convertible to its underlying type. +// +// TODO: Replace with std::is_scoped_enum in C++23. +// + template> + struct is_scoped_enum : std::false_type { + }; + + template + struct is_scoped_enum + : std::negation>> { + }; + + template + inline constexpr bool is_scoped_enum_v = is_scoped_enum::value; + +// Shorthand for casting an enumerator to its integral value. +// +// TODO: Replace with std::to_underlying in C++23. +// +// enum class E { A, B, C }; +// static_assert(ftl::to_underlying(E::B) == 1); +// + template>> + constexpr auto to_underlying(E v) { + return static_cast>(v); + } + +// Traits for retrieving an enum's range. An enum specifies its range by defining enumerators named +// ftl_first and ftl_last. If omitted, ftl_first defaults to 0, whereas ftl_last defaults to N - 1 +// where N is the bit width of the underlying type, but only if that type is unsigned, assuming the +// enumerators are flags. Also, note that unscoped enums must define both bounds, as casting out-of- +// range values results in undefined behavior if the underlying type is not fixed. +// +// enum class E { A, B, C, F = 5, ftl_last = F }; +// +// static_assert(ftl::enum_begin_v == E::A); +// static_assert(ftl::enum_last_v == E::F); +// static_assert(ftl::enum_size_v == 6); +// +// enum class F : std::uint16_t { X = 0b1, Y = 0b10, Z = 0b100 }; +// +// static_assert(ftl::enum_begin_v == F{0}); +// static_assert(ftl::enum_last_v == F{15}); +// static_assert(ftl::enum_size_v == 16); +// + template + struct enum_begin { + static_assert(is_scoped_enum_v, "Missing ftl_first enumerator"); + static constexpr E value{0}; + }; + + template + struct enum_begin> { + static constexpr E value = E::ftl_first; + }; + + template + inline constexpr E enum_begin_v = enum_begin::value; + + template + struct enum_end { + using U = std::underlying_type_t; + static_assert(is_scoped_enum_v && std::is_unsigned_v, "Missing ftl_last enumerator"); + + static constexpr E value{std::numeric_limits::digits}; + }; + + template + struct enum_end> { + static constexpr E value = E{to_underlying(E::ftl_last) + 1}; + }; + + template + inline constexpr E enum_end_v = enum_end::value; + + template + inline constexpr E enum_last_v = E{to_underlying(enum_end_v) - 1}; + + template + struct enum_size { + static constexpr auto kBegin = to_underlying(enum_begin_v); + static constexpr auto kEnd = to_underlying(enum_end_v); + static_assert(kBegin < kEnd, "Invalid range"); + + static constexpr std::size_t value = kEnd - kBegin; + static_assert(value <= 64, "Excessive range size"); + }; + + template + inline constexpr std::size_t enum_size_v = enum_size::value; + + namespace details { + + template + struct Identity { + static constexpr auto value = V; + }; + + template + using make_enum_sequence = std::make_integer_sequence, enum_size_v>; + + template class = Identity, typename = make_enum_sequence> + struct EnumRange; + + template class F, typename T, T... Vs> + struct EnumRange> { + static constexpr auto kBegin = to_underlying(enum_begin_v); + static constexpr auto kSize = enum_size_v; + + using R = decltype(F::value); + const R values[kSize] = {F(Vs + kBegin)>::value...}; + + constexpr const auto *begin() const { return values; } + + constexpr const auto *end() const { return values + kSize; } + }; + + template + struct EnumName { + static constexpr auto value = ftl_enum(); + }; + + template + struct EnumNameFull { + static constexpr auto value = ftl_enum_full(); + }; + + template + struct FlagName { + using E = decltype(I); + using U = std::underlying_type_t; + + static constexpr E V{U{1} << to_underlying(I)}; + static constexpr auto value = ftl_enum(); + }; + + } // namespace details + +// Returns an iterable over the range of an enum. +// +// enum class E { A, B, C, F = 5, ftl_last = F }; +// +// std::string string; +// for (E v : ftl::enum_range()) { +// string += ftl::enum_name(v).value_or("?"); +// } +// +// assert(string == "ABC??F"); +// + template + constexpr auto enum_range() { + return details::EnumRange{}; + } + +// Returns a stringified enumerator at compile time. +// +// enum class E { A, B, C }; +// static_assert(ftl::enum_name() == "B"); +// + template + constexpr std::string_view enum_name() { + constexpr auto kName = ftl_enum(); + static_assert(kName, "Unknown enumerator"); + return *kName; + } + +// Returns a stringified enumerator with class at compile time. +// +// enum class E { A, B, C }; +// static_assert(ftl::enum_name() == "E::B"); +// + template + constexpr std::string_view enum_name_full() { + constexpr auto kName = ftl_enum_full(); + static_assert(kName, "Unknown enumerator"); + return *kName; + } + +// Returns a stringified enumerator, possibly at compile time. +// +// enum class E { A, B, C, F = 5, ftl_last = F }; +// +// static_assert(ftl::enum_name(E::C).value_or("?") == "C"); +// static_assert(ftl::enum_name(E{3}).value_or("?") == "?"); +// + template + constexpr std::optional enum_name(E v) { + const auto value = to_underlying(v); + + constexpr auto kBegin = to_underlying(enum_begin_v); + constexpr auto kLast = to_underlying(enum_last_v); + if (value < kBegin || value > kLast) return {}; + + constexpr auto kRange = details::EnumRange{}; + return kRange.values[value - kBegin]; + } + +// Returns a stringified enumerator with class, possibly at compile time. +// +// enum class E { A, B, C, F = 5, ftl_last = F }; +// +// static_assert(ftl::enum_name(E::C).value_or("?") == "E::C"); +// static_assert(ftl::enum_name(E{3}).value_or("?") == "?"); +// + template + constexpr std::optional enum_name_full(E v) { + const auto value = to_underlying(v); + + constexpr auto kBegin = to_underlying(enum_begin_v); + constexpr auto kLast = to_underlying(enum_last_v); + if (value < kBegin || value > kLast) return {}; + + constexpr auto kRange = details::EnumRange{}; + return kRange.values[value - kBegin]; + } + +// Returns a stringified flag enumerator, possibly at compile time. +// +// enum class F : std::uint16_t { X = 0b1, Y = 0b10, Z = 0b100 }; +// +// static_assert(ftl::flag_name(F::Z).value_or("?") == "Z"); +// static_assert(ftl::flag_name(F{0b111}).value_or("?") == "?"); +// + template + constexpr std::optional flag_name(E v) { + const auto value = to_underlying(v); + + // TODO: Replace with std::popcount and std::countr_zero in C++20. + if (__builtin_popcountll(value) != 1) return {}; + + constexpr auto kRange = details::EnumRange{}; + return kRange.values[__builtin_ctzll(value)]; + } + +// Returns a stringified enumerator, or its integral value if not named. +// +// enum class E { A, B, C, F = 5, ftl_last = F }; +// +// assert(ftl::enum_string(E::C) == "C"); +// assert(ftl::enum_string(E{3}) == "3"); +// + template + inline std::string enum_string(E v) { + if (const auto name = enum_name(v)) { + return std::string(*name); + } + return to_string(to_underlying(v)); + } + +// Returns a stringified enumerator with class, or its integral value if not named. +// +// enum class E { A, B, C, F = 5, ftl_last = F }; +// +// assert(ftl::enum_string(E::C) == "E::C"); +// assert(ftl::enum_string(E{3}) == "3"); +// + template + inline std::string enum_string_full(E v) { + if (const auto name = enum_name_full(v)) { + return std::string(*name); + } + return to_string(to_underlying(v)); + } + +// Returns a stringified flag enumerator, or its integral value if not named. +// +// enum class F : std::uint16_t { X = 0b1, Y = 0b10, Z = 0b100 }; +// +// assert(ftl::flag_string(F::Z) == "Z"); +// assert(ftl::flag_string(F{7}) == "0b111"); +// + template + inline std::string flag_string(E v) { + if (const auto name = flag_name(v)) { + return std::string(*name); + } + constexpr auto radix = sizeof(E) == 1 ? Radix::kBin : Radix::kHex; + return to_string(to_underlying(v), radix); + } + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/expected.h b/sysbridge/src/main/cpp/android/ftl/expected.h new file mode 100644 index 0000000000..5ed362e536 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/expected.h @@ -0,0 +1,144 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include "../libbase/expected.h" +#include +#include + +#include + +// Given an expression `expr` that evaluates to an ftl::Expected result (R for short), FTL_TRY +// unwraps T out of R, or bails out of the enclosing function F if R has an error E. The return type +// of F must be R, since FTL_TRY propagates R in the error case. As a special case, ftl::Unit may be +// used as the error E to allow FTL_TRY expressions when F returns `void`. +// +// The non-standard syntax requires `-Wno-gnu-statement-expression-from-macro-expansion` to compile. +// The UnitToVoid conversion allows the macro to be used for early exit from a function that returns +// `void`. +// +// Example usage: +// +// using StringExp = ftl::Expected; +// +// StringExp repeat(StringExp exp) { +// const std::string str = FTL_TRY(exp); +// return StringExp(str + str); +// } +// +// assert(StringExp("haha"s) == repeat(StringExp("ha"s))); +// assert(repeat(ftl::Unexpected(std::errc::bad_message)).has_error([](std::errc e) { +// return e == std::errc::bad_message; +// })); +// +// +// FTL_TRY may be used in void-returning functions by using ftl::Unit as the error type: +// +// void uppercase(char& c, ftl::Optional opt) { +// c = std::toupper(FTL_TRY(std::move(opt).ok_or(ftl::Unit()))); +// } +// +// char c = '?'; +// uppercase(c, std::nullopt); +// assert(c == '?'); +// +// uppercase(c, 'a'); +// assert(c == 'A'); +// +#define FTL_TRY(expr) \ + ({ \ + auto exp_ = (expr); \ + if (!exp_.has_value()) { \ + using E = decltype(exp_)::error_type; \ + return android::ftl::details::UnitToVoid::from(std::move(exp_)); \ + } \ + exp_.value(); \ + }) + +// Given an expression `expr` that evaluates to an ftl::Expected result (R for short), +// FTL_EXPECT unwraps T out of R, or bails out of the enclosing function F if R has an error E. +// While FTL_TRY bails out with R, FTL_EXPECT bails out with E, which is useful when F does not +// need to propagate R because T is not relevant to the caller. +// +// Example usage: +// +// using StringExp = ftl::Expected; +// +// std::errc repeat(StringExp exp, std::string& out) { +// const std::string str = FTL_EXPECT(exp); +// out = str + str; +// return std::errc::operation_in_progress; +// } +// +// std::string str; +// assert(std::errc::operation_in_progress == repeat(StringExp("ha"s), str)); +// assert("haha"s == str); +// assert(std::errc::bad_message == repeat(ftl::Unexpected(std::errc::bad_message), str)); +// assert("haha"s == str); +// +#define FTL_EXPECT(expr) \ + ({ \ + auto exp_ = (expr); \ + if (!exp_.has_value()) { \ + return std::move(exp_.error()); \ + } \ + exp_.value(); \ + }) + +namespace android::ftl { + +// Superset of base::expected with monadic operations. +// +// TODO: Extend std::expected in C++23. +// + template + struct Expected final : base::expected { + using Base = base::expected; + using Base::expected; + + using Base::error; + using Base::has_value; + using Base::value; + + template + constexpr bool has_error(P predicate) const { + return !has_value() && predicate(error()); + } + + constexpr Optional value_opt() const &{ + return has_value() ? Optional(value()) : std::nullopt; + } + + constexpr Optional value_opt() &&{ + return has_value() ? Optional(std::move(value())) : std::nullopt; + } + + // Delete new for this class. Its base doesn't have a virtual destructor, and + // if it got deleted via base class pointer, it would cause undefined + // behavior. There's not a good reason to allocate this object on the heap + // anyway. + static void *operator new(size_t) = delete; + + static void *operator new[](size_t) = delete; + }; + + template + constexpr auto Unexpected(E &&error) { + return base::unexpected(std::forward(error)); + } + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/fake_guard.h b/sysbridge/src/main/cpp/android/ftl/fake_guard.h new file mode 100644 index 0000000000..056564a1a1 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/fake_guard.h @@ -0,0 +1,87 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#define FTL_ATTRIBUTE(a) __attribute__((a)) + +namespace android::ftl { + +// Granular alternative to [[clang::no_thread_safety_analysis]]. Given a std::mutex-like object, +// FakeGuard suppresses enforcement of thread-safe access to guarded variables within its scope. +// While FakeGuard is scoped to a block, there are macro shorthands for a single expression, as +// well as function/lambda scope (though calls must be indirect, e.g. virtual or std::function): +// +// struct { +// std::mutex mutex; +// int x FTL_ATTRIBUTE(guarded_by(mutex)) = -1; +// +// int f() { +// { +// ftl::FakeGuard guard(mutex); +// x = 0; +// } +// +// return FTL_FAKE_GUARD(mutex, x + 1); +// } +// +// std::function g() const { +// return [this]() FTL_FAKE_GUARD(mutex) { return x; }; +// } +// } s; +// +// assert(s.f() == 1); +// assert(s.g()() == 0); +// +// An example of a situation where FakeGuard helps is a mutex that guards writes on Thread 1, and +// reads on Thread 2. Reads on Thread 1, which is the only writer, need not be under lock, so can +// use FakeGuard to appease the thread safety analyzer. Another example is enforcing and documenting +// exclusive access by a single thread. This is done by defining a global constant that represents a +// thread context, and annotating guarded variables as if it were a mutex (though without any effect +// at run time): +// +// constexpr class [[clang::capability("mutex")]] { +// } kMainThreadContext; +// + template + struct [[clang::scoped_lockable]] FakeGuard final { + explicit FakeGuard(const Mutex &mutex) FTL_ATTRIBUTE(acquire_capability(mutex)) {} + + [[clang::release_capability()]] ~FakeGuard() {} + + FakeGuard(const FakeGuard &) = delete; + + FakeGuard &operator=(const FakeGuard &) = delete; + }; + +} // namespace android::ftl + +// TODO: Enable in C++23 once standard attributes can be used on lambdas. +#if 0 +#define FTL_FAKE_GUARD1(mutex) [[using clang: acquire_capability(mutex), release_capability(mutex)]] +#else +#define FTL_FAKE_GUARD1(mutex) \ + FTL_ATTRIBUTE(acquire_capability(mutex)) \ + FTL_ATTRIBUTE(release_capability(mutex)) +#endif + +#define FTL_FAKE_GUARD2(mutex, expr) \ + (android::ftl::FakeGuard(mutex), expr) + +#define FTL_MAKE_FAKE_GUARD(arg1, arg2, guard, ...) guard + +#define FTL_FAKE_GUARD(...) \ + FTL_MAKE_FAKE_GUARD(__VA_ARGS__, FTL_FAKE_GUARD2, FTL_FAKE_GUARD1, )(__VA_ARGS__) diff --git a/sysbridge/src/main/cpp/android/ftl/finalizer.h b/sysbridge/src/main/cpp/android/ftl/finalizer.h new file mode 100644 index 0000000000..b3342e959c --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/finalizer.h @@ -0,0 +1,213 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +#include +#include +#include + +#include + +namespace android::ftl { + +// An RAII wrapper that invokes a function object as a finalizer when destroyed. +// +// The function object must take no arguments, and must return void. If the function object needs +// any context for the call, it must store it itself, for example with a lambda capture. +// +// The stored function object will be called once (unless canceled via the `cancel()` member +// function) at the first of: +// +// - The Finalizer instance is destroyed. +// - `operator()` is used to invoke the contained function. +// - The Finalizer instance is move-assigned a new value. The function being replaced will be +// invoked, and the replacement will be stored to be called later. +// +// The intent with this class is to keep cleanup code next to the code that requires that +// cleanup be performed. +// +// bool read_file(std::string filename) { +// FILE* f = fopen(filename.c_str(), "rb"); +// if (f == nullptr) return false; +// const auto cleanup = ftl::Finalizer([f]() { fclose(f); }); +// // fread(...), etc +// return true; +// } +// +// The `FinalFunction` template argument to Finalizer allows a polymorphic function +// type for storing the finalization function, such as `std::function` or `ftl::Function`. +// +// For convenience, this header defines a few useful aliases for using those types. +// +// - `FinalizerStd`, an alias for `Finalizer>` +// - `FinalizerFtl`, an alias for `Finalizer>` +// - `FinalizerFtl1`, an alias for `Finalizer>` +// - `FinalizerFtl2`, an alias for `Finalizer>` +// - `FinalizerFtl3`, an alias for `Finalizer>` +// +// Clients of this header are free to define other aliases they need. +// +// A Finalizer that uses a polymorphic function type can be returned from a function call and/or +// stored as member data (to be destroyed along with the containing class). +// +// auto register(Observer* observer) -> ftl::FinalizerStd { +// const auto id = observers.add(observer); +// return ftl::Finalizer([id]() { observers.remove(id); }); +// } +// +// { +// const auto _ = register(observer); +// // do the things that required the registered observer. +// } +// // the observer is removed. +// +// Cautions: +// +// 1. When a Finalizer is stored as member data, you will almost certainly want that cleanup to +// happen first, before the rest of the other member data is destroyed. For safety you should +// assume that the finalization function will access that data directly or indirectly. +// +// This means that Finalizers should be defined last, after all other normal member data in a +// class. +// +// class MyClass { +// public: +// bool initialize() { +// ready_ = true; +// cleanup_ = ftl::Finalizer([this]() { ready_ = false; }); +// return true; +// } +// +// bool ready_ = false; +// +// // Finalizers should be last so other class members can be accessed before being +// // destroyed. +// ftl::FinalizerStd cleanup_; +// }; +// +// 2. Care must be taken to use `ftl::Finalizer()` when constructing locally from a lambda. If you +// forget to do so, you are just creating a lambda that won't be automatically invoked! +// +// const auto bad = [&counter](){ ++counter; }; // Just a lambda instance +// const auto good = ftl::Finalizer([&counter](){ ++counter; }); +// + template + class Finalizer final { + // requires(std::is_invocable_r_v) + static_assert(std::is_invocable_r_v); + + public: + // A default constructed Finalizer does nothing when destroyed. + // requires(std::is_default_constructible_v) + constexpr Finalizer() = default; + + // Constructs a Finalizer from a function object. + // requires(std::is_invocable_v) + template>> + [[nodiscard]] explicit constexpr Finalizer(F &&function) + : Finalizer(std::forward(function), false) {} + + constexpr ~Finalizer() { maybe_invoke(); } + + // Disallow copying. + Finalizer(const Finalizer &that) = delete; + + auto operator=(const Finalizer &that) = delete; + + // Move construction + // requires(std::is_move_constructible_v) + [[nodiscard]] constexpr Finalizer(Finalizer &&that) + : Finalizer(std::move(that.function_), std::exchange(that.canceled_, true)) {} + + // Implicit conversion move construction + // requires(!std::is_same_v>) + template>>> + // NOLINTNEXTLINE(google-explicit-constructor, cppcoreguidelines-rvalue-reference-param-not-moved) + [[nodiscard]] constexpr Finalizer(Finalizer &&that) + : Finalizer(std::move(that.function_), std::exchange(that.canceled_, true)) {} + + // Move assignment + // requires(std::is_move_assignable_v) + constexpr auto operator=(Finalizer &&that) -> Finalizer & { + maybe_invoke(); + + function_ = std::move(that.function_); + canceled_ = std::exchange(that.canceled_, true); + + return *this; + } + + // Implicit conversion move assignment + // requires(!std::is_same_v>) + template>>> + // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) + constexpr auto operator=(Finalizer &&that) -> Finalizer & { + *this = Finalizer(std::move(that.function_), std::exchange(that.canceled_, true)); + return *this; + } + + // Cancels the final function, preventing it from being invoked. + constexpr void cancel() { + canceled_ = true; + maybe_nullify_function(); + } + + // Invokes the final function now, if not already invoked. + constexpr void operator()() { maybe_invoke(); } + + private: + template + friend + class Finalizer; + + template>> + [[nodiscard]] explicit constexpr Finalizer(F &&function, bool canceled) + : function_(std::forward(function)), canceled_(canceled) {} + + constexpr void maybe_invoke() { + if (!std::exchange(canceled_, true)) { + std::invoke(function_); + maybe_nullify_function(); + } + } + + constexpr void maybe_nullify_function() { + // Sets function_ to nullptr if that is supported for the backing type. + if constexpr (std::is_assignable_v) { + function_ = nullptr; + } + } + + FinalFunction function_; + bool canceled_ = true; + }; + + template + Finalizer(F &&) -> Finalizer>; + +// A standard alias for using `std::function` as the polymorphic function type. + using FinalizerStd = Finalizer>; + +// Helpful aliases for using `ftl::Function` as the polymorphic function type. + using FinalizerFtl = Finalizer>; + using FinalizerFtl1 = Finalizer>; + using FinalizerFtl2 = Finalizer>; + using FinalizerFtl3 = Finalizer>; + +} // namespace android::ftl \ No newline at end of file diff --git a/sysbridge/src/main/cpp/android/ftl/flags.h b/sysbridge/src/main/cpp/android/ftl/flags.h new file mode 100644 index 0000000000..338acc240e --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/flags.h @@ -0,0 +1,246 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +// TODO(b/185536303): Align with FTL style. + +namespace android::ftl { + +/* A class for handling flags defined by an enum or enum class in a type-safe way. */ + template + class Flags { + // F must be an enum or its underlying type is undefined. Theoretically we could specialize this + // further to avoid this restriction but in general we want to encourage the use of enums + // anyways. + static_assert(std::is_enum_v, "Flags type must be an enum"); + using U = std::underlying_type_t; + + public: + constexpr Flags(F f) : mFlags(static_cast(f)) {} + + constexpr Flags(std::initializer_list fs) : mFlags(combine(fs)) {} + + constexpr Flags() : mFlags(0) {} + + constexpr Flags(const Flags &f) : mFlags(f.mFlags) {} + + // Provide a non-explicit construct for non-enum classes since they easily convert to their + // underlying types (e.g. when used with bitwise operators). For enum classes, however, we + // should force them to be explicitly constructed from their underlying types to make full use + // of the type checker. + template + constexpr Flags(T t, std::enable_if_t, T> * = nullptr) : mFlags(t) {} + + template + explicit constexpr Flags(T t, std::enable_if_t, T> * = nullptr) + : mFlags(t) {} + + class Iterator { + using Bits = std::uint64_t; + static_assert(sizeof(U) <= sizeof(Bits)); + + public: + constexpr Iterator() = default; + + Iterator(Flags flags) : mRemainingFlags(flags.mFlags) { (*this)++; } + + // Pre-fix ++ + Iterator &operator++() { + if (mRemainingFlags.none()) { + mCurrFlag = 0; + } else { + // TODO: Replace with std::countr_zero in C++20. + const Bits bit = static_cast(__builtin_ctzll( + mRemainingFlags.to_ullong())); + mRemainingFlags.reset(static_cast(bit)); + mCurrFlag = static_cast(static_cast(1) << bit); + } + return *this; + } + + // Post-fix ++ + Iterator operator++(int) { + Iterator iter = *this; + ++*this; + return iter; + } + + bool operator==(Iterator other) const { + return mCurrFlag == other.mCurrFlag && mRemainingFlags == other.mRemainingFlags; + } + + bool operator!=(Iterator other) const { return !(*this == other); } + + F operator*() const { return F{mCurrFlag}; } + + // iterator traits + + // In the future we could make this a bidirectional const iterator instead of a forward + // iterator but it doesn't seem worth the added complexity at this point. This could not, + // however, be made a non-const iterator as assigning one flag to another is a non-sensical + // operation. + using iterator_category = std::input_iterator_tag; + using value_type = F; + // Per the C++ spec, because input iterators are not assignable the iterator's reference + // type does not actually need to be a reference. In fact, making it a reference would imply + // that modifying it would change the underlying Flags object, which is obviously wrong for + // the same reason this can't be a non-const iterator. + using reference = F; + using difference_type = void; + using pointer = void; + + private: + std::bitset mRemainingFlags; + U mCurrFlag = 0; + }; + + /* + * Tests whether the given flag is set. + */ + bool test(F flag) const { + U f = static_cast(flag); + return (f & mFlags) == f; + } + + /* Tests whether any of the given flags are set */ + bool any(Flags f = ~Flags()) const { return (mFlags & f.mFlags) != 0; } + + /* Tests whether all of the given flags are set */ + bool all(Flags f) const { return (mFlags & f.mFlags) == f.mFlags; } + + constexpr Flags operator|(Flags rhs) const { + return static_cast(mFlags | rhs.mFlags); + } + + Flags &operator|=(Flags rhs) { + mFlags = mFlags | rhs.mFlags; + return *this; + } + + Flags operator&(Flags rhs) const { return static_cast(mFlags & rhs.mFlags); } + + Flags &operator&=(Flags rhs) { + mFlags = mFlags & rhs.mFlags; + return *this; + } + + Flags operator^(Flags rhs) const { return static_cast(mFlags ^ rhs.mFlags); } + + Flags &operator^=(Flags rhs) { + mFlags = mFlags ^ rhs.mFlags; + return *this; + } + + Flags operator~() { return static_cast(~mFlags); } + + bool operator==(Flags rhs) const { return mFlags == rhs.mFlags; } + + bool operator!=(Flags rhs) const { return !operator==(rhs); } + + Flags &operator=(const Flags &rhs) { + mFlags = rhs.mFlags; + return *this; + } + + inline Flags &clear(Flags f = static_cast(~static_cast(0))) { + return *this &= ~f; + } + + Iterator begin() const { return Iterator(*this); } + + Iterator end() const { return Iterator(); } + + /* + * Returns the stored set of flags. + * + * Note that this returns the underlying type rather than the base enum class. This is because + * the value is no longer necessarily a strict member of the enum since the returned value could + * be multiple enum variants OR'd together. + */ + U get() const { return mFlags; } + + std::string string() const { + std::string result; + bool first = true; + U unstringified = 0; + for (const F f: *this) { + if (const auto flagName = flag_name(f)) { + appendFlag(result, flagName.value(), first); + } else { + unstringified |= static_cast(f); + } + } + + if (unstringified != 0) { + constexpr auto radix = sizeof(U) == 1 ? Radix::kBin : Radix::kHex; + appendFlag(result, to_string(unstringified, radix), first); + } + + if (first) { + result += "0x0"; + } + + return result; + } + + private: + U mFlags; + + static constexpr U combine(std::initializer_list fs) { + U result = 0; + for (const F f: fs) { + result |= static_cast(f); + } + return result; + } + + static void appendFlag(std::string &str, const std::string_view &flag, bool &first) { + if (first) { + first = false; + } else { + str += " | "; + } + str += flag; + } + }; + +// This namespace provides operator overloads for enum classes to make it easier to work with them +// as flags. In order to use these, add them via a `using namespace` declaration. + namespace flag_operators { + + template>> + inline Flags operator~(F f) { + return static_cast(~to_underlying(f)); + } + + template>> + constexpr Flags operator|(F lhs, F rhs) { + return static_cast(to_underlying(lhs) | to_underlying(rhs)); + } + + } // namespace flag_operators +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/function.h b/sysbridge/src/main/cpp/android/ftl/function.h new file mode 100644 index 0000000000..80caf4e743 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/function.h @@ -0,0 +1,313 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include + +namespace android::ftl { + +// ftl::Function is a container for function object, and can mostly be used in place of +// std::function. +// +// Unlike std::function, a ftl::Function: +// +// * Uses a static amount of memory (controlled by N), and never any dynamic allocation. +// * Satisfies the std::is_trivially_copyable<> trait. +// * Satisfies the std::is_trivially_destructible<> trait. +// +// However those same limits are also required from the contained function object in turn. +// +// The size of a ftl::Function is guaranteed to be: +// +// sizeof(std::intptr_t) * (N + 2) +// +// A ftl::Function can always be implicitly converted to a larger size ftl::Function. +// Trying to convert the other way leads to a compilation error. +// +// A default-constructed ftl::Function is in an empty state. The operator bool() overload returns +// false in this state. It is undefined behavior to attempt to invoke the function in this state. +// +// The ftl::Function can also be constructed or assigned from ftl::no_op. This sets up the +// ftl::Function to be non-empty, with a function that when called does nothing except +// default-constructs a return value. +// +// The ftl::make_function() helpers construct a ftl::Function, including deducing the +// values of F and N from the arguments it is given. +// +// The static ftl::Function::make() helpers construct a ftl::Function without that +// deduction, and also allow for implicit argument conversion if the target being called needs them. +// +// The construction helpers allow any of the following types of functions to be stored: +// +// * Any SMALL function object (as defined by the C++ Standard), such as a lambda with a small +// capture, or other "functor". The requirements are: +// +// 1) The function object must be trivial to destroy (in fact, the destructor will never +// actually be called once copied to the internal storage). +// 2) The function object must be trivial to copy (the raw bytes will be copied as the +// ftl::Function is copied/moved). +// 3) The size of the function object cannot be larger than sizeof(std::intptr_t) * (N + 1), +// and it cannot require stricter alignment than alignof(std::intptr_t). +// +// With the default of N=0, a lambda can only capture a single pointer-sized argument. This is +// enough to capture `this`, which is why N=0 is the default. +// +// * A member function, with the address passed as the template value argument to the construction +// helper function, along with the instance pointer needed to invoke it passed as an ordinary +// argument. +// +// ftl::make_function<&Class::member_function>(this); +// +// Note that the indicated member function will be invoked non-virtually. If you need it to be +// invoked virtually, you should invoke it yourself with a small lambda like so: +// +// ftl::function([this] { virtual_member_function(); }); +// +// * An ordinary function ("free function"), with the address of the function passed as a template +// value argument. +// +// ftl::make_function<&std::atoi>(); +// +// As with the member function helper, as the function is known at compile time, it will be called +// directly. +// +// Example usage: +// +// class MyClass { +// public: +// void on_event() const {} +// int on_string(int*, std::string_view) { return 1; } +// +// auto get_function() { +// return ftl::function([this] { on_event(); }); +// } +// } cls; +// +// // A function container with no arguments, and returning no value. +// ftl::Function f; +// +// // Construct a ftl::Function containing a small lambda. +// f = cls.get_function(); +// +// // Construct a ftl::Function that calls `cls.on_event()`. +// f = ftl::function<&MyClass::on_event>(&cls); +// +// // Create a do-nothing function. +// f = ftl::no_op; +// +// // Invoke the contained function. +// f(); +// +// // Also invokes it. +// std::invoke(f); +// +// // Create a typedef to give a more meaningful name and bound the size. +// using MyFunction = ftl::Function; +// int* ptr = nullptr; +// auto f1 = MyFunction::make( +// [cls = &cls, ptr](std::string_view sv) { +// return cls->on_string(ptr, sv); +// }); +// int r = f1("abc"sv); +// +// // Returns a default-constructed int (0). +// f1 = ftl::no_op; +// r = f1("abc"sv); +// assert(r == 0); + + template + class Function; + +// Used to construct a Function that does nothing. + struct NoOpTag { + }; + + constexpr NoOpTag no_op; + +// Detects that a type is a `ftl::Function` regardless of what `F` and `N` are. + template + struct is_function : public std::false_type { + }; + + template + struct is_function> : public std::true_type { + }; + + template + constexpr bool is_function_v = is_function::value; + + template + class Function final { + // Enforce a valid size, with an arbitrary maximum allowed size for the container of + // sizeof(std::intptr_t) * 16, though that maximum can be relaxed. + static_assert(N <= details::kFunctionMaximumN); + + using OpaqueStorageTraits = details::function_opaque_storage; + + public: + // Defining result_type allows ftl::Function to be substituted for std::function. + using result_type = Ret; + + // Constructs an empty ftl::Function. + Function() = default; + + // Constructing or assigning from nullptr_t also creates an empty ftl::Function. + Function(std::nullptr_t) {} + + Function &operator=(std::nullptr_t) { return *this = Function(nullptr); } + + // Constructing from NoOpTag sets up a a special no-op function which is valid to call, and which + // returns a default constructed return value. + Function(NoOpTag) : function_(details::bind_opaque_no_op()) {} + + Function &operator=(NoOpTag) { return *this = Function(no_op); } + + // Constructing/assigning from a function object stores a copy of that function object, however: + // * It must be trivially copyable, as the implementation makes a copy with memcpy(). + // * It must be trivially destructible, as the implementation doesn't destroy the copy! + // * It must fit in the limited internal storage, which enforces size/alignment restrictions. + + template>> + Function(const F &f) + : opaque_(OpaqueStorageTraits::opaque_copy(f)), + function_(details::bind_opaque_function_object(f)) {} + + template>> + Function &operator=(const F &f) noexcept { + return *this = Function{OpaqueStorageTraits::opaque_copy(f), + details::bind_opaque_function_object(f)}; + } + + // Constructing/assigning from a smaller ftl::Function is allowed, but not anything else. + + template + Function(const Function &other) + : opaque_{OpaqueStorageTraits::opaque_copy(other.opaque_)}, + function_(other.function_) {} + + template + auto &operator=(const Function &other) { + return *this = Function{OpaqueStorageTraits::opaque_copy(other.opaque_), + other.function_}; + } + + // Returns true if a function is set. + explicit operator bool() const { return function_ != nullptr; } + + // Checks if the other function has the same contents as this one. + bool operator==(const Function &other) const { + return other.opaque_ == opaque_ && other.function_ == function_; + } + + bool operator!=(const Function &other) const { return !operator==(other); } + + // Alternative way of testing for a function being set. + bool operator==(std::nullptr_t) const { return function_ == nullptr; } + + bool operator!=(std::nullptr_t) const { return function_ != nullptr; } + + // Invokes the function. + Ret operator()(Args... args) const { + return std::invoke(function_, opaque_.data(), std::forward(args)...); + } + + // Creation helper for function objects, such as lambdas. + template + static auto make(const F &f) -> decltype(Function{f}) { + return Function{f}; + } + + // Creation helper for a class pointer and a compile-time chosen member function to call. + template + static auto make(Class *instance) -> decltype(Function{ + details::bind_member_function(instance, + static_cast(nullptr))}) { + return Function{details::bind_member_function( + instance, static_cast(nullptr))}; + } + + // Creation helper for a compile-time chosen free function to call. + template + static auto make() -> decltype(Function{ + details::bind_free_function( + static_cast(nullptr))}) { + return Function{ + details::bind_free_function( + static_cast(nullptr))}; + } + + private: + // Needed so a Function can be converted to a Function. + template + friend + class Function; + + // The function pointer type of function stored in `function_`. The first argument is always + // `&opaque_`. + using StoredFunction = Ret(void *, Args...); + + // The type of the opaque storage, used to hold an appropriate function object. + // The type stored here is ONLY known to the StoredFunction. + // We always use at least one std::intptr_t worth of storage, and always a multiple of that size. + using OpaqueStorage = typename OpaqueStorageTraits::type; + + // Internal constructor for creating from a raw opaque blob + function pointer. + Function(const OpaqueStorage &opaque, StoredFunction *function) + : opaque_(opaque), function_(function) {} + + // Note: `mutable` so that `operator() const` can use it. + mutable OpaqueStorage opaque_{}; + StoredFunction *function_{nullptr}; + }; + +// Makes a ftl::Function given a function object `F`. + template> + Function(const F &) -> Function; + + template + auto make_function(const F &f) -> decltype(Function{f}) { + return Function{f}; + } + +// Makes a ftl::Function given a `MemberFunction` and a instance pointer to the associated `Class`. + template + auto make_function(Class *instance) + -> decltype(Function{details::bind_member_function( + instance, + static_cast *>(nullptr))}) { + return Function{details::bind_member_function( + instance, + static_cast *>(nullptr))}; + } + +// Makes a ftl::Function given an ordinary free function. + template + auto make_function() -> decltype(Function{ + details::bind_free_function( + static_cast(nullptr))}) { + return Function{ + details::bind_free_function( + static_cast(nullptr))}; + } + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/future.h b/sysbridge/src/main/cpp/android/ftl/future.h new file mode 100644 index 0000000000..4110674618 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/future.h @@ -0,0 +1,135 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +#include + +namespace android::ftl { + +// Thin wrapper around FutureImpl (concretely std::future or std::shared_future) with +// extensions for pure values (created via ftl::yield) and continuations. +// +// See also SharedFuture shorthand below. +// + template class FutureImpl = std::future> + class Future final : public details::BaseFuture, T, FutureImpl> { + using Base = details::BaseFuture; + + friend Base; // For BaseFuture<...>::self. + friend details::BaseFuture, T, std::future>; // For BaseFuture<...>::share. + + public: + // Constructs an invalid future. + Future() : future_(std::in_place_type>) {} + + // Constructs a future from its standard counterpart, implicitly. + Future(FutureImpl &&f) : future_(std::move(f)) {} + + bool valid() const { + return std::holds_alternative(future_) || std::get>(future_).valid(); + } + + // Forwarding functions. Base::share is only defined when FutureImpl is std::future, whereas the + // following are defined for either FutureImpl: + using Base::get; + using Base::wait_for; + + // Attaches a continuation to the future. The continuation is a function that maps T to either R + // or ftl::Future. In the former case, the chain wraps the result in a future as if by + // ftl::yield. + // + // auto future = ftl::yield(123); + // ftl::Future futures[] = {ftl::yield('a'), ftl::yield('b')}; + // + // auto chain = + // ftl::Future(std::move(future)) + // .then([](int x) { return static_cast(x % 2); }) + // .then([&futures](std::size_t i) { return std::move(futures[i]); }); + // + // assert(chain.get() == 'b'); + // + template> + auto then(F &&op) && -> Future> { + return defer( + [](auto &&f, F &&op) { + R r = op(f.get()); + if constexpr (std::is_same_v>) { + return r; + } else { + return r.get(); + } + }, + std::move(*this), std::forward(op)); + } + + private: + template + friend Future yield(V &&); + + template + friend Future yield(Args &&...); + + template + Future(details::ValueTag, Args &&... args) + : future_(std::in_place_type, std::forward(args)...) {} + + std::variant> future_; + }; + + template + using SharedFuture = Future; + +// Deduction guide for implicit conversion. + template class FutureImpl> + Future(FutureImpl &&) -> Future; + +// Creates a future that wraps a value. +// +// auto future = ftl::yield(42); +// assert(future.get() == 42); +// +// auto ptr = std::make_unique('!'); +// auto future = ftl::yield(std::move(ptr)); +// assert(*future.get() == '!'); +// + template + inline Future yield(V &&value) { + return {details::ValueTag{}, std::move(value)}; + } + + template + inline Future yield(Args &&... args) { + return {details::ValueTag{}, std::forward(args)...}; + } + +// Creates a future that defers a function call until its result is queried. +// +// auto future = ftl::defer([](int x) { return x + 1; }, 99); +// assert(future.get() == 100); +// + template + inline auto defer(F &&f, Args &&... args) { + return Future( + std::async(std::launch::deferred, std::forward(f), std::forward(args)...)); + } + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/hash.h b/sysbridge/src/main/cpp/android/ftl/hash.h new file mode 100644 index 0000000000..6d13672e7e --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/hash.h @@ -0,0 +1,44 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include + +namespace android::ftl { + +// Non-cryptographic hash function (namely CityHash64) for strings with at most 64 characters. +// Unlike std::hash, which returns std::size_t and is only required to produce the same result +// for the same input within a single execution of a program, this hash is stable. + inline std::optional stable_hash(std::string_view view) { + const auto length = view.length(); + if (length <= 16) { + return details::hash_length_0_to_16(view.data(), length); + } + if (length <= 32) { + return details::hash_length_17_to_32(view.data(), length); + } + if (length <= 64) { + return details::hash_length_33_to_64(view.data(), length); + } + return {}; + } + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/ignore.h b/sysbridge/src/main/cpp/android/ftl/ignore.h new file mode 100644 index 0000000000..bd33511747 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/ignore.h @@ -0,0 +1,43 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +namespace android::ftl { + +// An alternative to `std::ignore` that makes it easy to ignore multiple values. +// +// Examples: +// +// void ftl_ignore_multiple(int arg1, const char* arg2, std::string arg3) { +// // When invoked, all the arguments are ignored. +// ftl::ignore(arg1, arg2, arg3); +// } +// +// void ftl_ignore_single(int arg) { +// // It can be used like std::ignore to ignore a single value +// ftl::ignore = arg; +// } +// + inline constexpr struct { + // NOLINTNEXTLINE(misc-unconventional-assign-operator, readability-named-parameter) + constexpr auto operator=(auto &&) const -> decltype(*this) { return *this; } + + // NOLINTNEXTLINE(readability-named-parameter) + constexpr void operator()(auto &&...) const {} + } ignore; + +} // namespace android::ftl \ No newline at end of file diff --git a/sysbridge/src/main/cpp/android/ftl/initializer_list.h b/sysbridge/src/main/cpp/android/ftl/initializer_list.h new file mode 100644 index 0000000000..e6d15c3061 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/initializer_list.h @@ -0,0 +1,112 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +namespace android::ftl { + +// Compile-time counterpart of std::initializer_list that stores per-element constructor +// arguments with heterogeneous types. For a container with elements of type T, given Sizes +// (S0, S1, ..., SN), N elements are initialized: the first element is initialized with the +// first S0 arguments, the second element is initialized with the next S1 arguments, and so +// on. The list of Types (T0, ..., TM) is flattened, so M is equal to the sum of the Sizes. +// +// An InitializerList is created using ftl::init::list, and is consumed by constructors of +// containers. The function call operator is overloaded such that arguments are accumulated +// in a tuple with each successive call. For instance, the following calls initialize three +// strings using different constructors, i.e. string literal, default, and count/character: +// +// ... = ftl::init::list("abc")()(3u, '?'); +// +// The following syntax is a shorthand for key-value pairs, where the first argument is the +// key, and the rest construct the value. The types of the key and value are deduced if the +// first pair contains exactly two arguments: +// +// ... = ftl::init::map(-1, "abc")(-2)(-3, 3u, '?'); +// +// ... = ftl::init::map(0, 'a')(1, 'b')(2, 'c'); +// +// WARNING: The InitializerList returned by an ftl::init::list expression must be consumed +// immediately, since temporary arguments are destroyed after the full expression. Storing +// an InitializerList results in dangling references. +// + template, typename... Types> + struct InitializerList; + + template + struct InitializerList, Types...> { + // Creates a superset InitializerList by appending the number of arguments to Sizes, and + // expanding Types with forwarding references for each argument. + template + [[nodiscard]] constexpr auto operator()(Args &&... args) && -> InitializerList< + T, std::index_sequence, Types..., Args && ...> { + return {std::tuple_cat(std::move(tuple), + std::forward_as_tuple(std::forward(args)...))}; + } + + // The temporary InitializerList returned by operator() is bound to an rvalue reference in + // container constructors, which extends the lifetime of any temporary arguments that this + // tuple refers to until the completion of the full expression containing the construction. + std::tuple tuple; + }; + + template> + struct KeyValue { + }; + +// Shorthand for key-value pairs that assigns the first argument to the key, and the rest to the +// value. The specialization is on KeyValue rather than std::pair, so that ftl::init::list works +// with the latter. + template + struct InitializerList, std::index_sequence, Types...> { + // Accumulate the three arguments to std::pair's piecewise constructor. + template + [[nodiscard]] constexpr auto operator()(K &&k, Args &&... args) && -> InitializerList< + KeyValue, std::index_sequence, Types..., std::piecewise_construct_t, + std::tuple, std::tuple> { + return {std::tuple_cat( + std::move(tuple), + std::forward_as_tuple(std::piecewise_construct, + std::forward_as_tuple(std::forward(k)), + std::forward_as_tuple(std::forward(args)...)))}; + } + + std::tuple tuple; + }; + + namespace init { + + template + [[nodiscard]] constexpr auto list(Args &&... args) { + return InitializerList{}(std::forward(args)...); + } + + template, typename... Args> + [[nodiscard]] constexpr auto map(Args &&... args) { + return list>(std::forward(args)...); + } + + template + [[nodiscard]] constexpr auto map(K &&k, V &&v) { + return list>(std::forward(k), std::forward(v)); + } + + } // namespace init +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/match.h b/sysbridge/src/main/cpp/android/ftl/match.h new file mode 100644 index 0000000000..490a4e72eb --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/match.h @@ -0,0 +1,63 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include + +namespace android::ftl { + +// Concise alternative to std::visit that compiles to branches rather than a dispatch table. For +// std::variant where N is small, this is slightly faster since the branches can be +// inlined unlike the function pointers. +// +// using namespace std::chrono; +// std::variant duration = 119min; +// +// // Mutable match. +// ftl::match(duration, [](auto& d) { ++d; }); +// +// // Immutable match. Exhaustive due to minutes being convertible to seconds. +// assert("2 hours"s == +// ftl::match(duration, +// [](const seconds& s) { +// const auto h = duration_cast(s); +// return std::to_string(h.count()) + " hours"s; +// }, +// [](const hours& h) { return std::to_string(h.count() / 24) + " days"s; })); +// + template + decltype(auto) match(std::variant &variant, Ms &&... matchers) { + const auto matcher = details::Matcher{std::forward(matchers)...}; + static_assert(details::is_exhaustive_match_v, + "Non-exhaustive match"); + + return details::Match::match(variant, matcher); + } + + template + decltype(auto) match(const std::variant &variant, Ms &&... matchers) { + const auto matcher = details::Matcher{std::forward(matchers)...}; + static_assert(details::is_exhaustive_match_v, + "Non-exhaustive match"); + + return details::Match::match(variant, matcher); + } + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/mixins.h b/sysbridge/src/main/cpp/android/ftl/mixins.h new file mode 100644 index 0000000000..0cfae3ce27 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/mixins.h @@ -0,0 +1,152 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace android::ftl { + +// CRTP mixins for defining type-safe wrappers that are distinct from their underlying type. Common +// uses are IDs, opaque handles, and physical quantities. The constructor is provided by (and must +// be inherited from) the `Constructible` mixin, whereas operators (equality, ordering, arithmetic, +// etc.) are enabled through inheritance: +// +// struct Id : ftl::Constructible, ftl::Equatable { +// using Constructible::Constructible; +// }; +// +// static_assert(!std::is_default_constructible_v); +// +// Unlike `Constructible`, `DefaultConstructible` allows default construction. The default value is +// zero-initialized unless specified: +// +// struct Color : ftl::DefaultConstructible, +// ftl::Equatable, +// ftl::Orderable { +// using DefaultConstructible::DefaultConstructible; +// }; +// +// static_assert(Color() == Color(0u)); +// static_assert(ftl::to_underlying(Color(-1)) == 255u); +// static_assert(Color(1u) < Color(2u)); +// +// struct Sequence : ftl::DefaultConstructible, +// ftl::Equatable, +// ftl::Orderable, +// ftl::Incrementable { +// using DefaultConstructible::DefaultConstructible; +// }; +// +// static_assert(Sequence() == Sequence(-1)); +// +// The underlying type need not be a fundamental type: +// +// struct Timeout : ftl::DefaultConstructible, +// ftl::Equatable, +// ftl::Addable { +// using DefaultConstructible::DefaultConstructible; +// }; +// +// using namespace std::chrono_literals; +// static_assert(Timeout() + Timeout(5s) == Timeout(15s)); +// + template + struct Constructible { + explicit constexpr Constructible(T value) : value_(value) {} + + explicit constexpr operator const T &() const { return value_; } + + private: + template class> + friend + class details::Mixin; + + T value_; + }; + + template + struct DefaultConstructible : Constructible { + using Constructible::Constructible; + + constexpr DefaultConstructible() : DefaultConstructible(T{kDefault}) {} + }; + +// Shorthand for casting a type-safe wrapper to its underlying value. + template + constexpr const T &to_underlying(const Constructible &c) { + return static_cast(c); + } + +// Comparison operators for equality. + template + struct Equatable : details::Mixin { + constexpr bool operator==(const Self &other) const { + return to_underlying(this->self()) == to_underlying(other); + } + + constexpr bool operator!=(const Self &other) const { return !(*this == other); } + }; + +// Comparison operators for ordering. + template + struct Orderable : details::Mixin { + constexpr bool operator<(const Self &other) const { + return to_underlying(this->self()) < to_underlying(other); + } + + constexpr bool operator>(const Self &other) const { return other < this->self(); } + + constexpr bool operator>=(const Self &other) const { return !(*this < other); } + + constexpr bool operator<=(const Self &other) const { return !(*this > other); } + }; + +// Pre-increment and post-increment operators. + template + struct Incrementable : details::Mixin { + constexpr Self &operator++() { + ++this->mut(); + return this->self(); + } + + constexpr Self operator++(int) { + const Self tmp = this->self(); + operator++(); + return tmp; + } + }; + +// Additive operators, including incrementing. + template + struct Addable : details::Mixin, Incrementable { + constexpr Self &operator+=(const Self &other) { + this->mut() += to_underlying(other); + return this->self(); + } + + constexpr Self operator+(const Self &other) const { + Self tmp = this->self(); + return tmp += other; + } + + private: + using Base = details::Mixin; + using Base::mut; + using Base::self; + }; + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/non_null.h b/sysbridge/src/main/cpp/android/ftl/non_null.h new file mode 100644 index 0000000000..df3e49dfd9 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/non_null.h @@ -0,0 +1,228 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +namespace android::ftl { + +// Enforces and documents non-null pre/post-condition for (raw or smart) pointers. +// +// void get_length(const ftl::NonNull>& string_ptr, +// ftl::NonNull length_ptr) { +// // No need for `nullptr` checks. +// *length_ptr = string_ptr->length(); +// } +// +// const auto string_ptr = ftl::as_non_null(std::make_shared("android")); +// std::size_t size; +// get_length(string_ptr, ftl::as_non_null(&size)); +// assert(size == 7u); +// +// For compatibility with std::unique_ptr and performance with std::shared_ptr, move +// operations are allowed despite breaking the invariant: +// +// using Pair = std::pair>, std::shared_ptr>; +// +// Pair dupe_if(ftl::NonNull> non_null_ptr, bool condition) { +// // Move the underlying pointer out, so `non_null_ptr` must not be accessed after this point. +// auto unique_ptr = std::move(non_null_ptr).take(); +// +// auto non_null_shared_ptr = ftl::as_non_null(std::shared_ptr(std::move(unique_ptr))); +// auto nullable_shared_ptr = condition ? non_null_shared_ptr.get() : nullptr; +// +// return {std::move(non_null_shared_ptr), std::move(nullable_shared_ptr)}; +// } +// +// auto ptr = ftl::as_non_null(std::make_unique(42)); +// const auto [ptr1, ptr2] = dupe_if(std::move(ptr), true); +// assert(ptr1.get() == ptr2); +// + template + class NonNull final { + struct Passkey { + }; + + public: + // Disallow `nullptr` explicitly for clear compilation errors. + NonNull() = delete; + + NonNull(std::nullptr_t) = delete; + + // Copy operations. + + constexpr NonNull(const NonNull &) = default; + + constexpr NonNull &operator=(const NonNull &) = default; + + template>> + constexpr NonNull(const NonNull &other) : pointer_(other.get()) {} + + template>> + constexpr NonNull &operator=(const NonNull &other) { + pointer_ = other.get(); + return *this; + } + + [[nodiscard]] constexpr const Pointer &get() const { return pointer_; } + + [[nodiscard]] constexpr explicit operator const Pointer &() const { return get(); } + + // Move operations. These break the invariant, so care must be taken to avoid subsequent access. + + constexpr NonNull(NonNull &&) = default; + + constexpr NonNull &operator=(NonNull &&) = default; + + [[nodiscard]] constexpr Pointer take() &&{ return std::move(pointer_); } + + [[nodiscard]] constexpr explicit operator Pointer() && { return take(); } + + // Dereferencing. + [[nodiscard]] constexpr decltype(auto) operator*() const { return *get(); } + + [[nodiscard]] constexpr decltype(auto) operator->() const { return get(); } + + [[nodiscard]] constexpr explicit operator bool() const { return !(pointer_ == nullptr); } + + // Private constructor for ftl::as_non_null. Excluded from candidate constructors for conversions + // through the passkey idiom, for clear compilation errors. + template + constexpr NonNull(Passkey, P &&pointer) : pointer_(std::forward

(pointer)) { + if (pointer_ == nullptr) std::abort(); + } + + private: + template + friend constexpr auto as_non_null(P &&) -> NonNull>; + + Pointer pointer_; + }; + + template + [[nodiscard]] constexpr auto as_non_null(P &&pointer) -> NonNull> { + using Passkey = typename NonNull>::Passkey; + return {Passkey{}, std::forward

(pointer)}; + } + +// NonNull

<=> NonNull + + template + constexpr bool operator==(const NonNull

&lhs, const NonNull &rhs) { + return lhs.get() == rhs.get(); + } + + template + constexpr bool operator!=(const NonNull

&lhs, const NonNull &rhs) { + return !operator==(lhs, rhs); + } + + template + constexpr bool operator<(const NonNull

&lhs, const NonNull &rhs) { + return lhs.get() < rhs.get(); + } + + template + constexpr bool operator<=(const NonNull

&lhs, const NonNull &rhs) { + return lhs.get() <= rhs.get(); + } + + template + constexpr bool operator>=(const NonNull

&lhs, const NonNull &rhs) { + return lhs.get() >= rhs.get(); + } + + template + constexpr bool operator>(const NonNull

&lhs, const NonNull &rhs) { + return lhs.get() > rhs.get(); + } + +// NonNull

<=> Q + + template + constexpr bool operator==(const NonNull

&lhs, const Q &rhs) { + return lhs.get() == rhs; + } + + template + constexpr bool operator!=(const NonNull

&lhs, const Q &rhs) { + return lhs.get() != rhs; + } + + template + constexpr bool operator<(const NonNull

&lhs, const Q &rhs) { + return lhs.get() < rhs; + } + + template + constexpr bool operator<=(const NonNull

&lhs, const Q &rhs) { + return lhs.get() <= rhs; + } + + template + constexpr bool operator>=(const NonNull

&lhs, const Q &rhs) { + return lhs.get() >= rhs; + } + + template + constexpr bool operator>(const NonNull

&lhs, const Q &rhs) { + return lhs.get() > rhs; + } + +// P <=> NonNull + + template + constexpr bool operator==(const P &lhs, const NonNull &rhs) { + return lhs == rhs.get(); + } + + template + constexpr bool operator!=(const P &lhs, const NonNull &rhs) { + return lhs != rhs.get(); + } + + template + constexpr bool operator<(const P &lhs, const NonNull &rhs) { + return lhs < rhs.get(); + } + + template + constexpr bool operator<=(const P &lhs, const NonNull &rhs) { + return lhs <= rhs.get(); + } + + template + constexpr bool operator>=(const P &lhs, const NonNull &rhs) { + return lhs >= rhs.get(); + } + + template + constexpr bool operator>(const P &lhs, const NonNull &rhs) { + return lhs > rhs.get(); + } + +} // namespace android::ftl + +// Specialize std::hash for ftl::NonNull +template +struct std::hash> { + std::size_t operator()(const android::ftl::NonNull

&ptr) const { + return std::hash

()(ptr.get()); + } +}; diff --git a/sysbridge/src/main/cpp/android/ftl/optional.h b/sysbridge/src/main/cpp/android/ftl/optional.h new file mode 100644 index 0000000000..c7b5d26a67 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/optional.h @@ -0,0 +1,147 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include "../libbase/expected.h" + +#include + +namespace android::ftl { + +// Superset of std::optional with monadic operations, as proposed in https://wg21.link/P0798R8. +// +// TODO: Remove standard APIs in C++23. +// + template + struct Optional final : std::optional { + using std::optional::optional; + + // Implicit downcast. + Optional(std::optional other) : std::optional(std::move(other)) {} + + using std::optional::has_value; + using std::optional::value; + + // Returns Optional where F is a function that maps T to U. + template + constexpr auto transform(F &&f) const &{ + using R = details::transform_result_t; + if (has_value()) return R(std::invoke(std::forward(f), value())); + return R(); + } + + template + constexpr auto transform(F &&f) &{ + using R = details::transform_result_t; + if (has_value()) return R(std::invoke(std::forward(f), value())); + return R(); + } + + template + constexpr auto transform(F &&f) const &&{ + using R = details::transform_result_t; + if (has_value()) return R(std::invoke(std::forward(f), std::move(value()))); + return R(); + } + + template + constexpr auto transform(F &&f) &&{ + using R = details::transform_result_t; + if (has_value()) return R(std::invoke(std::forward(f), std::move(value()))); + return R(); + } + + // Returns Optional where F is a function that maps T to Optional. + template + constexpr auto and_then(F &&f) const &{ + using R = details::and_then_result_t; + if (has_value()) return std::invoke(std::forward(f), value()); + return R(); + } + + template + constexpr auto and_then(F &&f) &{ + using R = details::and_then_result_t; + if (has_value()) return std::invoke(std::forward(f), value()); + return R(); + } + + template + constexpr auto and_then(F &&f) const &&{ + using R = details::and_then_result_t; + if (has_value()) return std::invoke(std::forward(f), std::move(value())); + return R(); + } + + template + constexpr auto and_then(F &&f) &&{ + using R = details::and_then_result_t; + if (has_value()) return std::invoke(std::forward(f), std::move(value())); + return R(); + } + + // Returns this Optional if not nullopt, or else the Optional returned by the function F. + template + constexpr auto or_else(F &&f) const & -> details::or_else_result_t { + if (has_value()) return *this; + return std::forward(f)(); + } + + template + constexpr auto or_else(F &&f) && -> details::or_else_result_t { + if (has_value()) return std::move(*this); + return std::forward(f)(); + } + + // Maps this Optional to expected where nullopt becomes E. + template + constexpr auto ok_or(E &&e) && -> base::expected { + if (has_value()) return std::move(value()); + return base::unexpected(std::forward(e)); + } + + // Delete new for this class. Its base doesn't have a virtual destructor, and + // if it got deleted via base class pointer, it would cause undefined + // behavior. There's not a good reason to allocate this object on the heap + // anyway. + static void *operator new(size_t) = delete; + + static void *operator new[](size_t) = delete; + }; + + template + constexpr bool operator==(const Optional &lhs, const Optional &rhs) { + return static_cast>(lhs) == static_cast>(rhs); + } + + template + constexpr bool operator!=(const Optional &lhs, const Optional &rhs) { + return !(lhs == rhs); + } + +// Deduction guides. + template + Optional(T) -> Optional; + + template + Optional(std::optional) -> Optional; + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/shared_mutex.h b/sysbridge/src/main/cpp/android/ftl/shared_mutex.h new file mode 100644 index 0000000000..3008313038 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/shared_mutex.h @@ -0,0 +1,49 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include + +namespace android::ftl { + +// Wrapper around std::shared_mutex to provide capabilities for thread-safety +// annotations. +// TODO(b/257958323): This class is no longer needed once b/135688034 is fixed (currently blocked on +// b/175635923). + class [[clang::capability("shared_mutex")]] SharedMutex final { + public: + [[clang::acquire_capability()]] void lock() { + mutex_.lock(); + } + + [[clang::release_capability()]] void unlock() { + mutex_.unlock(); + } + + [[clang::acquire_shared_capability()]] void lock_shared() { + mutex_.lock_shared(); + } + + [[clang::release_shared_capability()]] void unlock_shared() { + mutex_.unlock_shared(); + } + + private: + std::shared_mutex mutex_; + }; + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/small_map.h b/sysbridge/src/main/cpp/android/ftl/small_map.h new file mode 100644 index 0000000000..f564909cef --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/small_map.h @@ -0,0 +1,309 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include +#include +#include +#include + +namespace android::ftl { + +// Associative container with unique, unordered keys. Unlike std::unordered_map, key-value pairs are +// stored in contiguous storage for cache efficiency. The map is allocated statically until its size +// exceeds N, at which point mappings are relocated to dynamic memory. The try_emplace operation has +// a non-standard analogue try_replace that destructively emplaces. The API also defines an in-place +// counterpart to insert_or_assign: emplace_or_replace. Lookup is done not via a subscript operator, +// but immutable getters that can optionally transform the value. +// +// SmallMap unconditionally allocates on the heap. +// +// Example usage: +// +// ftl::SmallMap map; +// assert(map.empty()); +// assert(!map.dynamic()); +// +// map = ftl::init::map(123, "abc")(-1)(42, 3u, '?'); +// assert(map.size() == 3u); +// assert(!map.dynamic()); +// +// assert(map.contains(123)); +// assert(map.get(42).transform([](const std::string& s) { return s.size(); }) == 3u); +// +// const auto opt = map.get(-1); +// assert(opt); +// +// std::string& ref = *opt; +// assert(ref.empty()); +// ref = "xyz"; +// +// map.emplace_or_replace(0, "vanilla", 2u, 3u); +// assert(map.dynamic()); +// +// assert(map == SmallMap(ftl::init::map(-1, "xyz"sv)(0, "nil"sv)(42, "???"sv)(123, "abc"sv))); +// + template> + class SmallMap final { + using Map = SmallVector, N>; + + template + friend + class SmallMap; + + public: + using key_type = K; + using mapped_type = V; + + using value_type = typename Map::value_type; + using size_type = typename Map::size_type; + using difference_type = typename Map::difference_type; + + using reference = typename Map::reference; + using iterator = typename Map::iterator; + + using const_reference = typename Map::const_reference; + using const_iterator = typename Map::const_iterator; + + // Creates an empty map. + SmallMap() = default; + + // Constructs at most N key-value pairs in place by forwarding per-pair constructor arguments. + // The template arguments K, V, and N are inferred using the deduction guide defined below. + // The syntax for listing pairs is as follows: + // + // ftl::SmallMap map = ftl::init::map(123, "abc")(-1)(42, 3u, '?'); + // static_assert(std::is_same_v>); + // + // The types of the key and value are deduced if the first pair contains exactly two arguments: + // + // ftl::SmallMap map = ftl::init::map(0, 'a')(1, 'b')(2, 'c'); + // static_assert(std::is_same_v>); + // + template + SmallMap(InitializerList, Types...> &&list) + : map_(std::move(list)) { + deduplicate(); + } + + // Copies or moves key-value pairs from a convertible map. + template + SmallMap(SmallMap other) : map_(std::move(other.map_)) {} + + static constexpr size_type static_capacity() { return N; } + + size_type max_size() const { return map_.max_size(); } + + size_type size() const { return map_.size(); } + + bool empty() const { return map_.empty(); } + + // Returns whether the map is backed by static or dynamic storage. + bool dynamic() const { + if constexpr (static_capacity() > 0) { + return map_.dynamic(); + } else { + return true; + } + } + + iterator begin() { return map_.begin(); } + + const_iterator begin() const { return cbegin(); } + + const_iterator cbegin() const { return map_.cbegin(); } + + iterator end() { return map_.end(); } + + const_iterator end() const { return cend(); } + + const_iterator cend() const { return map_.cend(); } + + // Returns whether a mapping exists for the given key. + bool contains(const key_type &key) const { return get(key).has_value(); } + + // Returns a reference to the value for the given key, or std::nullopt if the key was not found. + // + // ftl::SmallMap map = ftl::init::map('a', 'A')('b', 'B')('c', 'C'); + // + // const auto opt = map.get('c'); + // assert(opt == 'C'); + // + // char d = 'd'; + // const auto ref = map.get('d').value_or(std::ref(d)); + // ref.get() = 'D'; + // assert(d == 'D'); + // + auto get(const key_type &key) const -> Optional> { + for (const auto &[k, v]: *this) { + if (KeyEqual{}(k, key)) { + return std::cref(v); + } + } + return {}; + } + + auto get(const key_type &key) -> Optional> { + for (auto &[k, v]: *this) { + if (KeyEqual{}(k, key)) { + return std::ref(v); + } + } + return {}; + } + + // Returns an iterator to an existing mapping for the given key, or the end() iterator otherwise. + const_iterator find(const key_type &key) const { + return const_cast(*this).find(key); + } + + iterator find(const key_type &key) { return find(key, begin()); } + + // Inserts a mapping unless it exists. Returns an iterator to the inserted or existing mapping, + // and whether the mapping was inserted. + // + // On emplace, if the map reaches its static or dynamic capacity, then all iterators are + // invalidated. Otherwise, only the end() iterator is invalidated. + // + template + std::pair try_emplace(const key_type &key, Args &&... args) { + if (const auto it = find(key); it != end()) { + return {it, false}; + } + + decltype(auto) ref_or_it = + map_.emplace_back(std::piecewise_construct, std::forward_as_tuple(key), + std::forward_as_tuple(std::forward(args)...)); + + if constexpr (static_capacity() > 0) { + return {&ref_or_it, true}; + } else { + return {ref_or_it, true}; + } + } + + // Replaces a mapping if it exists, and returns an iterator to it. Returns the end() iterator + // otherwise. + // + // The value is replaced via move constructor, so type V does not need to define copy/move + // assignment, e.g. its data members may be const. + // + // The arguments may directly or indirectly refer to the mapping being replaced. + // + // Iterators to the replaced mapping point to its replacement, and others remain valid. + // + template + iterator try_replace(const key_type &key, Args &&... args) { + const auto it = find(key); + if (it == end()) return it; + map_.replace(it, std::piecewise_construct, std::forward_as_tuple(key), + std::forward_as_tuple(std::forward(args)...)); + return it; + } + + // In-place counterpart of std::unordered_map's insert_or_assign. Returns true on emplace, or + // false on replace. + // + // The value is emplaced and replaced via move constructor, so type V does not need to define + // copy/move assignment, e.g. its data members may be const. + // + // On emplace, if the map reaches its static or dynamic capacity, then all iterators are + // invalidated. Otherwise, only the end() iterator is invalidated. On replace, iterators + // to the replaced mapping point to its replacement, and others remain valid. + // + template + std::pair emplace_or_replace(const key_type &key, Args &&... args) { + const auto [it, ok] = try_emplace(key, std::forward(args)...); + if (ok) return {it, ok}; + map_.replace(it, std::piecewise_construct, std::forward_as_tuple(key), + std::forward_as_tuple(std::forward(args)...)); + return {it, ok}; + } + + // Removes a mapping if it exists, and returns whether it did. + // + // The last() and end() iterators, as well as those to the erased mapping, are invalidated. + // + bool erase(const key_type &key) { return erase(key, begin()); } + + // Removes a mapping. + // + // The last() and end() iterators, as well as those to the erased mapping, are invalidated. + // + void erase(iterator it) { map_.unstable_erase(it); } + + // Removes all mappings. + // + // All iterators are invalidated. + // + void clear() { map_.clear(); } + + private: + iterator find(const key_type &key, iterator first) { + return std::find_if(first, end(), + [&key](const auto &pair) { return KeyEqual{}(pair.first, key); }); + } + + bool erase(const key_type &key, iterator first) { + const auto it = find(key, first); + if (it == end()) return false; + map_.unstable_erase(it); + return true; + } + + void deduplicate() { + for (auto it = begin(); it != end();) { + if (const auto key = it->first; ++it != end()) { + while (erase(key, it)); + } + } + } + + Map map_; + }; + +// Deduction guide for in-place constructor. + template + SmallMap(InitializerList, std::index_sequence, Types...> &&) + -> SmallMap; + +// Returns whether the key-value pairs of two maps are equal. + template + bool operator==(const SmallMap &lhs, const SmallMap &rhs) { + if (lhs.size() != rhs.size()) return false; + + for (const auto &[k, v]: lhs) { + const auto &lv = v; + if (!rhs.get(k).transform([&lv](const W &rv) { return lv == rv; }).value_or(false)) { + return false; + } + } + + return true; + } + +// TODO: Remove in C++20. + template + inline bool operator!=(const SmallMap &lhs, const SmallMap &rhs) { + return !(lhs == rhs); + } + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/small_vector.h b/sysbridge/src/main/cpp/android/ftl/small_vector.h new file mode 100644 index 0000000000..ebbe63f16e --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/small_vector.h @@ -0,0 +1,469 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +#include + +namespace android::ftl { + + template + struct is_small_vector; + +// ftl::StaticVector that promotes to std::vector when full. SmallVector is a drop-in replacement +// for std::vector with statically allocated storage for N elements, whose goal is to improve run +// time by avoiding heap allocation and increasing probability of cache hits. The standard API is +// augmented by an unstable_erase operation that does not preserve order, and a replace operation +// that destructively emplaces. +// +// Unlike std::vector, T does not require copy/move assignment, so may be an object with const data +// members, or be const itself. +// +// SmallVector is a specialization that thinly wraps std::vector. +// +// Example usage: +// +// ftl::SmallVector vector; +// assert(vector.empty()); +// assert(!vector.dynamic()); +// +// vector = {'a', 'b', 'c'}; +// assert(vector.size() == 3u); +// assert(!vector.dynamic()); +// +// vector.push_back('d'); +// assert(vector.dynamic()); +// +// vector.unstable_erase(vector.begin()); +// assert(vector == (ftl::SmallVector{'d', 'b', 'c'})); +// +// vector.pop_back(); +// assert(vector.back() == 'b'); +// assert(vector.dynamic()); +// +// const char array[] = "hi"; +// vector = ftl::SmallVector(array); +// assert(vector == (ftl::SmallVector{'h', 'i', '\0'})); +// assert(!vector.dynamic()); +// +// ftl::SmallVector strings = ftl::init::list("abc")("123456", 3u)(3u, '?'); +// assert(strings.size() == 3u); +// assert(!strings.dynamic()); +// +// assert(strings[0] == "abc"); +// assert(strings[1] == "123"); +// assert(strings[2] == "???"); +// + template + class SmallVector final : details::ArrayTraits, details::ArrayComparators { + using Static = StaticVector; + using Dynamic = SmallVector; + + public: + FTL_ARRAY_TRAIT(T, value_type); + FTL_ARRAY_TRAIT(T, size_type); + FTL_ARRAY_TRAIT(T, difference_type); + + FTL_ARRAY_TRAIT(T, pointer); + FTL_ARRAY_TRAIT(T, reference); + FTL_ARRAY_TRAIT(T, iterator); + FTL_ARRAY_TRAIT(T, reverse_iterator); + + FTL_ARRAY_TRAIT(T, const_pointer); + FTL_ARRAY_TRAIT(T, const_reference); + FTL_ARRAY_TRAIT(T, const_iterator); + FTL_ARRAY_TRAIT(T, const_reverse_iterator); + + // Creates an empty vector. + SmallVector() = default; + + // Constructs at most N elements. See StaticVector for underlying constructors. + template>{}>> + SmallVector(Arg &&arg, Args &&... args) + : vector_(std::in_place_type, std::forward(arg), + std::forward(args)...) {} + + // Copies or moves elements from a smaller convertible vector. + template 0)>> + SmallVector(SmallVector other) : vector_(convert(std::move(other))) {} + + void swap(SmallVector &other) { vector_.swap(other.vector_); } + + // Returns whether the vector is backed by static or dynamic storage. + bool dynamic() const { return std::holds_alternative(vector_); } + + // Avoid std::visit as it generates a dispatch table. +#define DISPATCH(T, F, ...) \ + T F() __VA_ARGS__ { \ + return dynamic() ? std::get(vector_).F() : std::get(vector_).F(); \ + } + + DISPATCH(size_type, max_size, const) + + DISPATCH(size_type, size, const) + + DISPATCH(bool, empty, const) + + DISPATCH(iterator, begin,) + + DISPATCH(const_iterator, begin, const) + + DISPATCH(const_iterator, cbegin, const) + + DISPATCH(iterator, end,) + + DISPATCH(const_iterator, end, const) + + DISPATCH(const_iterator, cend, const) + + DISPATCH(reverse_iterator, rbegin,) + + DISPATCH(const_reverse_iterator, rbegin, const) + + DISPATCH(const_reverse_iterator, crbegin, const) + + DISPATCH(reverse_iterator, rend,) + + DISPATCH(const_reverse_iterator, rend, const) + + DISPATCH(const_reverse_iterator, crend, const) + + DISPATCH(iterator, last,) + + DISPATCH(const_iterator, last, const) + + DISPATCH(reference, front,) + + DISPATCH(const_reference, front, const) + + DISPATCH(reference, back,) + + DISPATCH(const_reference, back, const) + + reference operator[](size_type i) { + return dynamic() ? std::get(vector_)[i] : std::get(vector_)[i]; + } + + const_reference + operator[](size_type i) const { return const_cast(*this)[i]; } + + // Replaces an element, and returns a reference to it. The iterator must be dereferenceable, so + // replacing at end() is erroneous. + // + // The element is emplaced via move constructor, so type T does not need to define copy/move + // assignment, e.g. its data members may be const. + // + // The arguments may directly or indirectly refer to the element being replaced. + // + // Iterators to the replaced element point to its replacement, and others remain valid. + // + template + reference replace(const_iterator it, Args &&... args) { + if (dynamic()) { + return std::get(vector_).replace(it, std::forward(args)...); + } else { + return std::get(vector_).replace(it, std::forward(args)...); + } + } + + // Appends an element, and returns a reference to it. + // + // If the vector reaches its static or dynamic capacity, then all iterators are invalidated. + // Otherwise, only the end() iterator is invalidated. + // + template + reference emplace_back(Args &&... args) { + constexpr auto kInsertStatic = &Static::template emplace_back; + constexpr auto kInsertDynamic = &Dynamic::template emplace_back; + return *insert(std::forward(args)...); + } + + // Appends an element. + // + // If the vector reaches its static or dynamic capacity, then all iterators are invalidated. + // Otherwise, only the end() iterator is invalidated. + // + void push_back(const value_type &v) { + constexpr auto kInsertStatic = + static_cast(&Static::push_back); + constexpr auto kInsertDynamic = + static_cast(&Dynamic::push_back); + insert(v); + } + + void push_back(value_type &&v) { + constexpr auto kInsertStatic = static_cast(&Static::push_back); + constexpr auto kInsertDynamic = + static_cast(&Dynamic::push_back); + insert(std::move(v)); + } + + // Removes the last element. The vector must not be empty, or the call is erroneous. + // + // The last() and end() iterators are invalidated. + // + DISPATCH(void, pop_back,) + + // Removes all elements. + // + // All iterators are invalidated. + // + DISPATCH(void, clear,) + +#undef DISPATCH + + // Erases an element, but does not preserve order. Rather than shifting subsequent elements, + // this moves the last element to the slot of the erased element. + // + // The last() and end() iterators, as well as those to the erased element, are invalidated. + // + void unstable_erase(iterator it) { + if (dynamic()) { + std::get(vector_).unstable_erase(it); + } else { + std::get(vector_).unstable_erase(it); + } + } + + // Extracts the elements as std::vector. + std::vector> promote() &&{ + if (dynamic()) { + return std::get(std::move(vector_)).promote(); + } else { + return {std::make_move_iterator(begin()), std::make_move_iterator(end())}; + } + } + + private: + template + friend + class SmallVector; + + template + static std::variant convert(SmallVector &&other) { + using Other = SmallVector; + + if (other.dynamic()) { + return std::get(std::move(other.vector_)); + } else { + return std::get(std::move(other.vector_)); + } + } + + template + auto insert(Args &&... args) { + if (Dynamic *const vector = std::get_if(&vector_)) { + return (vector->*InsertDynamic)(std::forward(args)...); + } + + auto &vector = std::get(vector_); + if (vector.full()) { + return (promote(vector).*InsertDynamic)(std::forward(args)...); + } else { + return (vector.*InsertStatic)(std::forward(args)...); + } + } + + Dynamic &promote(Static &static_vector) { + assert(static_vector.full()); + + // Allocate double capacity to reduce probability of reallocation. + Dynamic vector; + vector.reserve(Static::max_size() * 2); + std::move(static_vector.begin(), static_vector.end(), std::back_inserter(vector)); + + return vector_.template emplace(std::move(vector)); + } + + std::variant vector_; + }; + +// Partial specialization without static storage. + template + class SmallVector final : details::ArrayTraits, + details::ArrayComparators, + details::ArrayIterators, T>, + std::vector> { + using details::ArrayTraits::replace_at; + + using Iter = details::ArrayIterators; + using Impl = std::vector>; + + friend Iter; + + public: + FTL_ARRAY_TRAIT(T, value_type); + FTL_ARRAY_TRAIT(T, size_type); + FTL_ARRAY_TRAIT(T, difference_type); + + FTL_ARRAY_TRAIT(T, pointer); + FTL_ARRAY_TRAIT(T, reference); + FTL_ARRAY_TRAIT(T, iterator); + FTL_ARRAY_TRAIT(T, reverse_iterator); + + FTL_ARRAY_TRAIT(T, const_pointer); + FTL_ARRAY_TRAIT(T, const_reference); + FTL_ARRAY_TRAIT(T, const_iterator); + FTL_ARRAY_TRAIT(T, const_reverse_iterator); + + // See std::vector for underlying constructors. + using Impl::Impl; + + // Copies and moves a vector, respectively. + SmallVector(const SmallVector &) = default; + + SmallVector(SmallVector &&) = default; + + // Constructs elements in place. See StaticVector for underlying constructor. + template + SmallVector(InitializerList, Types...> &&list) + : SmallVector(SmallVector(std::move(list))) {} + + // Copies or moves elements from a convertible vector. + template + SmallVector(SmallVector other) : Impl(convert(std::move(other))) {} + + SmallVector &operator=(SmallVector other) { + // Define copy/move assignment in terms of copy/move construction. + swap(other); + return *this; + } + + void swap(SmallVector &other) { Impl::swap(other); } + + using Impl::empty; + using Impl::max_size; + using Impl::size; + + using Impl::reserve; + + // std::vector iterators are not necessarily raw pointers. + iterator begin() { return Impl::data(); } + + iterator end() { return Impl::data() + size(); } + + using Iter::begin; + using Iter::end; + + using Iter::cbegin; + using Iter::cend; + + using Iter::rbegin; + using Iter::rend; + + using Iter::crbegin; + using Iter::crend; + + using Iter::last; + + using Iter::back; + using Iter::front; + + using Iter::operator[]; + + template + reference replace(const_iterator it, Args &&... args) { + return replace_at(it, std::forward(args)...); + } + + template + iterator emplace_back(Args &&... args) { + return &Impl::emplace_back(std::forward(args)...); + } + + bool push_back(const value_type &v) { + Impl::push_back(v); + return true; + } + + bool push_back(value_type &&v) { + Impl::push_back(std::move(v)); + return true; + } + + using Impl::clear; + using Impl::pop_back; + + void unstable_erase(iterator it) { + if (it != last()) replace(it, std::move(back())); + pop_back(); + } + + std::vector> promote() &&{ return std::move(*this); } + + private: + template + static Impl convert(SmallVector &&other) { + if constexpr (std::is_constructible_v> &&>) { + return std::move(other).promote(); + } else { + SmallVector vector(other.size()); + + // Consistently with StaticVector, T only requires copy/move construction from U, rather than + // copy/move assignment. + auto it = vector.begin(); + for (auto &element: other) { + vector.replace(it++, std::move(element)); + } + + return vector; + } + } + }; + + template + struct is_small_vector : std::false_type { + }; + + template + struct is_small_vector> : std::true_type { + }; + +// Deduction guide for array constructor. + template + SmallVector(T (&)[N]) -> SmallVector, N>; + +// Deduction guide for variadic constructor. + template, + typename = std::enable_if_t<(std::is_constructible_v && ...)>> + SmallVector(T &&, Us &&...) -> SmallVector; + +// Deduction guide for in-place constructor. + template + SmallVector(InitializerList, Types...> &&) + -> SmallVector; + +// Deduction guide for StaticVector conversion. + template + SmallVector(StaticVector &&) -> SmallVector; + + template + inline void swap(SmallVector &lhs, SmallVector &rhs) { + lhs.swap(rhs); + } + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/static_vector.h b/sysbridge/src/main/cpp/android/ftl/static_vector.h new file mode 100644 index 0000000000..d14c12b390 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/static_vector.h @@ -0,0 +1,431 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace android::ftl { + + constexpr struct IteratorRangeTag { + } kIteratorRange; + +// Fixed-capacity, statically allocated counterpart of std::vector. Like std::array, StaticVector +// allocates contiguous storage for N elements of type T at compile time, but stores at most (rather +// than exactly) N elements. Unlike std::array, its default constructor does not require T to have a +// default constructor, since elements are constructed in place as the vector grows. Operations that +// insert an element (emplace_back, push_back, etc.) fail when the vector is full. The API otherwise +// adheres to standard containers, except the unstable_erase operation that does not preserve order, +// and the replace operation that destructively emplaces. +// +// Unlike std::vector, T does not require copy/move assignment, so may be an object with const data +// members, or be const itself. +// +// StaticVector is analogous to an iterable std::optional. +// StaticVector is an error. +// +// Example usage: +// +// ftl::StaticVector vector; +// assert(vector.empty()); +// +// vector = {'a', 'b'}; +// assert(vector.size() == 2u); +// +// vector.push_back('c'); +// assert(vector.full()); +// +// assert(!vector.push_back('d')); +// assert(vector.size() == 3u); +// +// vector.unstable_erase(vector.begin()); +// assert(vector == (ftl::StaticVector{'c', 'b'})); +// +// vector.pop_back(); +// assert(vector.back() == 'c'); +// +// const char array[] = "hi"; +// vector = ftl::StaticVector(array); +// assert(vector == (ftl::StaticVector{'h', 'i', '\0'})); +// +// ftl::StaticVector strings = ftl::init::list("abc")("123456", 3u)(3u, '?'); +// assert(strings.size() == 3u); +// assert(strings[0] == "abc"); +// assert(strings[1] == "123"); +// assert(strings[2] == "???"); +// + template + class StaticVector final : details::ArrayTraits, + details::ArrayIterators, T>, + details::ArrayComparators { + static_assert(N > 0); + + // For constructor that moves from a smaller convertible vector. + template + friend + class StaticVector; + + using details::ArrayTraits::construct_at; + using details::ArrayTraits::replace_at; + using details::ArrayTraits::in_place_swap_ranges; + using details::ArrayTraits::uninitialized_copy; + + using Iter = details::ArrayIterators; + friend Iter; + + // There is ambiguity when constructing from two iterator-like elements like pointers: + // they could be an iterator range, or arguments for in-place construction. Assume the + // latter unless they are input iterators and cannot be used to construct elements. If + // the former is intended, the caller can pass an IteratorRangeTag to disambiguate. + template> + using is_input_iterator = + std::conjunction, + std::negation>>; + + public: + FTL_ARRAY_TRAIT(T, value_type); + FTL_ARRAY_TRAIT(T, size_type); + FTL_ARRAY_TRAIT(T, difference_type); + + FTL_ARRAY_TRAIT(T, pointer); + FTL_ARRAY_TRAIT(T, reference); + FTL_ARRAY_TRAIT(T, iterator); + FTL_ARRAY_TRAIT(T, reverse_iterator); + + FTL_ARRAY_TRAIT(T, const_pointer); + FTL_ARRAY_TRAIT(T, const_reference); + FTL_ARRAY_TRAIT(T, const_iterator); + FTL_ARRAY_TRAIT(T, const_reverse_iterator); + + // Creates an empty vector. + StaticVector() = default; + + // Copies and moves a vector, respectively. + StaticVector(const StaticVector &other) + : StaticVector(kIteratorRange, other.begin(), other.end()) {} + + StaticVector(StaticVector &&other) { swap(other); } + + // Copies at most N elements from a smaller convertible vector. + template + StaticVector(const StaticVector &other) + : StaticVector(kIteratorRange, other.begin(), other.end()) { + static_assert(N >= M, "Insufficient capacity"); + } + + // Copies at most N elements from a smaller convertible array. + template + explicit StaticVector(U (&array)[M]) + : StaticVector(kIteratorRange, std::begin(array), std::end(array)) { + static_assert(N >= M, "Insufficient capacity"); + } + + // Copies at most N elements from the range [first, last). + // + // IteratorRangeTag disambiguates with initialization from two iterator-like elements. + // + template{}>> + StaticVector(Iterator first, Iterator last) : StaticVector(kIteratorRange, first, last) { + using V = typename std::iterator_traits::value_type; + static_assert(std::is_constructible_v, "Incompatible iterator range"); + } + + template + StaticVector(IteratorRangeTag, Iterator first, Iterator last) + : size_(std::min(max_size(), static_cast(std::distance(first, last)))) { + uninitialized_copy(first, first + size_, begin()); + } + + // Moves at most N elements from a smaller convertible vector. + template + StaticVector(StaticVector &&other) { + static_assert(N >= M, "Insufficient capacity"); + + // Same logic as swap, though M need not be equal to N. + std::uninitialized_move(other.begin(), other.end(), begin()); + std::destroy(other.begin(), other.end()); + std::swap(size_, other.size_); + } + + // Constructs at most N elements. The template arguments T and N are inferred using the + // deduction guide defined below. Note that T is determined from the first element, and + // subsequent elements must have convertible types: + // + // ftl::StaticVector vector = {1, 2, 3}; + // static_assert(std::is_same_v>); + // + // const auto copy = "quince"s; + // auto move = "tart"s; + // ftl::StaticVector vector = {copy, std::move(move)}; + // + // static_assert(std::is_same_v>); + // + template>> + StaticVector(E &&element, Es &&... elements) + : StaticVector(std::index_sequence<0>{}, std::forward(element), + std::forward(elements)...) { + static_assert(sizeof...(elements) < N, "Too many elements"); + } + + // Constructs at most N elements in place by forwarding per-element constructor arguments. The + // template arguments T and N are inferred using the deduction guide defined below. The syntax + // for listing arguments is as follows: + // + // ftl::StaticVector vector = ftl::init::list("abc")()(3u, '?'); + // + // static_assert(std::is_same_v>); + // assert(vector.full()); + // assert(vector[0] == "abc"); + // assert(vector[1].empty()); + // assert(vector[2] == "???"); + // + template + StaticVector(InitializerList, Types...> &&list) + : StaticVector(std::index_sequence<0, 0, Size>{}, std::make_index_sequence{}, + std::index_sequence{}, list.tuple) { + static_assert(sizeof...(Sizes) < N, "Too many elements"); + } + + ~StaticVector() { std::destroy(begin(), end()); } + + StaticVector &operator=(const StaticVector &other) { + StaticVector copy(other); + swap(copy); + return *this; + } + + StaticVector &operator=(StaticVector &&other) { + clear(); + swap(other); + return *this; + } + + // IsEmpty enables a fast path when the vector is known to be empty at compile time. + template + void swap(StaticVector &); + + static constexpr size_type max_size() { return N; } + + size_type size() const { return size_; } + + bool empty() const { return size() == 0; } + + bool full() const { return size() == max_size(); } + + iterator begin() { return std::launder(reinterpret_cast(data_)); } + + iterator end() { return begin() + size(); } + + using Iter::begin; + using Iter::end; + + using Iter::cbegin; + using Iter::cend; + + using Iter::rbegin; + using Iter::rend; + + using Iter::crbegin; + using Iter::crend; + + using Iter::last; + + using Iter::back; + using Iter::front; + + using Iter::operator[]; + + // Replaces an element, and returns a reference to it. The iterator must be dereferenceable, so + // replacing at end() is erroneous. + // + // The element is emplaced via move constructor, so type T does not need to define copy/move + // assignment, e.g. its data members may be const. + // + // The arguments may directly or indirectly refer to the element being replaced. + // + // Iterators to the replaced element point to its replacement, and others remain valid. + // + template + reference replace(const_iterator it, Args &&... args) { + return replace_at(it, std::forward(args)...); + } + + // Appends an element, and returns an iterator to it. If the vector is full, the element is not + // inserted, and the end() iterator is returned. + // + // On success, the end() iterator is invalidated. + // + template + iterator emplace_back(Args &&... args) { + if (full()) return end(); + const iterator it = construct_at(end(), std::forward(args)...); + ++size_; + return it; + } + + // Appends an element unless the vector is full, and returns whether the element was inserted. + // + // On success, the end() iterator is invalidated. + // + bool push_back(const value_type &v) { + // Two statements for sequence point. + const iterator it = emplace_back(v); + return it != end(); + } + + bool push_back(value_type &&v) { + // Two statements for sequence point. + const iterator it = emplace_back(std::move(v)); + return it != end(); + } + + // Removes the last element. The vector must not be empty, or the call is erroneous. + // + // The last() and end() iterators are invalidated. + // + void pop_back() { unstable_erase(last()); } + + // Removes all elements. + // + // All iterators are invalidated. + // + void clear() { + std::destroy(begin(), end()); + size_ = 0; + } + + // Erases an element, but does not preserve order. Rather than shifting subsequent elements, + // this moves the last element to the slot of the erased element. + // + // The last() and end() iterators, as well as those to the erased element, are invalidated. + // + void unstable_erase(const_iterator it) { + std::destroy_at(it); + if (it != last()) { + // Move last element and destroy its source for destructor side effects. This is only + // safe because exceptions are disabled. + construct_at(it, std::move(back())); + std::destroy_at(last()); + } + --size_; + } + + private: + // Recursion for variadic constructor. + template + StaticVector(std::index_sequence, E &&element, Es &&... elements) + : StaticVector(std::index_sequence{}, std::forward(elements)...) { + construct_at(begin() + I, std::forward(element)); + } + + // Base case for variadic constructor. + template + explicit StaticVector(std::index_sequence) : size_(I) {} + + // Recursion for in-place constructor. + // + // Construct element I by extracting its arguments from the InitializerList tuple. ArgIndex + // is the position of its first argument in Args, and ArgCount is the number of arguments. + // The Indices sequence corresponds to [0, ArgCount). + // + // The Sizes sequence lists the argument counts for elements after I, so Size is the ArgCount + // for the next element. The recursion stops when Sizes is empty for the last element. + // + template + StaticVector(std::index_sequence, std::index_sequence, + std::index_sequence, std::tuple &tuple) + : StaticVector(std::index_sequence{}, + std::make_index_sequence{}, std::index_sequence{}, + tuple) { + construct_at(begin() + I, std::move(std::get(tuple))...); + } + + // Base case for in-place constructor. + template + StaticVector(std::index_sequence, std::index_sequence, + std::index_sequence<>, std::tuple &tuple) + : size_(I + 1) { + construct_at(begin() + I, std::move(std::get(tuple))...); + } + + size_type size_ = 0; + std::aligned_storage_t data_[N]; + }; + +// Deduction guide for array constructor. + template + StaticVector(T (&)[N]) -> StaticVector, N>; + +// Deduction guide for variadic constructor. + template, + typename = std::enable_if_t<(std::is_constructible_v && ...)>> + StaticVector(T &&, Us &&...) -> StaticVector; + +// Deduction guide for in-place constructor. + template + StaticVector(InitializerList, Types...> &&) + -> StaticVector; + + template + template + void StaticVector::swap(StaticVector &other) { + auto [to, from] = std::make_pair(this, &other); + if (from == this) return; + + // Assume this vector has fewer elements, so the excess of the other vector will be moved to it. + auto [min, max] = std::make_pair(size(), other.size()); + + // No elements to swap if moving into an empty vector. + if constexpr (IsEmpty) { + assert(min == 0); + } else { + if (min > max) { + std::swap(from, to); + std::swap(min, max); + } + + // Swap elements [0, min). + in_place_swap_ranges(begin(), begin() + min, other.begin()); + + // No elements to move if sizes are equal. + if (min == max) return; + } + + // Move elements [min, max) and destroy their source for destructor side effects. + const auto [first, last] = std::make_pair(from->begin() + min, from->begin() + max); + std::uninitialized_move(first, last, to->begin() + min); + std::destroy(first, last); + + std::swap(size_, other.size_); + } + + template + inline void swap(StaticVector &lhs, StaticVector &rhs) { + lhs.swap(rhs); + } + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/string.h b/sysbridge/src/main/cpp/android/ftl/string.h new file mode 100644 index 0000000000..46e45e8169 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/string.h @@ -0,0 +1,105 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace android::ftl { + + enum class Radix { + kBin = 2, kDec = 10, kHex = 16 + }; + + template + struct to_chars_length { + static_assert(std::is_integral_v); + // Maximum binary digits, plus minus sign and radix prefix. + static constexpr std::size_t value = + std::numeric_limits>::digits + 3; + }; + + template + constexpr std::size_t to_chars_length_v = to_chars_length::value; + + template + using to_chars_buffer_t = char[to_chars_length_v]; + +// Lightweight (not allocating nor sprintf-based) alternative to std::to_string for integers, with +// optional radix. See also ftl::to_string below. +// +// ftl::to_chars_buffer_t<> buffer; +// +// assert(ftl::to_chars(buffer, 123u) == "123"); +// assert(ftl::to_chars(buffer, -42, ftl::Radix::kBin) == "-0b101010"); +// assert(ftl::to_chars(buffer, 0xcafe, ftl::Radix::kHex) == "0xcafe"); +// assert(ftl::to_chars(buffer, '*', ftl::Radix::kHex) == "0x2a"); +// + template + std::string_view to_chars(char (&buffer)[N], T v, Radix radix = Radix::kDec) { + static_assert(N >= to_chars_length_v); + + auto begin = buffer + 2; + const auto [end, err] = std::to_chars(begin, buffer + N, v, static_cast(radix)); + assert(err == std::errc()); + + if (radix == Radix::kDec) { + // TODO: Replace with {begin, end} in C++20. + return {begin, static_cast(end - begin)}; + } + + const auto prefix = radix == Radix::kBin ? 'b' : 'x'; + if constexpr (std::is_unsigned_v) { + buffer[0] = '0'; + buffer[1] = prefix; + } else { + if (*begin == '-') { + *buffer = '-'; + } else { + --begin; + } + + *begin-- = prefix; + *begin = '0'; + } + + // TODO: Replace with {buffer, end} in C++20. + return {buffer, static_cast(end - buffer)}; + } + +// Lightweight (not sprintf-based) alternative to std::to_string for integers, with optional radix. +// +// assert(ftl::to_string(123u) == "123"); +// assert(ftl::to_string(-42, ftl::Radix::kBin) == "-0b101010"); +// assert(ftl::to_string(0xcafe, ftl::Radix::kHex) == "0xcafe"); +// assert(ftl::to_string('*', ftl::Radix::kHex) == "0x2a"); +// + template + inline std::string to_string(T v, Radix radix = Radix::kDec) { + to_chars_buffer_t buffer; + return std::string(to_chars(buffer, v, radix)); + } + + std::string to_string(bool) = delete; + + std::string to_string(bool, Radix) = delete; + +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/ftl/unit.h b/sysbridge/src/main/cpp/android/ftl/unit.h new file mode 100644 index 0000000000..2dd71e43a1 --- /dev/null +++ b/sysbridge/src/main/cpp/android/ftl/unit.h @@ -0,0 +1,79 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +namespace android::ftl { + +// The unit type, and its only value. + constexpr struct Unit { + } unit; + + constexpr bool operator==(Unit, Unit) { + return true; + } + + constexpr bool operator!=(Unit, Unit) { + return false; + } + +// Adapts a function object F to return Unit. The return value of F is ignored. +// +// As a practical use, the function passed to ftl::Optional::transform is not allowed to return +// void (cf. https://wg21.link/P0798R8#mapping-functions-returning-void), but may return Unit if +// only its side effects are meaningful: +// +// ftl::Optional opt = "food"s; +// opt.transform(ftl::unit_fn([](std::string& str) { str.pop_back(); })); +// assert(opt == "foo"s); +// + template + struct UnitFn { + F f; + + template + Unit operator()(Args &&... args) { + return f(std::forward(args)...), unit; + } + }; + + template + constexpr auto unit_fn(F &&f) -> UnitFn> { + return {std::forward(f)}; + } + + namespace details { + +// Identity function for all T except Unit, which maps to void. + template + struct UnitToVoid { + template + static auto from(U &&value) { + return value; + } + }; + + template<> + struct UnitToVoid { + template + static void from(U &&) {} + }; + + } // namespace details +} // namespace android::ftl diff --git a/sysbridge/src/main/cpp/android/input/Input.cpp b/sysbridge/src/main/cpp/android/input/Input.cpp new file mode 100644 index 0000000000..7af317a488 --- /dev/null +++ b/sysbridge/src/main/cpp/android/input/Input.cpp @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include + +#define LOG_TAG "Input" + +namespace android { + + bool isFromSource(uint32_t source, uint32_t test) { + return (source & test) == test; + } +} \ No newline at end of file diff --git a/sysbridge/src/main/cpp/android/input/Input.h b/sysbridge/src/main/cpp/android/input/Input.h new file mode 100644 index 0000000000..b07479be2a --- /dev/null +++ b/sysbridge/src/main/cpp/android/input/Input.h @@ -0,0 +1,71 @@ + +/* + * Flags that flow alongside events in the input dispatch system to help with certain + * policy decisions such as waking from device sleep. + * + * These flags are also defined in frameworks/base/core/java/android/view/WindowManagerPolicy.java. + */ +enum { + /* These flags originate in RawEvents and are generally set in the key map. + * NOTE: If you want a flag to be able to set in a keylayout file, then you must add it to + * InputEventLabels.h as well. */ + + // Indicates that the event should wake the device. + POLICY_FLAG_WAKE = 0x00000001, + + // Indicates that the key is virtual, such as a capacitive button, and should + // generate haptic feedback. Virtual keys may be suppressed for some time + // after a recent touch to prevent accidental activation of virtual keys adjacent + // to the touch screen during an edge swipe. + POLICY_FLAG_VIRTUAL = 0x00000002, + + // Indicates that the key is the special function modifier. + POLICY_FLAG_FUNCTION = 0x00000004, + + // Indicates that the key represents a special gesture that has been detected by + // the touch firmware or driver. Causes touch events from the same device to be canceled. + // This policy flag prevents key events from changing touch mode state. + POLICY_FLAG_GESTURE = 0x00000008, + + // Indicates that key usage mapping represents a fallback mapping. + // Fallback mappings cannot be used to definitively determine whether a device + // supports a key code. For example, a HID device can report a key press + // as a HID usage code if it is not mapped to any linux key code in the kernel. + // However, we cannot know which HID usage codes that device supports from + // userspace through the evdev. We can use fallback mappings to convert HID + // usage codes to Android key codes without needing to know if a device can + // actually report the usage code. + POLICY_FLAG_FALLBACK_USAGE_MAPPING = 0x00000010, + + POLICY_FLAG_RAW_MASK = 0x0000ffff, + + /* These flags are set by the input dispatcher. */ + + // Indicates that the input event was injected. + POLICY_FLAG_INJECTED = 0x01000000, + + // Indicates that the input event is from a trusted source such as a directly attached + // input device or an application with system-wide event injection permission. + POLICY_FLAG_TRUSTED = 0x02000000, + + // Indicates that the input event has passed through an input filter. + POLICY_FLAG_FILTERED = 0x04000000, + + // Disables automatic key repeating behavior. + POLICY_FLAG_DISABLE_KEY_REPEAT = 0x08000000, + + /* These flags are set by the input reader policy as it intercepts each event. */ + + // Indicates that the device was in an interactive state when the + // event was intercepted. + POLICY_FLAG_INTERACTIVE = 0x20000000, + + // Indicates that the event should be dispatched to applications. + // The input event should still be sent to the InputDispatcher so that it can see all + // input events received include those that it will not deliver. + POLICY_FLAG_PASS_TO_USER = 0x40000000, +}; + +namespace android { + bool isFromSource(uint32_t source, uint32_t test); +} \ No newline at end of file diff --git a/sysbridge/src/main/cpp/android/input/InputDevice.cpp b/sysbridge/src/main/cpp/android/input/InputDevice.cpp new file mode 100644 index 0000000000..66c72d20a7 --- /dev/null +++ b/sysbridge/src/main/cpp/android/input/InputDevice.cpp @@ -0,0 +1,358 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +#include +#include +#include + +#include "../logging.h" +#include "../libbase/stringprintf.h" +#include +#include "InputDevice.h" +#include "InputEventLabels.h" +#include "../ui/LogicalDisplayId.h" + +using android::base::StringPrintf; + +namespace android { + +// Set to true to log detailed debugging messages about IDC file probing. + static constexpr bool DEBUG_PROBE = false; + + static const char *CONFIGURATION_FILE_DIR[] = { + "idc/", + "keylayout/", + "keychars/", + }; + + static const char *CONFIGURATION_FILE_EXTENSION[] = { + ".idc", + ".kl", + ".kcm", + }; + + static bool isValidNameChar(char ch) { + return isascii(ch) && (isdigit(ch) || isalpha(ch) || ch == '-' || ch == '_'); + } + + static void appendInputDeviceConfigurationFileRelativePath(std::string &path, + const std::string &name, + InputDeviceConfigurationFileType type) { + path += CONFIGURATION_FILE_DIR[static_cast(type)]; + path += name; + path += CONFIGURATION_FILE_EXTENSION[static_cast(type)]; + } + + std::string getInputDeviceConfigurationFilePathByDeviceIdentifier( + const InputDeviceIdentifier &deviceIdentifier, InputDeviceConfigurationFileType type, + const char *suffix) { + if (deviceIdentifier.vendor != 0 && deviceIdentifier.product != 0) { + if (deviceIdentifier.version != 0) { + // Try vendor product version. + std::string versionPath = + getInputDeviceConfigurationFilePathByName( + StringPrintf("Vendor_%04x_Product_%" + "04x_Version_%04x%s", + deviceIdentifier.vendor, + deviceIdentifier.product, + deviceIdentifier.version, + suffix), + type); + if (!versionPath.empty()) { + LOGI("Found key layout map by version path %s", versionPath.c_str()); + return versionPath; + } + } + + // Try vendor product. + std::string productPath = + getInputDeviceConfigurationFilePathByName( + StringPrintf("Vendor_%04x_Product_%04x%s", + deviceIdentifier.vendor, + deviceIdentifier.product, + suffix), + type); + if (!productPath.empty()) { + LOGI("Found key layout map by product path %s", productPath.c_str()); + return productPath; + } + } + + // Try device name. + std::string namePath = getInputDeviceConfigurationFilePathByName( + deviceIdentifier.getCanonicalName() + suffix, + type); + + if (!namePath.empty()) { + LOGI("Found key layout map by name path %s", namePath.c_str()); + return namePath; + } + + // As a last resort, just use the Generic file. + return getInputDeviceConfigurationFilePathByName("Generic", type); + } + + std::string getInputDeviceConfigurationFilePathByName( + const std::string &name, InputDeviceConfigurationFileType type) { + // Search system repository. + std::string path; + + // Treblized input device config files will be located /product/usr, /system_ext/usr, + // /odm/usr or /vendor/usr. + std::vector pathPrefixes{ + "/product/usr/", + "/system_ext/usr/", + "/odm/usr/", + "/vendor/usr/", + "/system/usr", + }; + // These files may also be in the APEX pointed by input_device.config_file.apex sysprop. +// if (auto apex = GetProperty("input_device.config_file.apex", ""); !apex.empty()) { +// pathPrefixes.push_back("/apex/" + apex + "/etc/usr/"); +// } + // ANDROID_ROOT may not be set on host + if (auto android_root = getenv("ANDROID_ROOT"); android_root != nullptr) { + pathPrefixes.push_back(std::string(android_root) + "/usr/"); + } + for (const auto &prefix: pathPrefixes) { + path = prefix; + appendInputDeviceConfigurationFileRelativePath(path, name, type); + if (!access(path.c_str(), R_OK)) { + if (DEBUG_PROBE) { + LOGI("Found system-provided input device configuration file at %s", + path.c_str()); + } + return path; + } else if (errno != ENOENT) { + if (DEBUG_PROBE) { + LOGW("Couldn't find a system-provided input device configuration file at %s due to error %d (%s); there may be an IDC file there that cannot be loaded.", + path.c_str(), errno, strerror(errno)); + } + } else { + if (DEBUG_PROBE) { + LOGE("Didn't find system-provided input device configuration file at %s: %s", + path.c_str(), strerror(errno)); + } + } + } + + // Search user repository. + // TODO Should only look here if not in safe mode. + path = ""; + char *androidData = getenv("ANDROID_DATA"); + if (androidData != nullptr) { + path += androidData; + } + path += "/system/devices/"; + appendInputDeviceConfigurationFileRelativePath(path, name, type); + if (!access(path.c_str(), R_OK)) { + if (DEBUG_PROBE) { + LOGI("Found system user input device configuration file at %s", path.c_str()); + } + return path; + } else if (errno != ENOENT) { + LOGW("Couldn't find a system user input device configuration file at %s due to error %d (%s); there may be an IDC file there that cannot be loaded.", + path.c_str(), errno, strerror(errno)); + } else { + if (DEBUG_PROBE) { + LOGE("Didn't find system user input device configuration file at %s: %s", + path.c_str(), strerror(errno)); + } + } + + // Not found. + if (DEBUG_PROBE) { + LOGI("Probe failed to find input device configuration file with name '%s' and type %s", + name.c_str(), ftl::enum_string(type).c_str()); + } + + + return ""; + } + +// --- InputDeviceIdentifier + + std::string InputDeviceIdentifier::getCanonicalName() const { + std::string replacedName = name; + for (char &ch: replacedName) { + if (!isValidNameChar(ch)) { + ch = '_'; + } + } + return replacedName; + } + + +// --- InputDeviceInfo --- + + InputDeviceInfo::InputDeviceInfo() { + initialize(-1, 0, -1, InputDeviceIdentifier(), "", false, false, + ui::LogicalDisplayId::INVALID); + } + + InputDeviceInfo::InputDeviceInfo(const InputDeviceInfo &other) + : mId(other.mId), + mGeneration(other.mGeneration), + mControllerNumber(other.mControllerNumber), + mIdentifier(other.mIdentifier), + mAlias(other.mAlias), + mIsExternal(other.mIsExternal), + mHasMic(other.mHasMic), + mKeyboardLayoutInfo(other.mKeyboardLayoutInfo), + mSources(other.mSources), + mKeyboardType(other.mKeyboardType), + mUsiVersion(other.mUsiVersion), + mAssociatedDisplayId(other.mAssociatedDisplayId), + mEnabled(other.mEnabled), + mHasVibrator(other.mHasVibrator), + mHasBattery(other.mHasBattery), + mHasButtonUnderPad(other.mHasButtonUnderPad), + mHasSensor(other.mHasSensor), + mMotionRanges(other.mMotionRanges), + mSensors(other.mSensors), + mLights(other.mLights), + mViewBehavior(other.mViewBehavior) {} + + InputDeviceInfo &InputDeviceInfo::operator=(const InputDeviceInfo &other) { + mId = other.mId; + mGeneration = other.mGeneration; + mControllerNumber = other.mControllerNumber; + mIdentifier = other.mIdentifier; + mAlias = other.mAlias; + mIsExternal = other.mIsExternal; + mHasMic = other.mHasMic; + mKeyboardLayoutInfo = other.mKeyboardLayoutInfo; + mSources = other.mSources; + mKeyboardType = other.mKeyboardType; + mUsiVersion = other.mUsiVersion; + mAssociatedDisplayId = other.mAssociatedDisplayId; + mEnabled = other.mEnabled; + mHasVibrator = other.mHasVibrator; + mHasBattery = other.mHasBattery; + mHasButtonUnderPad = other.mHasButtonUnderPad; + mHasSensor = other.mHasSensor; + mMotionRanges = other.mMotionRanges; + mSensors = other.mSensors; + mLights = other.mLights; + mViewBehavior = other.mViewBehavior; + return *this; + } + + InputDeviceInfo::~InputDeviceInfo() { + } + + void InputDeviceInfo::initialize(int32_t id, int32_t generation, int32_t controllerNumber, + const InputDeviceIdentifier &identifier, + const std::string &alias, + bool isExternal, bool hasMic, + ui::LogicalDisplayId associatedDisplayId, + InputDeviceViewBehavior viewBehavior, bool enabled) { + mId = id; + mGeneration = generation; + mControllerNumber = controllerNumber; + mIdentifier = identifier; + mAlias = alias; + mIsExternal = isExternal; + mHasMic = hasMic; + mSources = 0; + mKeyboardType = AINPUT_KEYBOARD_TYPE_NONE; + mAssociatedDisplayId = associatedDisplayId; + mEnabled = enabled; + mHasVibrator = false; + mHasBattery = false; + mHasButtonUnderPad = false; + mHasSensor = false; + mViewBehavior = viewBehavior; + mUsiVersion.reset(); + mMotionRanges.clear(); + mSensors.clear(); + mLights.clear(); + } + + const InputDeviceInfo::MotionRange *InputDeviceInfo::getMotionRange( + int32_t axis, uint32_t source) const { + for (const MotionRange &range: mMotionRanges) { + if (range.axis == axis && isFromSource(range.source, source)) { + return ⦥ + } + } + return nullptr; + } + + void InputDeviceInfo::addSource(uint32_t source) { + mSources |= source; + } + + void InputDeviceInfo::addMotionRange(int32_t axis, uint32_t source, float min, float max, + float flat, float fuzz, float resolution) { + MotionRange range = {axis, source, min, max, flat, fuzz, resolution}; + mMotionRanges.push_back(range); + } + + void InputDeviceInfo::addMotionRange(const MotionRange &range) { + mMotionRanges.push_back(range); + } + + void InputDeviceInfo::addSensorInfo(const InputDeviceSensorInfo &info) { + if (mSensors.find(info.type) != mSensors.end()) { + LOGW("Sensor type %s already exists, will be replaced by new sensor added.", + ftl::enum_string(info.type).c_str()); + } + mSensors.insert_or_assign(info.type, info); + } + + void InputDeviceInfo::addBatteryInfo(const InputDeviceBatteryInfo &info) { + if (mBatteries.find(info.id) != mBatteries.end()) { + LOGW("Battery id %d already exists, will be replaced by new battery added.", info.id); + } + mBatteries.insert_or_assign(info.id, info); + } + + void InputDeviceInfo::addLightInfo(const InputDeviceLightInfo &info) { + if (mLights.find(info.id) != mLights.end()) { + LOGW("Light id %d already exists, will be replaced by new light added.", info.id); + } + mLights.insert_or_assign(info.id, info); + } + + void InputDeviceInfo::setKeyboardType(int32_t keyboardType) { + mKeyboardType = keyboardType; + } + + void InputDeviceInfo::setKeyboardLayoutInfo(KeyboardLayoutInfo layoutInfo) { + mKeyboardLayoutInfo = std::move(layoutInfo); + } + + std::vector InputDeviceInfo::getSensors() { + std::vector infos; + infos.reserve(mSensors.size()); + for (const auto &[type, info]: mSensors) { + infos.push_back(info); + } + return infos; + } + + std::vector InputDeviceInfo::getLights() { + std::vector infos; + infos.reserve(mLights.size()); + for (const auto &[id, info]: mLights) { + infos.push_back(info); + } + return infos; + } + +} // namespace android \ No newline at end of file diff --git a/sysbridge/src/main/cpp/android/input/InputDevice.h b/sysbridge/src/main/cpp/android/input/InputDevice.h new file mode 100644 index 0000000000..c7af07cc20 --- /dev/null +++ b/sysbridge/src/main/cpp/android/input/InputDevice.h @@ -0,0 +1,455 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include "../ftl/flags.h" +#include "../ftl/mixins.h" +#include "Input.h" +#include +#include +#include +#include "../ui/LogicalDisplayId.h" + +namespace android { + +/* + * Identifies a device. + */ + struct InputDeviceIdentifier { + inline InputDeviceIdentifier() : + bus(0), vendor(0), product(0), version(0) { + } + + // Information provided by the kernel. + std::string name; + std::string location; + std::string uniqueId; + int bus; + int vendor; + int product; + int version; + + // A composite input device descriptor string that uniquely identifies the device + // even across reboots or reconnections. The value of this field is used by + // upper layers of the input system to associate settings with individual devices. + // It is hashed from whatever kernel provided information is available. + // Ideally, the way this value is computed should not change between Android releases + // because that would invalidate persistent settings that rely on it. + std::string descriptor; + + // A value added to uniquely identify a device in the absence of a unique id. This + // is intended to be a minimum way to distinguish from other active devices and may + // reuse values that are not associated with an input anymore. + uint16_t nonce; + + // The bluetooth address of the device, if known. + std::optional bluetoothAddress; + + /** + * Return InputDeviceIdentifier.name that has been adjusted as follows: + * - all characters besides alphanumerics, dash, + * and underscore have been replaced with underscores. + * This helps in situations where a file that matches the device name is needed, + * while conforming to the filename limitations. + */ + std::string getCanonicalName() const; + + bool operator==(const InputDeviceIdentifier &) const = default; + + bool operator!=(const InputDeviceIdentifier &) const = default; + }; + +/** + * Holds View related behaviors for an InputDevice. + */ + struct InputDeviceViewBehavior { + /** + * The smooth scroll behavior that applies for all source/axis, if defined by the device. + * Empty optional if the device has not specified the default smooth scroll behavior. + */ + std::optional shouldSmoothScroll; + }; + +/* Types of input device sensors. Keep sync with core/java/android/hardware/Sensor.java */ + enum class InputDeviceSensorType : int32_t { + ACCELEROMETER = ASENSOR_TYPE_ACCELEROMETER, + MAGNETIC_FIELD = ASENSOR_TYPE_MAGNETIC_FIELD, + ORIENTATION = 3, + GYROSCOPE = ASENSOR_TYPE_GYROSCOPE, + LIGHT = ASENSOR_TYPE_LIGHT, + PRESSURE = ASENSOR_TYPE_PRESSURE, + TEMPERATURE = 7, + PROXIMITY = ASENSOR_TYPE_PROXIMITY, + GRAVITY = ASENSOR_TYPE_GRAVITY, + LINEAR_ACCELERATION = ASENSOR_TYPE_LINEAR_ACCELERATION, + ROTATION_VECTOR = ASENSOR_TYPE_ROTATION_VECTOR, + RELATIVE_HUMIDITY = ASENSOR_TYPE_RELATIVE_HUMIDITY, + AMBIENT_TEMPERATURE = ASENSOR_TYPE_AMBIENT_TEMPERATURE, + MAGNETIC_FIELD_UNCALIBRATED = ASENSOR_TYPE_MAGNETIC_FIELD_UNCALIBRATED, + GAME_ROTATION_VECTOR = ASENSOR_TYPE_GAME_ROTATION_VECTOR, + GYROSCOPE_UNCALIBRATED = ASENSOR_TYPE_GYROSCOPE_UNCALIBRATED, + SIGNIFICANT_MOTION = ASENSOR_TYPE_SIGNIFICANT_MOTION, + + ftl_first = ACCELEROMETER, + ftl_last = SIGNIFICANT_MOTION + }; + + enum class InputDeviceSensorAccuracy : int32_t { + NONE = 0, + LOW = 1, + MEDIUM = 2, + HIGH = 3, + + ftl_last = HIGH, + }; + + enum class InputDeviceSensorReportingMode : int32_t { + CONTINUOUS = 0, + ON_CHANGE = 1, + ONE_SHOT = 2, + SPECIAL_TRIGGER = 3, + }; + + enum class InputDeviceLightType : int32_t { + INPUT = 0, + PLAYER_ID = 1, + KEYBOARD_BACKLIGHT = 2, + KEYBOARD_MIC_MUTE = 3, + KEYBOARD_VOLUME_MUTE = 4, + + ftl_last = KEYBOARD_VOLUME_MUTE + }; + + enum class InputDeviceLightCapability : uint32_t { + /** Capability to change brightness of the light */ + BRIGHTNESS = 0x00000001, + /** Capability to change color of the light */ + RGB = 0x00000002, + }; + + struct InputDeviceSensorInfo { + explicit InputDeviceSensorInfo(std::string name, std::string vendor, int32_t version, + InputDeviceSensorType type, + InputDeviceSensorAccuracy accuracy, + float maxRange, float resolution, float power, + int32_t minDelay, + int32_t fifoReservedEventCount, int32_t fifoMaxEventCount, + std::string stringType, int32_t maxDelay, int32_t flags, + int32_t id) + : name(name), + vendor(vendor), + version(version), + type(type), + accuracy(accuracy), + maxRange(maxRange), + resolution(resolution), + power(power), + minDelay(minDelay), + fifoReservedEventCount(fifoReservedEventCount), + fifoMaxEventCount(fifoMaxEventCount), + stringType(stringType), + maxDelay(maxDelay), + flags(flags), + id(id) {} + + // Name string of the sensor. + std::string name; + // Vendor string of this sensor. + std::string vendor; + // Version of the sensor's module. + int32_t version; + // Generic type of this sensor. + InputDeviceSensorType type; + // The current accuracy of sensor event. + InputDeviceSensorAccuracy accuracy; + // Maximum range of the sensor in the sensor's unit. + float maxRange; + // Resolution of the sensor in the sensor's unit. + float resolution; + // The power in mA used by this sensor while in use. + float power; + // The minimum delay allowed between two events in microsecond or zero if this sensor only + // returns a value when the data it's measuring changes. + int32_t minDelay; + // Number of events reserved for this sensor in the batch mode FIFO. + int32_t fifoReservedEventCount; + // Maximum number of events of this sensor that could be batched. + int32_t fifoMaxEventCount; + // The type of this sensor as a string. + std::string stringType; + // The delay between two sensor events corresponding to the lowest frequency that this sensor + // supports. + int32_t maxDelay; + // Sensor flags + int32_t flags; + // Sensor id, same as the input device ID it belongs to. + int32_t id; + }; + + struct BrightnessLevel : ftl::DefaultConstructible, + ftl::Equatable, + ftl::Orderable, + ftl::Addable { + using DefaultConstructible::DefaultConstructible; + }; + + struct InputDeviceLightInfo { + explicit InputDeviceLightInfo(std::string name, int32_t id, InputDeviceLightType type, + ftl::Flags capabilityFlags, + int32_t ordinal, + std::set preferredBrightnessLevels) + : name(name), + id(id), + type(type), + capabilityFlags(capabilityFlags), + ordinal(ordinal), + preferredBrightnessLevels(std::move(preferredBrightnessLevels)) {} + + // Name string of the light. + std::string name; + // Light id + int32_t id; + // Type of the light. + InputDeviceLightType type; + // Light capabilities. + ftl::Flags capabilityFlags; + // Ordinal of the light + int32_t ordinal; + // Custom brightness levels for the light + std::set preferredBrightnessLevels; + }; + + struct InputDeviceBatteryInfo { + explicit InputDeviceBatteryInfo(std::string name, int32_t id) : name(name), id(id) {} + + // Name string of the battery. + std::string name; + // Battery id + int32_t id; + }; + + struct KeyboardLayoutInfo { + explicit KeyboardLayoutInfo(std::string languageTag, std::string layoutType) + : languageTag(languageTag), layoutType(layoutType) {} + + // A BCP 47 conformant language tag such as "en-US". + std::string languageTag; + // The layout type such as QWERTY or AZERTY. + std::string layoutType; + + inline bool operator==(const KeyboardLayoutInfo &other) const { + return languageTag == other.languageTag && layoutType == other.layoutType; + } + + inline bool operator!=(const KeyboardLayoutInfo &other) const { return !(*this == other); } + }; + +// The version of the Universal Stylus Initiative (USI) protocol supported by the input device. + struct InputDeviceUsiVersion { + int32_t majorVersion = -1; + int32_t minorVersion = -1; + }; + +/* + * Describes the characteristics and capabilities of an input device. + */ + class InputDeviceInfo { + public: + InputDeviceInfo(); + + InputDeviceInfo(const InputDeviceInfo &other); + + InputDeviceInfo &operator=(const InputDeviceInfo &other); + + ~InputDeviceInfo(); + + struct MotionRange { + int32_t axis; + uint32_t source; + float min; + float max; + float flat; + float fuzz; + float resolution; + }; + + void initialize(int32_t id, int32_t generation, int32_t controllerNumber, + const InputDeviceIdentifier &identifier, const std::string &alias, + bool isExternal, bool hasMic, ui::LogicalDisplayId associatedDisplayId, + InputDeviceViewBehavior viewBehavior = {{}}, bool enabled = true); + + inline int32_t getId() const { return mId; } + + inline int32_t getControllerNumber() const { return mControllerNumber; } + + inline int32_t getGeneration() const { return mGeneration; } + + inline const InputDeviceIdentifier &getIdentifier() const { return mIdentifier; } + + inline const std::string &getAlias() const { return mAlias; } + + inline const std::string &getDisplayName() const { + return mAlias.empty() ? mIdentifier.name : mAlias; + } + + inline bool isExternal() const { return mIsExternal; } + + inline bool hasMic() const { return mHasMic; } + + inline uint32_t getSources() const { return mSources; } + + const MotionRange *getMotionRange(int32_t axis, uint32_t source) const; + + void addSource(uint32_t source); + + void addMotionRange(int32_t axis, uint32_t source, + float min, float max, float flat, float fuzz, float resolution); + + void addMotionRange(const MotionRange &range); + + void addSensorInfo(const InputDeviceSensorInfo &info); + + void addBatteryInfo(const InputDeviceBatteryInfo &info); + + void addLightInfo(const InputDeviceLightInfo &info); + + void setKeyboardType(int32_t keyboardType); + + inline int32_t getKeyboardType() const { return mKeyboardType; } + + void setKeyboardLayoutInfo(KeyboardLayoutInfo keyboardLayoutInfo); + + inline const std::optional &getKeyboardLayoutInfo() const { + return mKeyboardLayoutInfo; + } + + inline const InputDeviceViewBehavior &getViewBehavior() const { return mViewBehavior; } + + inline void setVibrator(bool hasVibrator) { mHasVibrator = hasVibrator; } + + inline bool hasVibrator() const { return mHasVibrator; } + + inline void setHasBattery(bool hasBattery) { mHasBattery = hasBattery; } + + inline bool hasBattery() const { return mHasBattery; } + + inline void setButtonUnderPad(bool hasButton) { mHasButtonUnderPad = hasButton; } + + inline bool hasButtonUnderPad() const { return mHasButtonUnderPad; } + + inline void setHasSensor(bool hasSensor) { mHasSensor = hasSensor; } + + inline bool hasSensor() const { return mHasSensor; } + + inline const std::vector &getMotionRanges() const { + return mMotionRanges; + } + + std::vector getSensors(); + + std::vector getLights(); + + inline void setUsiVersion(std::optional usiVersion) { + mUsiVersion = std::move(usiVersion); + } + + inline std::optional getUsiVersion() const { return mUsiVersion; } + + inline ui::LogicalDisplayId getAssociatedDisplayId() const { return mAssociatedDisplayId; } + + inline void setEnabled(bool enabled) { mEnabled = enabled; } + + inline bool isEnabled() const { return mEnabled; } + + private: + int32_t mId; + int32_t mGeneration; + int32_t mControllerNumber; + InputDeviceIdentifier mIdentifier; + std::string mAlias; + bool mIsExternal; + bool mHasMic; + std::optional mKeyboardLayoutInfo; + uint32_t mSources; + int32_t mKeyboardType; + std::optional mUsiVersion; + ui::LogicalDisplayId mAssociatedDisplayId{ui::LogicalDisplayId::INVALID}; + bool mEnabled; + + bool mHasVibrator; + bool mHasBattery; + bool mHasButtonUnderPad; + bool mHasSensor; + + std::vector mMotionRanges; + std::unordered_map mSensors; + /* Map from light ID to light info */ + std::unordered_map mLights; + /* Map from battery ID to battery info */ + std::unordered_map mBatteries; + /** The View related behaviors for the device. */ + InputDeviceViewBehavior mViewBehavior; + }; + +/* Types of input device configuration files. */ + enum class InputDeviceConfigurationFileType : int32_t { + CONFIGURATION = 0, /* .idc file */ + KEY_LAYOUT = 1, /* .kl file */ + KEY_CHARACTER_MAP = 2, /* .kcm file */ + ftl_last = KEY_CHARACTER_MAP, + }; + +/* + * Gets the path of an input device configuration file, if one is available. + * Considers both system provided and user installed configuration files. + * The optional suffix is appended to the end of the file name (before the + * extension). + * + * The device identifier is used to construct several default configuration file + * names to try based on the device name, vendor, product, and version. + * + * Returns an empty string if not found. + */ + extern std::string getInputDeviceConfigurationFilePathByDeviceIdentifier( + const InputDeviceIdentifier &deviceIdentifier, InputDeviceConfigurationFileType type, + const char *suffix = ""); + +/* + * Gets the path of an input device configuration file, if one is available. + * Considers both system provided and user installed configuration files. + * + * The name is case-sensitive and is used to construct the filename to resolve. + * All characters except 'a'-'z', 'A'-'Z', '0'-'9', '-', and '_' are replaced by underscores. + * + * Returns an empty string if not found. + */ + extern std::string getInputDeviceConfigurationFilePathByName( + const std::string &name, InputDeviceConfigurationFileType type); + + enum ReservedInputDeviceId : int32_t { + // Device id representing an invalid device + INVALID_INPUT_DEVICE_ID = -2, + // Device id of a special "virtual" keyboard that is always present. + VIRTUAL_KEYBOARD_ID = -1, + // Device id of the "built-in" keyboard if there is one. + BUILT_IN_KEYBOARD_ID = 0, + // First device id available for dynamic devices + END_RESERVED_ID = 1, + }; + +} // namespace android diff --git a/sysbridge/src/main/cpp/android/input/InputEventLabels.cpp b/sysbridge/src/main/cpp/android/input/InputEventLabels.cpp new file mode 100644 index 0000000000..d8d3c5b0ca --- /dev/null +++ b/sysbridge/src/main/cpp/android/input/InputEventLabels.cpp @@ -0,0 +1,1422 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "InputEventLabels.h" + +#include +#include +#include +#include "Input.h" + +#define DEFINE_KEYCODE(key) { #key, AKEYCODE_##key } +#define DEFINE_AXIS(axis) { #axis, AMOTION_EVENT_AXIS_##axis } +#define DEFINE_FLAG(flag) { #flag, POLICY_FLAG_##flag } + +namespace android { + +// clang-format off + +// NOTE: If you add a new keycode here you must also add it to several other files. +// Refer to frameworks/base/core/java/android/view/KeyEvent.java for the full list. +#define KEYCODES_SEQUENCE \ + DEFINE_KEYCODE(UNKNOWN), \ + DEFINE_KEYCODE(SOFT_LEFT), \ + DEFINE_KEYCODE(SOFT_RIGHT), \ + DEFINE_KEYCODE(HOME), \ + DEFINE_KEYCODE(BACK), \ + DEFINE_KEYCODE(CALL), \ + DEFINE_KEYCODE(ENDCALL), \ + DEFINE_KEYCODE(0), \ + DEFINE_KEYCODE(1), \ + DEFINE_KEYCODE(2), \ + DEFINE_KEYCODE(3), \ + DEFINE_KEYCODE(4), \ + DEFINE_KEYCODE(5), \ + DEFINE_KEYCODE(6), \ + DEFINE_KEYCODE(7), \ + DEFINE_KEYCODE(8), \ + DEFINE_KEYCODE(9), \ + DEFINE_KEYCODE(STAR), \ + DEFINE_KEYCODE(POUND), \ + DEFINE_KEYCODE(DPAD_UP), \ + DEFINE_KEYCODE(DPAD_DOWN), \ + DEFINE_KEYCODE(DPAD_LEFT), \ + DEFINE_KEYCODE(DPAD_RIGHT), \ + DEFINE_KEYCODE(DPAD_CENTER), \ + DEFINE_KEYCODE(VOLUME_UP), \ + DEFINE_KEYCODE(VOLUME_DOWN), \ + DEFINE_KEYCODE(POWER), \ + DEFINE_KEYCODE(CAMERA), \ + DEFINE_KEYCODE(CLEAR), \ + DEFINE_KEYCODE(A), \ + DEFINE_KEYCODE(B), \ + DEFINE_KEYCODE(C), \ + DEFINE_KEYCODE(D), \ + DEFINE_KEYCODE(E), \ + DEFINE_KEYCODE(F), \ + DEFINE_KEYCODE(G), \ + DEFINE_KEYCODE(H), \ + DEFINE_KEYCODE(I), \ + DEFINE_KEYCODE(J), \ + DEFINE_KEYCODE(K), \ + DEFINE_KEYCODE(L), \ + DEFINE_KEYCODE(M), \ + DEFINE_KEYCODE(N), \ + DEFINE_KEYCODE(O), \ + DEFINE_KEYCODE(P), \ + DEFINE_KEYCODE(Q), \ + DEFINE_KEYCODE(R), \ + DEFINE_KEYCODE(S), \ + DEFINE_KEYCODE(T), \ + DEFINE_KEYCODE(U), \ + DEFINE_KEYCODE(V), \ + DEFINE_KEYCODE(W), \ + DEFINE_KEYCODE(X), \ + DEFINE_KEYCODE(Y), \ + DEFINE_KEYCODE(Z), \ + DEFINE_KEYCODE(COMMA), \ + DEFINE_KEYCODE(PERIOD), \ + DEFINE_KEYCODE(ALT_LEFT), \ + DEFINE_KEYCODE(ALT_RIGHT), \ + DEFINE_KEYCODE(SHIFT_LEFT), \ + DEFINE_KEYCODE(SHIFT_RIGHT), \ + DEFINE_KEYCODE(TAB), \ + DEFINE_KEYCODE(SPACE), \ + DEFINE_KEYCODE(SYM), \ + DEFINE_KEYCODE(EXPLORER), \ + DEFINE_KEYCODE(ENVELOPE), \ + DEFINE_KEYCODE(ENTER), \ + DEFINE_KEYCODE(DEL), \ + DEFINE_KEYCODE(GRAVE), \ + DEFINE_KEYCODE(MINUS), \ + DEFINE_KEYCODE(EQUALS), \ + DEFINE_KEYCODE(LEFT_BRACKET), \ + DEFINE_KEYCODE(RIGHT_BRACKET), \ + DEFINE_KEYCODE(BACKSLASH), \ + DEFINE_KEYCODE(SEMICOLON), \ + DEFINE_KEYCODE(APOSTROPHE), \ + DEFINE_KEYCODE(SLASH), \ + DEFINE_KEYCODE(AT), \ + DEFINE_KEYCODE(NUM), \ + DEFINE_KEYCODE(HEADSETHOOK), \ + DEFINE_KEYCODE(FOCUS), \ + DEFINE_KEYCODE(PLUS), \ + DEFINE_KEYCODE(MENU), \ + DEFINE_KEYCODE(NOTIFICATION), \ + DEFINE_KEYCODE(SEARCH), \ + DEFINE_KEYCODE(MEDIA_PLAY_PAUSE), \ + DEFINE_KEYCODE(MEDIA_STOP), \ + DEFINE_KEYCODE(MEDIA_NEXT), \ + DEFINE_KEYCODE(MEDIA_PREVIOUS), \ + DEFINE_KEYCODE(MEDIA_REWIND), \ + DEFINE_KEYCODE(MEDIA_FAST_FORWARD), \ + DEFINE_KEYCODE(MUTE), \ + DEFINE_KEYCODE(PAGE_UP), \ + DEFINE_KEYCODE(PAGE_DOWN), \ + DEFINE_KEYCODE(PICTSYMBOLS), \ + DEFINE_KEYCODE(SWITCH_CHARSET), \ + DEFINE_KEYCODE(BUTTON_A), \ + DEFINE_KEYCODE(BUTTON_B), \ + DEFINE_KEYCODE(BUTTON_C), \ + DEFINE_KEYCODE(BUTTON_X), \ + DEFINE_KEYCODE(BUTTON_Y), \ + DEFINE_KEYCODE(BUTTON_Z), \ + DEFINE_KEYCODE(BUTTON_L1), \ + DEFINE_KEYCODE(BUTTON_R1), \ + DEFINE_KEYCODE(BUTTON_L2), \ + DEFINE_KEYCODE(BUTTON_R2), \ + DEFINE_KEYCODE(BUTTON_THUMBL), \ + DEFINE_KEYCODE(BUTTON_THUMBR), \ + DEFINE_KEYCODE(BUTTON_START), \ + DEFINE_KEYCODE(BUTTON_SELECT), \ + DEFINE_KEYCODE(BUTTON_MODE), \ + DEFINE_KEYCODE(ESCAPE), \ + DEFINE_KEYCODE(FORWARD_DEL), \ + DEFINE_KEYCODE(CTRL_LEFT), \ + DEFINE_KEYCODE(CTRL_RIGHT), \ + DEFINE_KEYCODE(CAPS_LOCK), \ + DEFINE_KEYCODE(SCROLL_LOCK), \ + DEFINE_KEYCODE(META_LEFT), \ + DEFINE_KEYCODE(META_RIGHT), \ + DEFINE_KEYCODE(FUNCTION), \ + DEFINE_KEYCODE(SYSRQ), \ + DEFINE_KEYCODE(BREAK), \ + DEFINE_KEYCODE(MOVE_HOME), \ + DEFINE_KEYCODE(MOVE_END), \ + DEFINE_KEYCODE(INSERT), \ + DEFINE_KEYCODE(FORWARD), \ + DEFINE_KEYCODE(MEDIA_PLAY), \ + DEFINE_KEYCODE(MEDIA_PAUSE), \ + DEFINE_KEYCODE(MEDIA_CLOSE), \ + DEFINE_KEYCODE(MEDIA_EJECT), \ + DEFINE_KEYCODE(MEDIA_RECORD), \ + DEFINE_KEYCODE(F1), \ + DEFINE_KEYCODE(F2), \ + DEFINE_KEYCODE(F3), \ + DEFINE_KEYCODE(F4), \ + DEFINE_KEYCODE(F5), \ + DEFINE_KEYCODE(F6), \ + DEFINE_KEYCODE(F7), \ + DEFINE_KEYCODE(F8), \ + DEFINE_KEYCODE(F9), \ + DEFINE_KEYCODE(F10), \ + DEFINE_KEYCODE(F11), \ + DEFINE_KEYCODE(F12), \ + DEFINE_KEYCODE(NUM_LOCK), \ + DEFINE_KEYCODE(NUMPAD_0), \ + DEFINE_KEYCODE(NUMPAD_1), \ + DEFINE_KEYCODE(NUMPAD_2), \ + DEFINE_KEYCODE(NUMPAD_3), \ + DEFINE_KEYCODE(NUMPAD_4), \ + DEFINE_KEYCODE(NUMPAD_5), \ + DEFINE_KEYCODE(NUMPAD_6), \ + DEFINE_KEYCODE(NUMPAD_7), \ + DEFINE_KEYCODE(NUMPAD_8), \ + DEFINE_KEYCODE(NUMPAD_9), \ + DEFINE_KEYCODE(NUMPAD_DIVIDE), \ + DEFINE_KEYCODE(NUMPAD_MULTIPLY), \ + DEFINE_KEYCODE(NUMPAD_SUBTRACT), \ + DEFINE_KEYCODE(NUMPAD_ADD), \ + DEFINE_KEYCODE(NUMPAD_DOT), \ + DEFINE_KEYCODE(NUMPAD_COMMA), \ + DEFINE_KEYCODE(NUMPAD_ENTER), \ + DEFINE_KEYCODE(NUMPAD_EQUALS), \ + DEFINE_KEYCODE(NUMPAD_LEFT_PAREN), \ + DEFINE_KEYCODE(NUMPAD_RIGHT_PAREN), \ + DEFINE_KEYCODE(VOLUME_MUTE), \ + DEFINE_KEYCODE(INFO), \ + DEFINE_KEYCODE(CHANNEL_UP), \ + DEFINE_KEYCODE(CHANNEL_DOWN), \ + DEFINE_KEYCODE(ZOOM_IN), \ + DEFINE_KEYCODE(ZOOM_OUT), \ + DEFINE_KEYCODE(TV), \ + DEFINE_KEYCODE(WINDOW), \ + DEFINE_KEYCODE(GUIDE), \ + DEFINE_KEYCODE(DVR), \ + DEFINE_KEYCODE(BOOKMARK), \ + DEFINE_KEYCODE(CAPTIONS), \ + DEFINE_KEYCODE(SETTINGS), \ + DEFINE_KEYCODE(TV_POWER), \ + DEFINE_KEYCODE(TV_INPUT), \ + DEFINE_KEYCODE(STB_POWER), \ + DEFINE_KEYCODE(STB_INPUT), \ + DEFINE_KEYCODE(AVR_POWER), \ + DEFINE_KEYCODE(AVR_INPUT), \ + DEFINE_KEYCODE(PROG_RED), \ + DEFINE_KEYCODE(PROG_GREEN), \ + DEFINE_KEYCODE(PROG_YELLOW), \ + DEFINE_KEYCODE(PROG_BLUE), \ + DEFINE_KEYCODE(APP_SWITCH), \ + DEFINE_KEYCODE(BUTTON_1), \ + DEFINE_KEYCODE(BUTTON_2), \ + DEFINE_KEYCODE(BUTTON_3), \ + DEFINE_KEYCODE(BUTTON_4), \ + DEFINE_KEYCODE(BUTTON_5), \ + DEFINE_KEYCODE(BUTTON_6), \ + DEFINE_KEYCODE(BUTTON_7), \ + DEFINE_KEYCODE(BUTTON_8), \ + DEFINE_KEYCODE(BUTTON_9), \ + DEFINE_KEYCODE(BUTTON_10), \ + DEFINE_KEYCODE(BUTTON_11), \ + DEFINE_KEYCODE(BUTTON_12), \ + DEFINE_KEYCODE(BUTTON_13), \ + DEFINE_KEYCODE(BUTTON_14), \ + DEFINE_KEYCODE(BUTTON_15), \ + DEFINE_KEYCODE(BUTTON_16), \ + DEFINE_KEYCODE(LANGUAGE_SWITCH), \ + DEFINE_KEYCODE(MANNER_MODE), \ + DEFINE_KEYCODE(3D_MODE), \ + DEFINE_KEYCODE(CONTACTS), \ + DEFINE_KEYCODE(CALENDAR), \ + DEFINE_KEYCODE(MUSIC), \ + DEFINE_KEYCODE(CALCULATOR), \ + DEFINE_KEYCODE(ZENKAKU_HANKAKU), \ + DEFINE_KEYCODE(EISU), \ + DEFINE_KEYCODE(MUHENKAN), \ + DEFINE_KEYCODE(HENKAN), \ + DEFINE_KEYCODE(KATAKANA_HIRAGANA), \ + DEFINE_KEYCODE(YEN), \ + DEFINE_KEYCODE(RO), \ + DEFINE_KEYCODE(KANA), \ + DEFINE_KEYCODE(ASSIST), \ + DEFINE_KEYCODE(BRIGHTNESS_DOWN), \ + DEFINE_KEYCODE(BRIGHTNESS_UP), \ + DEFINE_KEYCODE(MEDIA_AUDIO_TRACK), \ + DEFINE_KEYCODE(SLEEP), \ + DEFINE_KEYCODE(WAKEUP), \ + DEFINE_KEYCODE(PAIRING), \ + DEFINE_KEYCODE(MEDIA_TOP_MENU), \ + DEFINE_KEYCODE(11), \ + DEFINE_KEYCODE(12), \ + DEFINE_KEYCODE(LAST_CHANNEL), \ + DEFINE_KEYCODE(TV_DATA_SERVICE), \ + DEFINE_KEYCODE(VOICE_ASSIST), \ + DEFINE_KEYCODE(TV_RADIO_SERVICE), \ + DEFINE_KEYCODE(TV_TELETEXT), \ + DEFINE_KEYCODE(TV_NUMBER_ENTRY), \ + DEFINE_KEYCODE(TV_TERRESTRIAL_ANALOG), \ + DEFINE_KEYCODE(TV_TERRESTRIAL_DIGITAL), \ + DEFINE_KEYCODE(TV_SATELLITE), \ + DEFINE_KEYCODE(TV_SATELLITE_BS), \ + DEFINE_KEYCODE(TV_SATELLITE_CS), \ + DEFINE_KEYCODE(TV_SATELLITE_SERVICE), \ + DEFINE_KEYCODE(TV_NETWORK), \ + DEFINE_KEYCODE(TV_ANTENNA_CABLE), \ + DEFINE_KEYCODE(TV_INPUT_HDMI_1), \ + DEFINE_KEYCODE(TV_INPUT_HDMI_2), \ + DEFINE_KEYCODE(TV_INPUT_HDMI_3), \ + DEFINE_KEYCODE(TV_INPUT_HDMI_4), \ + DEFINE_KEYCODE(TV_INPUT_COMPOSITE_1), \ + DEFINE_KEYCODE(TV_INPUT_COMPOSITE_2), \ + DEFINE_KEYCODE(TV_INPUT_COMPONENT_1), \ + DEFINE_KEYCODE(TV_INPUT_COMPONENT_2), \ + DEFINE_KEYCODE(TV_INPUT_VGA_1), \ + DEFINE_KEYCODE(TV_AUDIO_DESCRIPTION), \ + DEFINE_KEYCODE(TV_AUDIO_DESCRIPTION_MIX_UP), \ + DEFINE_KEYCODE(TV_AUDIO_DESCRIPTION_MIX_DOWN), \ + DEFINE_KEYCODE(TV_ZOOM_MODE), \ + DEFINE_KEYCODE(TV_CONTENTS_MENU), \ + DEFINE_KEYCODE(TV_MEDIA_CONTEXT_MENU), \ + DEFINE_KEYCODE(TV_TIMER_PROGRAMMING), \ + DEFINE_KEYCODE(HELP), \ + DEFINE_KEYCODE(NAVIGATE_PREVIOUS), \ + DEFINE_KEYCODE(NAVIGATE_NEXT), \ + DEFINE_KEYCODE(NAVIGATE_IN), \ + DEFINE_KEYCODE(NAVIGATE_OUT), \ + DEFINE_KEYCODE(STEM_PRIMARY), \ + DEFINE_KEYCODE(STEM_1), \ + DEFINE_KEYCODE(STEM_2), \ + DEFINE_KEYCODE(STEM_3), \ + DEFINE_KEYCODE(DPAD_UP_LEFT), \ + DEFINE_KEYCODE(DPAD_DOWN_LEFT), \ + DEFINE_KEYCODE(DPAD_UP_RIGHT), \ + DEFINE_KEYCODE(DPAD_DOWN_RIGHT), \ + DEFINE_KEYCODE(MEDIA_SKIP_FORWARD), \ + DEFINE_KEYCODE(MEDIA_SKIP_BACKWARD), \ + DEFINE_KEYCODE(MEDIA_STEP_FORWARD), \ + DEFINE_KEYCODE(MEDIA_STEP_BACKWARD), \ + DEFINE_KEYCODE(SOFT_SLEEP), \ + DEFINE_KEYCODE(CUT), \ + DEFINE_KEYCODE(COPY), \ + DEFINE_KEYCODE(PASTE), \ + DEFINE_KEYCODE(SYSTEM_NAVIGATION_UP), \ + DEFINE_KEYCODE(SYSTEM_NAVIGATION_DOWN), \ + DEFINE_KEYCODE(SYSTEM_NAVIGATION_LEFT), \ + DEFINE_KEYCODE(SYSTEM_NAVIGATION_RIGHT), \ + DEFINE_KEYCODE(ALL_APPS), \ + DEFINE_KEYCODE(REFRESH), \ + DEFINE_KEYCODE(THUMBS_UP), \ + DEFINE_KEYCODE(THUMBS_DOWN), \ + DEFINE_KEYCODE(PROFILE_SWITCH), \ + DEFINE_KEYCODE(VIDEO_APP_1), \ + DEFINE_KEYCODE(VIDEO_APP_2), \ + DEFINE_KEYCODE(VIDEO_APP_3), \ + DEFINE_KEYCODE(VIDEO_APP_4), \ + DEFINE_KEYCODE(VIDEO_APP_5), \ + DEFINE_KEYCODE(VIDEO_APP_6), \ + DEFINE_KEYCODE(VIDEO_APP_7), \ + DEFINE_KEYCODE(VIDEO_APP_8), \ + DEFINE_KEYCODE(FEATURED_APP_1), \ + DEFINE_KEYCODE(FEATURED_APP_2), \ + DEFINE_KEYCODE(FEATURED_APP_3), \ + DEFINE_KEYCODE(FEATURED_APP_4), \ + DEFINE_KEYCODE(DEMO_APP_1), \ + DEFINE_KEYCODE(DEMO_APP_2), \ + DEFINE_KEYCODE(DEMO_APP_3), \ + DEFINE_KEYCODE(DEMO_APP_4), \ + DEFINE_KEYCODE(KEYBOARD_BACKLIGHT_DOWN), \ + DEFINE_KEYCODE(KEYBOARD_BACKLIGHT_UP), \ + DEFINE_KEYCODE(KEYBOARD_BACKLIGHT_TOGGLE), \ + DEFINE_KEYCODE(STYLUS_BUTTON_PRIMARY), \ + DEFINE_KEYCODE(STYLUS_BUTTON_SECONDARY), \ + DEFINE_KEYCODE(STYLUS_BUTTON_TERTIARY), \ + DEFINE_KEYCODE(STYLUS_BUTTON_TAIL), \ + DEFINE_KEYCODE(RECENT_APPS), \ + DEFINE_KEYCODE(MACRO_1), \ + DEFINE_KEYCODE(MACRO_2), \ + DEFINE_KEYCODE(MACRO_3), \ + DEFINE_KEYCODE(MACRO_4) +// DEFINE_KEYCODE(EMOJI_PICKER), \ +// DEFINE_KEYCODE(SCREENSHOT), \ +// DEFINE_KEYCODE(DICTATE), \ +// DEFINE_KEYCODE(NEW), \ +// DEFINE_KEYCODE(CLOSE), \ +// DEFINE_KEYCODE(DO_NOT_DISTURB), \ +// DEFINE_KEYCODE(PRINT), \ +// DEFINE_KEYCODE(LOCK), \ +// DEFINE_KEYCODE(FULLSCREEN), \ +// DEFINE_KEYCODE(F13), \ +// DEFINE_KEYCODE(F14), \ +// DEFINE_KEYCODE(F15), \ +// DEFINE_KEYCODE(F16), \ +// DEFINE_KEYCODE(F17), \ +// DEFINE_KEYCODE(F18), \ +// DEFINE_KEYCODE(F19),\ +// DEFINE_KEYCODE(F20), \ +// DEFINE_KEYCODE(F21), \ +// DEFINE_KEYCODE(F22), \ +// DEFINE_KEYCODE(F23), \ +// DEFINE_KEYCODE(F24) + +// NOTE: If you add a new axis here you must also add it to several other files. +// Refer to frameworks/base/core/java/android/view/MotionEvent.java for the full list. +#define AXES_SEQUENCE \ + DEFINE_AXIS(X), \ + DEFINE_AXIS(Y), \ + DEFINE_AXIS(PRESSURE), \ + DEFINE_AXIS(SIZE), \ + DEFINE_AXIS(TOUCH_MAJOR), \ + DEFINE_AXIS(TOUCH_MINOR), \ + DEFINE_AXIS(TOOL_MAJOR), \ + DEFINE_AXIS(TOOL_MINOR), \ + DEFINE_AXIS(ORIENTATION), \ + DEFINE_AXIS(VSCROLL), \ + DEFINE_AXIS(HSCROLL), \ + DEFINE_AXIS(Z), \ + DEFINE_AXIS(RX), \ + DEFINE_AXIS(RY), \ + DEFINE_AXIS(RZ), \ + DEFINE_AXIS(HAT_X), \ + DEFINE_AXIS(HAT_Y), \ + DEFINE_AXIS(LTRIGGER), \ + DEFINE_AXIS(RTRIGGER), \ + DEFINE_AXIS(THROTTLE), \ + DEFINE_AXIS(RUDDER), \ + DEFINE_AXIS(WHEEL), \ + DEFINE_AXIS(GAS), \ + DEFINE_AXIS(BRAKE), \ + DEFINE_AXIS(DISTANCE), \ + DEFINE_AXIS(TILT), \ + DEFINE_AXIS(SCROLL), \ + DEFINE_AXIS(RELATIVE_X), \ + DEFINE_AXIS(RELATIVE_Y), \ + {"RESERVED_29", 29}, \ + {"RESERVED_30", 30}, \ + {"RESERVED_31", 31}, \ + DEFINE_AXIS(GENERIC_1), \ + DEFINE_AXIS(GENERIC_2), \ + DEFINE_AXIS(GENERIC_3), \ + DEFINE_AXIS(GENERIC_4), \ + DEFINE_AXIS(GENERIC_5), \ + DEFINE_AXIS(GENERIC_6), \ + DEFINE_AXIS(GENERIC_7), \ + DEFINE_AXIS(GENERIC_8), \ + DEFINE_AXIS(GENERIC_9), \ + DEFINE_AXIS(GENERIC_10), \ + DEFINE_AXIS(GENERIC_11), \ + DEFINE_AXIS(GENERIC_12), \ + DEFINE_AXIS(GENERIC_13), \ + DEFINE_AXIS(GENERIC_14), \ + DEFINE_AXIS(GENERIC_15), \ + DEFINE_AXIS(GENERIC_16), \ + DEFINE_AXIS(GESTURE_X_OFFSET), \ + DEFINE_AXIS(GESTURE_Y_OFFSET), \ + DEFINE_AXIS(GESTURE_SCROLL_X_DISTANCE), \ + DEFINE_AXIS(GESTURE_SCROLL_Y_DISTANCE), \ + DEFINE_AXIS(GESTURE_PINCH_SCALE_FACTOR), \ + DEFINE_AXIS(GESTURE_SWIPE_FINGER_COUNT) + +#define FLAGS_SEQUENCE \ + DEFINE_FLAG(VIRTUAL), \ + DEFINE_FLAG(FUNCTION), \ + DEFINE_FLAG(GESTURE), \ + DEFINE_FLAG(WAKE), \ + DEFINE_FLAG(FALLBACK_USAGE_MAPPING) + +// clang-format on + +// --- InputEventLookup --- + + InputEventLookup::InputEventLookup() + : KEYCODES({KEYCODES_SEQUENCE}), + KEY_NAMES({KEYCODES_SEQUENCE}), + AXES({AXES_SEQUENCE}), + AXES_NAMES({AXES_SEQUENCE}), + FLAGS({FLAGS_SEQUENCE}) {} + + std::optional InputEventLookup::lookupValueByLabel( + const std::unordered_map &map, const char *literal) { + std::string str(literal); + auto it = map.find(str); + return it != map.end() ? std::make_optional(it->second) : std::nullopt; + } + + const char *InputEventLookup::lookupLabelByValue(const std::vector &vec, + int value) { + if (static_cast(value) < vec.size()) { + return vec[value].literal; + } + return nullptr; + } + + std::optional InputEventLookup::getKeyCodeByLabel(const char *label) { + const auto &self = get(); + return self.lookupValueByLabel(self.KEYCODES, label); + } + + const char *InputEventLookup::getLabelByKeyCode(int32_t keyCode) { + const auto &self = get(); + if (keyCode >= 0 && static_cast(keyCode) < self.KEYCODES.size()) { + return get().lookupLabelByValue(self.KEY_NAMES, keyCode); + } + return nullptr; + } + + std::optional InputEventLookup::getKeyFlagByLabel(const char *label) { + const auto &self = get(); + return lookupValueByLabel(self.FLAGS, label); + } + + std::optional InputEventLookup::getAxisByLabel(const char *label) { + const auto &self = get(); + return lookupValueByLabel(self.AXES, label); + } + + const char *InputEventLookup::getAxisLabel(int32_t axisId) { + const auto &self = get(); + return lookupLabelByValue(self.AXES_NAMES, axisId); + } + + std::optional InputEventLookup::getLedByLabel(const char *label) { + const auto &self = get(); + return lookupValueByLabel(self.LEDS, label); + } + + namespace { + + struct label { + const char *name; + int value; + }; + +#define LABEL(constant) \ + { #constant, constant } +#define LABEL_END \ + { nullptr, -1 } + +// Inserted from the file: out/soong/.intermediates/system/core/toolbox/toolbox_input_labels/gen/input.h-labels.h + static struct label ev_key_value_labels[] = { + {"UP", 0}, + {"DOWN", 1}, + {"REPEAT", 2}, + LABEL_END, + }; + + + static struct label input_prop_labels[] = { + LABEL(INPUT_PROP_POINTER), + LABEL(INPUT_PROP_DIRECT), + LABEL(INPUT_PROP_BUTTONPAD), + LABEL(INPUT_PROP_SEMI_MT), + LABEL(INPUT_PROP_TOPBUTTONPAD), + LABEL(INPUT_PROP_POINTING_STICK), + LABEL(INPUT_PROP_ACCELEROMETER), + LABEL(INPUT_PROP_MAX), + LABEL_END, + }; + static struct label ev_labels[] = { + LABEL(EV_VERSION), + LABEL(EV_SYN), + LABEL(EV_KEY), + LABEL(EV_REL), + LABEL(EV_ABS), + LABEL(EV_MSC), + LABEL(EV_SW), + LABEL(EV_LED), + LABEL(EV_SND), + LABEL(EV_REP), + LABEL(EV_FF), + LABEL(EV_PWR), + LABEL(EV_FF_STATUS), + LABEL(EV_MAX), + LABEL_END, + }; + static struct label syn_labels[] = { + LABEL(SYN_REPORT), + LABEL(SYN_CONFIG), + LABEL(SYN_MT_REPORT), + LABEL(SYN_DROPPED), + LABEL(SYN_MAX), + LABEL_END, + }; + static struct label key_labels[] = { + LABEL(KEY_RESERVED), + LABEL(KEY_ESC), + LABEL(KEY_1), + LABEL(KEY_2), + LABEL(KEY_3), + LABEL(KEY_4), + LABEL(KEY_5), + LABEL(KEY_6), + LABEL(KEY_7), + LABEL(KEY_8), + LABEL(KEY_9), + LABEL(KEY_0), + LABEL(KEY_MINUS), + LABEL(KEY_EQUAL), + LABEL(KEY_BACKSPACE), + LABEL(KEY_TAB), + LABEL(KEY_Q), + LABEL(KEY_W), + LABEL(KEY_E), + LABEL(KEY_R), + LABEL(KEY_T), + LABEL(KEY_Y), + LABEL(KEY_U), + LABEL(KEY_I), + LABEL(KEY_O), + LABEL(KEY_P), + LABEL(KEY_LEFTBRACE), + LABEL(KEY_RIGHTBRACE), + LABEL(KEY_ENTER), + LABEL(KEY_LEFTCTRL), + LABEL(KEY_A), + LABEL(KEY_S), + LABEL(KEY_D), + LABEL(KEY_F), + LABEL(KEY_G), + LABEL(KEY_H), + LABEL(KEY_J), + LABEL(KEY_K), + LABEL(KEY_L), + LABEL(KEY_SEMICOLON), + LABEL(KEY_APOSTROPHE), + LABEL(KEY_GRAVE), + LABEL(KEY_LEFTSHIFT), + LABEL(KEY_BACKSLASH), + LABEL(KEY_Z), + LABEL(KEY_X), + LABEL(KEY_C), + LABEL(KEY_V), + LABEL(KEY_B), + LABEL(KEY_N), + LABEL(KEY_M), + LABEL(KEY_COMMA), + LABEL(KEY_DOT), + LABEL(KEY_SLASH), + LABEL(KEY_RIGHTSHIFT), + LABEL(KEY_KPASTERISK), + LABEL(KEY_LEFTALT), + LABEL(KEY_SPACE), + LABEL(KEY_CAPSLOCK), + LABEL(KEY_F1), + LABEL(KEY_F2), + LABEL(KEY_F3), + LABEL(KEY_F4), + LABEL(KEY_F5), + LABEL(KEY_F6), + LABEL(KEY_F7), + LABEL(KEY_F8), + LABEL(KEY_F9), + LABEL(KEY_F10), + LABEL(KEY_NUMLOCK), + LABEL(KEY_SCROLLLOCK), + LABEL(KEY_KP7), + LABEL(KEY_KP8), + LABEL(KEY_KP9), + LABEL(KEY_KPMINUS), + LABEL(KEY_KP4), + LABEL(KEY_KP5), + LABEL(KEY_KP6), + LABEL(KEY_KPPLUS), + LABEL(KEY_KP1), + LABEL(KEY_KP2), + LABEL(KEY_KP3), + LABEL(KEY_KP0), + LABEL(KEY_KPDOT), + LABEL(KEY_ZENKAKUHANKAKU), + LABEL(KEY_102ND), + LABEL(KEY_F11), + LABEL(KEY_F12), + LABEL(KEY_RO), + LABEL(KEY_KATAKANA), + LABEL(KEY_HIRAGANA), + LABEL(KEY_HENKAN), + LABEL(KEY_KATAKANAHIRAGANA), + LABEL(KEY_MUHENKAN), + LABEL(KEY_KPJPCOMMA), + LABEL(KEY_KPENTER), + LABEL(KEY_RIGHTCTRL), + LABEL(KEY_KPSLASH), + LABEL(KEY_SYSRQ), + LABEL(KEY_RIGHTALT), + LABEL(KEY_LINEFEED), + LABEL(KEY_HOME), + LABEL(KEY_UP), + LABEL(KEY_PAGEUP), + LABEL(KEY_LEFT), + LABEL(KEY_RIGHT), + LABEL(KEY_END), + LABEL(KEY_DOWN), + LABEL(KEY_PAGEDOWN), + LABEL(KEY_INSERT), + LABEL(KEY_DELETE), + LABEL(KEY_MACRO), + LABEL(KEY_MUTE), + LABEL(KEY_VOLUMEDOWN), + LABEL(KEY_VOLUMEUP), + LABEL(KEY_POWER), + LABEL(KEY_KPEQUAL), + LABEL(KEY_KPPLUSMINUS), + LABEL(KEY_PAUSE), + LABEL(KEY_SCALE), + LABEL(KEY_KPCOMMA), + LABEL(KEY_HANGEUL), + LABEL(KEY_HANJA), + LABEL(KEY_YEN), + LABEL(KEY_LEFTMETA), + LABEL(KEY_RIGHTMETA), + LABEL(KEY_COMPOSE), + LABEL(KEY_STOP), + LABEL(KEY_AGAIN), + LABEL(KEY_PROPS), + LABEL(KEY_UNDO), + LABEL(KEY_FRONT), + LABEL(KEY_COPY), + LABEL(KEY_OPEN), + LABEL(KEY_PASTE), + LABEL(KEY_FIND), + LABEL(KEY_CUT), + LABEL(KEY_HELP), + LABEL(KEY_MENU), + LABEL(KEY_CALC), + LABEL(KEY_SETUP), + LABEL(KEY_SLEEP), + LABEL(KEY_WAKEUP), + LABEL(KEY_FILE), + LABEL(KEY_SENDFILE), + LABEL(KEY_DELETEFILE), + LABEL(KEY_XFER), + LABEL(KEY_PROG1), + LABEL(KEY_PROG2), + LABEL(KEY_WWW), + LABEL(KEY_MSDOS), + LABEL(KEY_COFFEE), + LABEL(KEY_ROTATE_DISPLAY), + LABEL(KEY_CYCLEWINDOWS), + LABEL(KEY_MAIL), + LABEL(KEY_BOOKMARKS), + LABEL(KEY_COMPUTER), + LABEL(KEY_BACK), + LABEL(KEY_FORWARD), + LABEL(KEY_CLOSECD), + LABEL(KEY_EJECTCD), + LABEL(KEY_EJECTCLOSECD), + LABEL(KEY_NEXTSONG), + LABEL(KEY_PLAYPAUSE), + LABEL(KEY_PREVIOUSSONG), + LABEL(KEY_STOPCD), + LABEL(KEY_RECORD), + LABEL(KEY_REWIND), + LABEL(KEY_PHONE), + LABEL(KEY_ISO), + LABEL(KEY_CONFIG), + LABEL(KEY_HOMEPAGE), + LABEL(KEY_REFRESH), + LABEL(KEY_EXIT), + LABEL(KEY_MOVE), + LABEL(KEY_EDIT), + LABEL(KEY_SCROLLUP), + LABEL(KEY_SCROLLDOWN), + LABEL(KEY_KPLEFTPAREN), + LABEL(KEY_KPRIGHTPAREN), + LABEL(KEY_NEW), + LABEL(KEY_REDO), + LABEL(KEY_F13), + LABEL(KEY_F14), + LABEL(KEY_F15), + LABEL(KEY_F16), + LABEL(KEY_F17), + LABEL(KEY_F18), + LABEL(KEY_F19), + LABEL(KEY_F20), + LABEL(KEY_F21), + LABEL(KEY_F22), + LABEL(KEY_F23), + LABEL(KEY_F24), + LABEL(KEY_PLAYCD), + LABEL(KEY_PAUSECD), + LABEL(KEY_PROG3), + LABEL(KEY_PROG4), + LABEL(KEY_ALL_APPLICATIONS), + LABEL(KEY_SUSPEND), + LABEL(KEY_CLOSE), + LABEL(KEY_PLAY), + LABEL(KEY_FASTFORWARD), + LABEL(KEY_BASSBOOST), + LABEL(KEY_PRINT), + LABEL(KEY_HP), + LABEL(KEY_CAMERA), + LABEL(KEY_SOUND), + LABEL(KEY_QUESTION), + LABEL(KEY_EMAIL), + LABEL(KEY_CHAT), + LABEL(KEY_SEARCH), + LABEL(KEY_CONNECT), + LABEL(KEY_FINANCE), + LABEL(KEY_SPORT), + LABEL(KEY_SHOP), + LABEL(KEY_ALTERASE), + LABEL(KEY_CANCEL), + LABEL(KEY_BRIGHTNESSDOWN), + LABEL(KEY_BRIGHTNESSUP), + LABEL(KEY_MEDIA), + LABEL(KEY_SWITCHVIDEOMODE), + LABEL(KEY_KBDILLUMTOGGLE), + LABEL(KEY_KBDILLUMDOWN), + LABEL(KEY_KBDILLUMUP), + LABEL(KEY_SEND), + LABEL(KEY_REPLY), + LABEL(KEY_FORWARDMAIL), + LABEL(KEY_SAVE), + LABEL(KEY_DOCUMENTS), + LABEL(KEY_BATTERY), + LABEL(KEY_BLUETOOTH), + LABEL(KEY_WLAN), + LABEL(KEY_UWB), + LABEL(KEY_UNKNOWN), + LABEL(KEY_VIDEO_NEXT), + LABEL(KEY_VIDEO_PREV), + LABEL(KEY_BRIGHTNESS_CYCLE), + LABEL(KEY_BRIGHTNESS_AUTO), + LABEL(KEY_DISPLAY_OFF), + LABEL(KEY_WWAN), + LABEL(KEY_RFKILL), + LABEL(KEY_MICMUTE), + LABEL(BTN_MISC), + LABEL(BTN_0), + LABEL(BTN_1), + LABEL(BTN_2), + LABEL(BTN_3), + LABEL(BTN_4), + LABEL(BTN_5), + LABEL(BTN_6), + LABEL(BTN_7), + LABEL(BTN_8), + LABEL(BTN_9), + LABEL(BTN_MOUSE), + LABEL(BTN_LEFT), + LABEL(BTN_RIGHT), + LABEL(BTN_MIDDLE), + LABEL(BTN_SIDE), + LABEL(BTN_EXTRA), + LABEL(BTN_FORWARD), + LABEL(BTN_BACK), + LABEL(BTN_TASK), + LABEL(BTN_JOYSTICK), + LABEL(BTN_TRIGGER), + LABEL(BTN_THUMB), + LABEL(BTN_THUMB2), + LABEL(BTN_TOP), + LABEL(BTN_TOP2), + LABEL(BTN_PINKIE), + LABEL(BTN_BASE), + LABEL(BTN_BASE2), + LABEL(BTN_BASE3), + LABEL(BTN_BASE4), + LABEL(BTN_BASE5), + LABEL(BTN_BASE6), + LABEL(BTN_DEAD), + LABEL(BTN_GAMEPAD), + LABEL(BTN_SOUTH), + LABEL(BTN_EAST), + LABEL(BTN_C), + LABEL(BTN_NORTH), + LABEL(BTN_WEST), + LABEL(BTN_Z), + LABEL(BTN_TL), + LABEL(BTN_TR), + LABEL(BTN_TL2), + LABEL(BTN_TR2), + LABEL(BTN_SELECT), + LABEL(BTN_START), + LABEL(BTN_MODE), + LABEL(BTN_THUMBL), + LABEL(BTN_THUMBR), + LABEL(BTN_DIGI), + LABEL(BTN_TOOL_PEN), + LABEL(BTN_TOOL_RUBBER), + LABEL(BTN_TOOL_BRUSH), + LABEL(BTN_TOOL_PENCIL), + LABEL(BTN_TOOL_AIRBRUSH), + LABEL(BTN_TOOL_FINGER), + LABEL(BTN_TOOL_MOUSE), + LABEL(BTN_TOOL_LENS), + LABEL(BTN_TOOL_QUINTTAP), + LABEL(BTN_STYLUS3), + LABEL(BTN_TOUCH), + LABEL(BTN_STYLUS), + LABEL(BTN_STYLUS2), + LABEL(BTN_TOOL_DOUBLETAP), + LABEL(BTN_TOOL_TRIPLETAP), + LABEL(BTN_TOOL_QUADTAP), + LABEL(BTN_WHEEL), + LABEL(BTN_GEAR_DOWN), + LABEL(BTN_GEAR_UP), + LABEL(KEY_OK), + LABEL(KEY_SELECT), + LABEL(KEY_GOTO), + LABEL(KEY_CLEAR), + LABEL(KEY_POWER2), + LABEL(KEY_OPTION), + LABEL(KEY_INFO), + LABEL(KEY_TIME), + LABEL(KEY_VENDOR), + LABEL(KEY_ARCHIVE), + LABEL(KEY_PROGRAM), + LABEL(KEY_CHANNEL), + LABEL(KEY_FAVORITES), + LABEL(KEY_EPG), + LABEL(KEY_PVR), + LABEL(KEY_MHP), + LABEL(KEY_LANGUAGE), + LABEL(KEY_TITLE), + LABEL(KEY_SUBTITLE), + LABEL(KEY_ANGLE), + LABEL(KEY_FULL_SCREEN), + LABEL(KEY_MODE), + LABEL(KEY_KEYBOARD), + LABEL(KEY_ASPECT_RATIO), + LABEL(KEY_PC), + LABEL(KEY_TV), + LABEL(KEY_TV2), + LABEL(KEY_VCR), + LABEL(KEY_VCR2), + LABEL(KEY_SAT), + LABEL(KEY_SAT2), + LABEL(KEY_CD), + LABEL(KEY_TAPE), + LABEL(KEY_RADIO), + LABEL(KEY_TUNER), + LABEL(KEY_PLAYER), + LABEL(KEY_TEXT), + LABEL(KEY_DVD), + LABEL(KEY_AUX), + LABEL(KEY_MP3), + LABEL(KEY_AUDIO), + LABEL(KEY_VIDEO), + LABEL(KEY_DIRECTORY), + LABEL(KEY_LIST), + LABEL(KEY_MEMO), + LABEL(KEY_CALENDAR), + LABEL(KEY_RED), + LABEL(KEY_GREEN), + LABEL(KEY_YELLOW), + LABEL(KEY_BLUE), + LABEL(KEY_CHANNELUP), + LABEL(KEY_CHANNELDOWN), + LABEL(KEY_FIRST), + LABEL(KEY_LAST), + LABEL(KEY_AB), + LABEL(KEY_NEXT), + LABEL(KEY_RESTART), + LABEL(KEY_SLOW), + LABEL(KEY_SHUFFLE), + LABEL(KEY_BREAK), + LABEL(KEY_PREVIOUS), + LABEL(KEY_DIGITS), + LABEL(KEY_TEEN), + LABEL(KEY_TWEN), + LABEL(KEY_VIDEOPHONE), + LABEL(KEY_GAMES), + LABEL(KEY_ZOOMIN), + LABEL(KEY_ZOOMOUT), + LABEL(KEY_ZOOMRESET), + LABEL(KEY_WORDPROCESSOR), + LABEL(KEY_EDITOR), + LABEL(KEY_SPREADSHEET), + LABEL(KEY_GRAPHICSEDITOR), + LABEL(KEY_PRESENTATION), + LABEL(KEY_DATABASE), + LABEL(KEY_NEWS), + LABEL(KEY_VOICEMAIL), + LABEL(KEY_ADDRESSBOOK), + LABEL(KEY_MESSENGER), + LABEL(KEY_DISPLAYTOGGLE), + LABEL(KEY_SPELLCHECK), + LABEL(KEY_LOGOFF), + LABEL(KEY_DOLLAR), + LABEL(KEY_EURO), + LABEL(KEY_FRAMEBACK), + LABEL(KEY_FRAMEFORWARD), + LABEL(KEY_CONTEXT_MENU), + LABEL(KEY_MEDIA_REPEAT), + LABEL(KEY_10CHANNELSUP), + LABEL(KEY_10CHANNELSDOWN), + LABEL(KEY_IMAGES), + LABEL(KEY_NOTIFICATION_CENTER), + LABEL(KEY_PICKUP_PHONE), + LABEL(KEY_HANGUP_PHONE), + LABEL(KEY_DEL_EOL), + LABEL(KEY_DEL_EOS), + LABEL(KEY_INS_LINE), + LABEL(KEY_DEL_LINE), + LABEL(KEY_FN), + LABEL(KEY_FN_ESC), + LABEL(KEY_FN_F1), + LABEL(KEY_FN_F2), + LABEL(KEY_FN_F3), + LABEL(KEY_FN_F4), + LABEL(KEY_FN_F5), + LABEL(KEY_FN_F6), + LABEL(KEY_FN_F7), + LABEL(KEY_FN_F8), + LABEL(KEY_FN_F9), + LABEL(KEY_FN_F10), + LABEL(KEY_FN_F11), + LABEL(KEY_FN_F12), + LABEL(KEY_FN_1), + LABEL(KEY_FN_2), + LABEL(KEY_FN_D), + LABEL(KEY_FN_E), + LABEL(KEY_FN_F), + LABEL(KEY_FN_S), + LABEL(KEY_FN_B), + LABEL(KEY_FN_RIGHT_SHIFT), + LABEL(KEY_BRL_DOT1), + LABEL(KEY_BRL_DOT2), + LABEL(KEY_BRL_DOT3), + LABEL(KEY_BRL_DOT4), + LABEL(KEY_BRL_DOT5), + LABEL(KEY_BRL_DOT6), + LABEL(KEY_BRL_DOT7), + LABEL(KEY_BRL_DOT8), + LABEL(KEY_BRL_DOT9), + LABEL(KEY_BRL_DOT10), + LABEL(KEY_NUMERIC_0), + LABEL(KEY_NUMERIC_1), + LABEL(KEY_NUMERIC_2), + LABEL(KEY_NUMERIC_3), + LABEL(KEY_NUMERIC_4), + LABEL(KEY_NUMERIC_5), + LABEL(KEY_NUMERIC_6), + LABEL(KEY_NUMERIC_7), + LABEL(KEY_NUMERIC_8), + LABEL(KEY_NUMERIC_9), + LABEL(KEY_NUMERIC_STAR), + LABEL(KEY_NUMERIC_POUND), + LABEL(KEY_NUMERIC_A), + LABEL(KEY_NUMERIC_B), + LABEL(KEY_NUMERIC_C), + LABEL(KEY_NUMERIC_D), + LABEL(KEY_CAMERA_FOCUS), + LABEL(KEY_WPS_BUTTON), + LABEL(KEY_TOUCHPAD_TOGGLE), + LABEL(KEY_TOUCHPAD_ON), + LABEL(KEY_TOUCHPAD_OFF), + LABEL(KEY_CAMERA_ZOOMIN), + LABEL(KEY_CAMERA_ZOOMOUT), + LABEL(KEY_CAMERA_UP), + LABEL(KEY_CAMERA_DOWN), + LABEL(KEY_CAMERA_LEFT), + LABEL(KEY_CAMERA_RIGHT), + LABEL(KEY_ATTENDANT_ON), + LABEL(KEY_ATTENDANT_OFF), + LABEL(KEY_ATTENDANT_TOGGLE), + LABEL(KEY_LIGHTS_TOGGLE), + LABEL(BTN_DPAD_UP), + LABEL(BTN_DPAD_DOWN), + LABEL(BTN_DPAD_LEFT), + LABEL(BTN_DPAD_RIGHT), + LABEL(KEY_ALS_TOGGLE), + LABEL(KEY_ROTATE_LOCK_TOGGLE), +// LABEL(KEY_REFRESH_RATE_TOGGLE), + LABEL(KEY_BUTTONCONFIG), + LABEL(KEY_TASKMANAGER), + LABEL(KEY_JOURNAL), + LABEL(KEY_CONTROLPANEL), + LABEL(KEY_APPSELECT), + LABEL(KEY_SCREENSAVER), + LABEL(KEY_VOICECOMMAND), + LABEL(KEY_ASSISTANT), + LABEL(KEY_KBD_LAYOUT_NEXT), + LABEL(KEY_EMOJI_PICKER), + LABEL(KEY_DICTATE), + LABEL(KEY_CAMERA_ACCESS_ENABLE), + LABEL(KEY_CAMERA_ACCESS_DISABLE), + LABEL(KEY_CAMERA_ACCESS_TOGGLE), +// LABEL(KEY_ACCESSIBILITY), +// LABEL(KEY_DO_NOT_DISTURB), + LABEL(KEY_BRIGHTNESS_MIN), + LABEL(KEY_BRIGHTNESS_MAX), + LABEL(KEY_KBDINPUTASSIST_PREV), + LABEL(KEY_KBDINPUTASSIST_NEXT), + LABEL(KEY_KBDINPUTASSIST_PREVGROUP), + LABEL(KEY_KBDINPUTASSIST_NEXTGROUP), + LABEL(KEY_KBDINPUTASSIST_ACCEPT), + LABEL(KEY_KBDINPUTASSIST_CANCEL), + LABEL(KEY_RIGHT_UP), + LABEL(KEY_RIGHT_DOWN), + LABEL(KEY_LEFT_UP), + LABEL(KEY_LEFT_DOWN), + LABEL(KEY_ROOT_MENU), + LABEL(KEY_MEDIA_TOP_MENU), + LABEL(KEY_NUMERIC_11), + LABEL(KEY_NUMERIC_12), + LABEL(KEY_AUDIO_DESC), + LABEL(KEY_3D_MODE), + LABEL(KEY_NEXT_FAVORITE), + LABEL(KEY_STOP_RECORD), + LABEL(KEY_PAUSE_RECORD), + LABEL(KEY_VOD), + LABEL(KEY_UNMUTE), + LABEL(KEY_FASTREVERSE), + LABEL(KEY_SLOWREVERSE), + LABEL(KEY_DATA), + LABEL(KEY_ONSCREEN_KEYBOARD), + LABEL(KEY_PRIVACY_SCREEN_TOGGLE), + LABEL(KEY_SELECTIVE_SCREENSHOT), + LABEL(KEY_NEXT_ELEMENT), + LABEL(KEY_PREVIOUS_ELEMENT), + LABEL(KEY_AUTOPILOT_ENGAGE_TOGGLE), + LABEL(KEY_MARK_WAYPOINT), + LABEL(KEY_SOS), + LABEL(KEY_NAV_CHART), + LABEL(KEY_FISHING_CHART), + LABEL(KEY_SINGLE_RANGE_RADAR), + LABEL(KEY_DUAL_RANGE_RADAR), + LABEL(KEY_RADAR_OVERLAY), + LABEL(KEY_TRADITIONAL_SONAR), + LABEL(KEY_CLEARVU_SONAR), + LABEL(KEY_SIDEVU_SONAR), + LABEL(KEY_NAV_INFO), + LABEL(KEY_BRIGHTNESS_MENU), + LABEL(KEY_MACRO1), + LABEL(KEY_MACRO2), + LABEL(KEY_MACRO3), + LABEL(KEY_MACRO4), + LABEL(KEY_MACRO5), + LABEL(KEY_MACRO6), + LABEL(KEY_MACRO7), + LABEL(KEY_MACRO8), + LABEL(KEY_MACRO9), + LABEL(KEY_MACRO10), + LABEL(KEY_MACRO11), + LABEL(KEY_MACRO12), + LABEL(KEY_MACRO13), + LABEL(KEY_MACRO14), + LABEL(KEY_MACRO15), + LABEL(KEY_MACRO16), + LABEL(KEY_MACRO17), + LABEL(KEY_MACRO18), + LABEL(KEY_MACRO19), + LABEL(KEY_MACRO20), + LABEL(KEY_MACRO21), + LABEL(KEY_MACRO22), + LABEL(KEY_MACRO23), + LABEL(KEY_MACRO24), + LABEL(KEY_MACRO25), + LABEL(KEY_MACRO26), + LABEL(KEY_MACRO27), + LABEL(KEY_MACRO28), + LABEL(KEY_MACRO29), + LABEL(KEY_MACRO30), + LABEL(KEY_MACRO_RECORD_START), + LABEL(KEY_MACRO_RECORD_STOP), + LABEL(KEY_MACRO_PRESET_CYCLE), + LABEL(KEY_MACRO_PRESET1), + LABEL(KEY_MACRO_PRESET2), + LABEL(KEY_MACRO_PRESET3), + LABEL(KEY_KBD_LCD_MENU1), + LABEL(KEY_KBD_LCD_MENU2), + LABEL(KEY_KBD_LCD_MENU3), + LABEL(KEY_KBD_LCD_MENU4), + LABEL(KEY_KBD_LCD_MENU5), + LABEL(BTN_TRIGGER_HAPPY), + LABEL(BTN_TRIGGER_HAPPY1), + LABEL(BTN_TRIGGER_HAPPY2), + LABEL(BTN_TRIGGER_HAPPY3), + LABEL(BTN_TRIGGER_HAPPY4), + LABEL(BTN_TRIGGER_HAPPY5), + LABEL(BTN_TRIGGER_HAPPY6), + LABEL(BTN_TRIGGER_HAPPY7), + LABEL(BTN_TRIGGER_HAPPY8), + LABEL(BTN_TRIGGER_HAPPY9), + LABEL(BTN_TRIGGER_HAPPY10), + LABEL(BTN_TRIGGER_HAPPY11), + LABEL(BTN_TRIGGER_HAPPY12), + LABEL(BTN_TRIGGER_HAPPY13), + LABEL(BTN_TRIGGER_HAPPY14), + LABEL(BTN_TRIGGER_HAPPY15), + LABEL(BTN_TRIGGER_HAPPY16), + LABEL(BTN_TRIGGER_HAPPY17), + LABEL(BTN_TRIGGER_HAPPY18), + LABEL(BTN_TRIGGER_HAPPY19), + LABEL(BTN_TRIGGER_HAPPY20), + LABEL(BTN_TRIGGER_HAPPY21), + LABEL(BTN_TRIGGER_HAPPY22), + LABEL(BTN_TRIGGER_HAPPY23), + LABEL(BTN_TRIGGER_HAPPY24), + LABEL(BTN_TRIGGER_HAPPY25), + LABEL(BTN_TRIGGER_HAPPY26), + LABEL(BTN_TRIGGER_HAPPY27), + LABEL(BTN_TRIGGER_HAPPY28), + LABEL(BTN_TRIGGER_HAPPY29), + LABEL(BTN_TRIGGER_HAPPY30), + LABEL(BTN_TRIGGER_HAPPY31), + LABEL(BTN_TRIGGER_HAPPY32), + LABEL(BTN_TRIGGER_HAPPY33), + LABEL(BTN_TRIGGER_HAPPY34), + LABEL(BTN_TRIGGER_HAPPY35), + LABEL(BTN_TRIGGER_HAPPY36), + LABEL(BTN_TRIGGER_HAPPY37), + LABEL(BTN_TRIGGER_HAPPY38), + LABEL(BTN_TRIGGER_HAPPY39), + LABEL(BTN_TRIGGER_HAPPY40), + LABEL(KEY_MAX), + LABEL_END, + }; + static struct label rel_labels[] = { + LABEL(REL_X), + LABEL(REL_Y), + LABEL(REL_Z), + LABEL(REL_RX), + LABEL(REL_RY), + LABEL(REL_RZ), + LABEL(REL_HWHEEL), + LABEL(REL_DIAL), + LABEL(REL_WHEEL), + LABEL(REL_MISC), + LABEL(REL_RESERVED), + LABEL(REL_WHEEL_HI_RES), + LABEL(REL_HWHEEL_HI_RES), + LABEL(REL_MAX), + LABEL_END, + }; + static struct label abs_labels[] = { + LABEL(ABS_X), + LABEL(ABS_Y), + LABEL(ABS_Z), + LABEL(ABS_RX), + LABEL(ABS_RY), + LABEL(ABS_RZ), + LABEL(ABS_THROTTLE), + LABEL(ABS_RUDDER), + LABEL(ABS_WHEEL), + LABEL(ABS_GAS), + LABEL(ABS_BRAKE), + LABEL(ABS_HAT0X), + LABEL(ABS_HAT0Y), + LABEL(ABS_HAT1X), + LABEL(ABS_HAT1Y), + LABEL(ABS_HAT2X), + LABEL(ABS_HAT2Y), + LABEL(ABS_HAT3X), + LABEL(ABS_HAT3Y), + LABEL(ABS_PRESSURE), + LABEL(ABS_DISTANCE), + LABEL(ABS_TILT_X), + LABEL(ABS_TILT_Y), + LABEL(ABS_TOOL_WIDTH), + LABEL(ABS_VOLUME), + LABEL(ABS_PROFILE), + LABEL(ABS_MISC), + LABEL(ABS_RESERVED), + LABEL(ABS_MT_SLOT), + LABEL(ABS_MT_TOUCH_MAJOR), + LABEL(ABS_MT_TOUCH_MINOR), + LABEL(ABS_MT_WIDTH_MAJOR), + LABEL(ABS_MT_WIDTH_MINOR), + LABEL(ABS_MT_ORIENTATION), + LABEL(ABS_MT_POSITION_X), + LABEL(ABS_MT_POSITION_Y), + LABEL(ABS_MT_TOOL_TYPE), + LABEL(ABS_MT_BLOB_ID), + LABEL(ABS_MT_TRACKING_ID), + LABEL(ABS_MT_PRESSURE), + LABEL(ABS_MT_DISTANCE), + LABEL(ABS_MT_TOOL_X), + LABEL(ABS_MT_TOOL_Y), + LABEL(ABS_MAX), + LABEL_END, + }; + static struct label sw_labels[] = { + LABEL(SW_LID), + LABEL(SW_TABLET_MODE), + LABEL(SW_HEADPHONE_INSERT), + LABEL(SW_RFKILL_ALL), + LABEL(SW_MICROPHONE_INSERT), + LABEL(SW_DOCK), + LABEL(SW_LINEOUT_INSERT), + LABEL(SW_JACK_PHYSICAL_INSERT), + LABEL(SW_VIDEOOUT_INSERT), + LABEL(SW_CAMERA_LENS_COVER), + LABEL(SW_KEYPAD_SLIDE), + LABEL(SW_FRONT_PROXIMITY), + LABEL(SW_ROTATE_LOCK), + LABEL(SW_LINEIN_INSERT), + LABEL(SW_MUTE_DEVICE), + LABEL(SW_PEN_INSERTED), + LABEL(SW_MACHINE_COVER), + LABEL(SW_MAX), + LABEL_END, + }; + static struct label msc_labels[] = { + LABEL(MSC_SERIAL), + LABEL(MSC_PULSELED), + LABEL(MSC_GESTURE), + LABEL(MSC_RAW), + LABEL(MSC_SCAN), + LABEL(MSC_TIMESTAMP), + LABEL(MSC_MAX), + LABEL_END, + }; + static struct label led_labels[] = { + LABEL(LED_NUML), + LABEL(LED_CAPSL), + LABEL(LED_SCROLLL), + LABEL(LED_COMPOSE), + LABEL(LED_KANA), + LABEL(LED_SLEEP), + LABEL(LED_SUSPEND), + LABEL(LED_MUTE), + LABEL(LED_MISC), + LABEL(LED_MAIL), + LABEL(LED_CHARGING), + LABEL(LED_MAX), + LABEL_END, + }; + static struct label rep_labels[] = { + LABEL(REP_DELAY), + LABEL(REP_PERIOD), + LABEL(REP_MAX), + LABEL_END, + }; + static struct label snd_labels[] = { + LABEL(SND_CLICK), + LABEL(SND_BELL), + LABEL(SND_TONE), + LABEL(SND_MAX), + LABEL_END, + }; + static struct label mt_tool_labels[] = { + LABEL(MT_TOOL_FINGER), + LABEL(MT_TOOL_PEN), + LABEL(MT_TOOL_PALM), + LABEL(MT_TOOL_DIAL), + LABEL(MT_TOOL_MAX), + LABEL_END, + }; + static struct label ff_status_labels[] = { + LABEL(FF_STATUS_STOPPED), + LABEL(FF_STATUS_PLAYING), + LABEL(FF_STATUS_MAX), + LABEL_END, + }; + static struct label ff_labels[] = { + LABEL(FF_RUMBLE), + LABEL(FF_PERIODIC), + LABEL(FF_CONSTANT), + LABEL(FF_SPRING), + LABEL(FF_FRICTION), + LABEL(FF_DAMPER), + LABEL(FF_INERTIA), + LABEL(FF_RAMP), + LABEL(FF_SQUARE), + LABEL(FF_TRIANGLE), + LABEL(FF_SINE), + LABEL(FF_SAW_UP), + LABEL(FF_SAW_DOWN), + LABEL(FF_CUSTOM), + LABEL(FF_GAIN), + LABEL(FF_AUTOCENTER), + LABEL(FF_MAX), + LABEL_END, + }; + +#undef LABEL +#undef LABEL_END + + std::string getLabel(const label *labels, int value) { + if (labels == nullptr) return std::to_string(value); + while (labels->name != nullptr && value != labels->value) { + labels++; + } + return labels->name != nullptr ? labels->name : std::to_string(value); + } + + std::optional getValue(const label *labels, const char *searchLabel) { + if (labels == nullptr) return {}; + while (labels->name != nullptr && ::strcasecmp(labels->name, searchLabel) != 0) { + labels++; + } + return labels->name != nullptr ? std::make_optional(labels->value) : std::nullopt; + } + + const label *getCodeLabelsForType(int32_t type) { + switch (type) { + case EV_SYN: + return syn_labels; + case EV_KEY: + return key_labels; + case EV_REL: + return rel_labels; + case EV_ABS: + return abs_labels; + case EV_SW: + return sw_labels; + case EV_MSC: + return msc_labels; + case EV_LED: + return led_labels; + case EV_REP: + return rep_labels; + case EV_SND: + return snd_labels; + case EV_FF: + return ff_labels; + case EV_FF_STATUS: + return ff_status_labels; + default: + return nullptr; + } + } + + const label *getValueLabelsForTypeAndCode(int32_t type, int32_t code) { + if (type == EV_KEY) { + return ev_key_value_labels; + } + if (type == EV_ABS && code == ABS_MT_TOOL_TYPE) { + return mt_tool_labels; + } + return nullptr; + } + + } // namespace + + EvdevEventLabel + InputEventLookup::getLinuxEvdevLabel(int32_t type, int32_t code, int32_t value) { + return { + .type = getLabel(ev_labels, type), + .code = getLabel(getCodeLabelsForType(type), code), + .value = getLabel(getValueLabelsForTypeAndCode(type, code), value), + }; + } + + std::optional InputEventLookup::getLinuxEvdevEventTypeByLabel(const char *label) { + return getValue(ev_labels, label); + } + + std::optional InputEventLookup::getLinuxEvdevEventCodeByLabel(int32_t type, + const char *label) { + return getValue(getCodeLabelsForType(type), label); + } + + std::optional InputEventLookup::getLinuxEvdevInputPropByLabel(const char *label) { + return getValue(input_prop_labels, label); + } + +} // namespace android diff --git a/sysbridge/src/main/cpp/android/input/InputEventLabels.h b/sysbridge/src/main/cpp/android/input/InputEventLabels.h new file mode 100644 index 0000000000..066ff06ca7 --- /dev/null +++ b/sysbridge/src/main/cpp/android/input/InputEventLabels.h @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +namespace android { + + template + size_t size(T (&)[N]) { return N; } + + struct InputEventLabel { + const char *literal; + int value; + }; + + struct EvdevEventLabel { + std::string type; + std::string code; + std::string value; + }; + +// NOTE: If you want a new key code, axis code, led code or flag code in keylayout file, +// then you must add it to InputEventLabels.cpp. + + class InputEventLookup { + /** + * This class is not purely static, but uses a singleton pattern in order to delay the + * initialization of the maps that it contains. If it were purely static, the maps could be + * created early, and would cause sanitizers to report memory leaks. + */ + public: + InputEventLookup(InputEventLookup& other) = delete; + + void operator=(const InputEventLookup&) = delete; + + static std::optional lookupValueByLabel(const std::unordered_map& map, + const char* literal); + + static const char* lookupLabelByValue(const std::vector& vec, int value); + + static std::optional getKeyCodeByLabel(const char* label); + + static const char* getLabelByKeyCode(int32_t keyCode); + + static std::optional getKeyFlagByLabel(const char* label); + + static std::optional getAxisByLabel(const char* label); + + static const char* getAxisLabel(int32_t axisId); + + static std::optional getLedByLabel(const char* label); + + static EvdevEventLabel getLinuxEvdevLabel(int32_t type, int32_t code, int32_t value); + + static std::optional getLinuxEvdevEventTypeByLabel(const char* label); + + static std::optional getLinuxEvdevEventCodeByLabel(int32_t type, const char* label); + + static std::optional getLinuxEvdevInputPropByLabel(const char* label); + + private: + InputEventLookup(); + + static const InputEventLookup& get() { + static InputEventLookup sLookup; + return sLookup; + } + + const std::unordered_map KEYCODES; + + const std::vector KEY_NAMES; + + const std::unordered_map AXES; + + const std::vector AXES_NAMES; + + const std::unordered_map LEDS; + + const std::unordered_map FLAGS; + }; + +} // namespace android diff --git a/sysbridge/src/main/cpp/android/input/KeyLayoutMap.cpp b/sysbridge/src/main/cpp/android/input/KeyLayoutMap.cpp new file mode 100644 index 0000000000..bbf5a5437b --- /dev/null +++ b/sysbridge/src/main/cpp/android/input/KeyLayoutMap.cpp @@ -0,0 +1,444 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, Tokenizer.cppeither express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../../logging.h" +#include +#include "../utils/String8.h" +#include "KeyLayoutMap.h" +#include "../utils/Tokenizer.h" +#include "InputEventLabels.h" +#include + +#include +#include +#include +#include "../libbase/result.h" +#include "../liblog/log_main.h" +#include "Input.h" + +#define DEBUG_MAPPING false +#define DEBUG_PARSER false + +// Enables debug output for parser performance. +#define DEBUG_PARSER_PERFORMANCE 0 + +namespace android { + + namespace { + + std::optional parseInt(const char *str) { + char *end; + errno = 0; + const int value = strtol(str, &end, 0); + if (end == str) { + LOGE("Could not parse %s", str); + return {}; + } + if (errno == ERANGE) { + LOGE("Out of bounds: %s", str); + return {}; + } + return value; + } + + constexpr const char *WHITESPACE = " \t\r"; + + } // namespace + + KeyLayoutMap::KeyLayoutMap() = default; + + KeyLayoutMap::~KeyLayoutMap() = default; + + base::Result> + KeyLayoutMap::loadContents(const std::string &filename, + const char *contents) { + return load(filename, contents); + } + + base::Result> KeyLayoutMap::load(const std::string &filename, + const char *contents) { + Tokenizer *tokenizer; + status_t status; + if (contents == nullptr) { + status = Tokenizer::open(String8(filename.c_str()), &tokenizer); + } else { + status = Tokenizer::fromContents(String8(filename.c_str()), contents, &tokenizer); + } + + if (status) { + LOGE("Error %d opening key layout map file %s.", status, filename.c_str()); + return Errorf("Error {} opening key layout map file {}.", status, filename.c_str()); + } + std::unique_ptr t(tokenizer); + auto ret = load(t.get()); + if (!ret.ok()) { + return ret; + } + const std::shared_ptr &map = *ret; + LOG_ALWAYS_FATAL_IF(map == nullptr, "Returned map should not be null if there's no error"); + + map->mLoadFileName = filename; + return ret; + } + + base::Result> KeyLayoutMap::load(Tokenizer *tokenizer) { + std::shared_ptr map = std::shared_ptr(new KeyLayoutMap()); + status_t status = OK; + if (!map.get()) { + LOGE("Error allocating key layout map."); + return Errorf("Error allocating key layout map."); + } else { +#if DEBUG_PARSER_PERFORMANCE + nsecs_t startTime = systemTime(SYSTEM_TIME_MONOTONIC); +#endif + Parser parser(map.get(), tokenizer); + status = parser.parse(); +#if DEBUG_PARSER_PERFORMANCE + nsecs_t elapsedTime = systemTime(SYSTEM_TIME_MONOTONIC) - startTime; + LOGD("Parsed key layout map file '%s' %d lines in %0.3fms.", + tokenizer->getFilename().c_str(), tokenizer->getLineNumber(), + elapsedTime / 1000000.0); +#endif + if (!status) { + return std::move(map); + } + } + return Errorf("Load KeyLayoutMap failed {}.", status); + } + + status_t KeyLayoutMap::mapKey(int32_t scanCode, int32_t usageCode, + int32_t *outKeyCode, uint32_t *outFlags) const { + const Key *key = getKey(scanCode, usageCode); + if (!key) { + ALOGD_IF(DEBUG_MAPPING, "mapKey: scanCode=%d, usageCode=0x%08x ~ Failed.", scanCode, + usageCode); + *outKeyCode = AKEYCODE_UNKNOWN; + *outFlags = 0; + return NAME_NOT_FOUND; + } + + *outKeyCode = key->keyCode; + *outFlags = key->flags; + + ALOGD_IF(DEBUG_MAPPING, + "mapKey: scanCode=%d, usageCode=0x%08x ~ Result keyCode=%d, outFlags=0x%08x.", + scanCode, usageCode, *outKeyCode, *outFlags); + return NO_ERROR; + } + + const KeyLayoutMap::Key *KeyLayoutMap::getKey(int32_t scanCode, int32_t usageCode) const { + if (usageCode) { + auto it = mKeysByUsageCode.find(usageCode); + if (it != mKeysByUsageCode.end()) { + return &it->second; + } + } + if (scanCode) { + auto it = mKeysByScanCode.find(scanCode); + if (it != mKeysByScanCode.end()) { + return &it->second; + } + } + return nullptr; + } + + std::vector KeyLayoutMap::findScanCodesForKey(int32_t keyCode) const { + std::vector scanCodes; + // b/354333072: Only consider keys without FUNCTION flag + for (const auto &[scanCode, key]: mKeysByScanCode) { + if (keyCode == key.keyCode && !(key.flags & POLICY_FLAG_FUNCTION)) { + scanCodes.push_back(scanCode); + } + } + return scanCodes; + } + + std::vector KeyLayoutMap::findUsageCodesForKey(int32_t keyCode) const { + std::vector usageCodes; + for (const auto &[usageCode, key]: mKeysByUsageCode) { + if (keyCode == key.keyCode && !(key.flags & POLICY_FLAG_FALLBACK_USAGE_MAPPING)) { + usageCodes.push_back(usageCode); + } + } + return usageCodes; + } + + std::optional KeyLayoutMap::mapAxis(int32_t scanCode) const { + auto it = mAxes.find(scanCode); + if (it == mAxes.end()) { + ALOGD_IF(DEBUG_MAPPING, "mapAxis: scanCode=%d ~ Failed.", scanCode); + return std::nullopt; + } + + const AxisInfo &axisInfo = it->second; + ALOGD_IF(DEBUG_MAPPING, + "mapAxis: scanCode=%d ~ Result mode=%d, axis=%d, highAxis=%d, " + "splitValue=%d, flatOverride=%d.", + scanCode, axisInfo.mode, axisInfo.axis, axisInfo.highAxis, axisInfo.splitValue, + axisInfo.flatOverride); + return axisInfo; + } + +// --- KeyLayoutMap::Parser --- + + KeyLayoutMap::Parser::Parser(KeyLayoutMap *map, Tokenizer *tokenizer) : + mMap(map), mTokenizer(tokenizer) { + } + + KeyLayoutMap::Parser::~Parser() { + } + + status_t KeyLayoutMap::Parser::parse() { + while (!mTokenizer->isEof()) { + ALOGD_IF(DEBUG_PARSER, "Parsing %s: '%s'.", mTokenizer->getLocation().c_str(), + mTokenizer->peekRemainderOfLine().c_str()); + + mTokenizer->skipDelimiters(WHITESPACE); + + if (!mTokenizer->isEol() && mTokenizer->peekChar() != '#') { + String8 keywordToken = mTokenizer->nextToken(WHITESPACE); + if (keywordToken == "key") { + mTokenizer->skipDelimiters(WHITESPACE); + status_t status = parseKey(); + if (status) return status; + } else if (keywordToken == "axis") { + mTokenizer->skipDelimiters(WHITESPACE); + status_t status = parseAxis(); + if (status) return status; + } else if (keywordToken == "led") { + // Skip LEDs, we don't need them for Key Mapper + mTokenizer->nextLine(); + continue; + } else if (keywordToken == "sensor") { + // Skip Sensors, we don't need them for Key Mapper + mTokenizer->nextLine(); + continue; + } else if (keywordToken == "requires_kernel_config") { + mTokenizer->skipDelimiters(WHITESPACE); + status_t status = parseRequiredKernelConfig(); + if (status) return status; + } else { + LOGE("%s: Expected keyword, got '%s'.", mTokenizer->getLocation().c_str(), + keywordToken.c_str()); + return BAD_VALUE; + } + + mTokenizer->skipDelimiters(WHITESPACE); + + if (!mTokenizer->isEol() && mTokenizer->peekChar() != '#') { + LOGW("%s: Expected end of line or trailing comment, got '%s'.", + mTokenizer->getLocation().c_str(), + mTokenizer->peekRemainderOfLine().c_str()); + return BAD_VALUE; + } + } + + mTokenizer->nextLine(); + } + return NO_ERROR; + } + + status_t KeyLayoutMap::Parser::parseKey() { + String8 codeToken = mTokenizer->nextToken(WHITESPACE); + bool mapUsage = false; + if (codeToken == "usage") { + mapUsage = true; + mTokenizer->skipDelimiters(WHITESPACE); + codeToken = mTokenizer->nextToken(WHITESPACE); + } + + std::optional code = parseInt(codeToken.c_str()); + if (!code) { + LOGE("%s: Expected key %s number, got '%s'.", mTokenizer->getLocation().c_str(), + mapUsage ? "usage" : "scan code", codeToken.c_str()); + return BAD_VALUE; + } + std::unordered_map &map = + mapUsage ? mMap->mKeysByUsageCode : mMap->mKeysByScanCode; + if (map.find(*code) != map.end()) { + LOGE("%s: Duplicate entry for key %s '%s'.", mTokenizer->getLocation().c_str(), + mapUsage ? "usage" : "scan code", codeToken.c_str()); + return BAD_VALUE; + } + + mTokenizer->skipDelimiters(WHITESPACE); + String8 keyCodeToken = mTokenizer->nextToken(WHITESPACE); + std::optional keyCode = InputEventLookup::getKeyCodeByLabel(keyCodeToken.c_str()); + + if (!keyCode) { +// LOGW("%s: Unknown key code label %s", mTokenizer->getLocation().c_str(), +// keyCodeToken.c_str()); + // Do not crash at this point because there may be more flags afterwards that need parsing. + } + + uint32_t flags = 0; + for (;;) { + mTokenizer->skipDelimiters(WHITESPACE); + if (mTokenizer->isEol() || mTokenizer->peekChar() == '#') break; + + String8 flagToken = mTokenizer->nextToken(WHITESPACE); + std::optional flag = InputEventLookup::getKeyFlagByLabel(flagToken.c_str()); + if (!flag) { + LOGE("%s: Expected key flag label, got '%s'.", mTokenizer->getLocation().c_str(), + flagToken.c_str()); + return BAD_VALUE; + } + if (flags & *flag) { + LOGE("%s: Duplicate key flag '%s'.", mTokenizer->getLocation().c_str(), + flagToken.c_str()); + return BAD_VALUE; + } + flags |= *flag; + } + + ALOGD_IF(DEBUG_PARSER, "Parsed key %s: code=%d, keyCode=%d, flags=0x%08x.", + mapUsage ? "usage" : "scan code", *code, *keyCode, flags); + + // The key code may be unknown so only insert a key if it is known. + if (keyCode) { + Key key; + key.keyCode = *keyCode; + key.flags = flags; + map.insert({*code, key}); + } + + return NO_ERROR; + } + + status_t KeyLayoutMap::Parser::parseAxis() { + String8 scanCodeToken = mTokenizer->nextToken(WHITESPACE); + std::optional scanCode = parseInt(scanCodeToken.c_str()); + if (!scanCode) { + LOGE("%s: Expected axis scan code number, got '%s'.", mTokenizer->getLocation().c_str(), + scanCodeToken.c_str()); + return BAD_VALUE; + } + if (mMap->mAxes.find(*scanCode) != mMap->mAxes.end()) { + LOGE("%s: Duplicate entry for axis scan code '%s'.", mTokenizer->getLocation().c_str(), + scanCodeToken.c_str()); + return BAD_VALUE; + } + + AxisInfo axisInfo; + + mTokenizer->skipDelimiters(WHITESPACE); + String8 token = mTokenizer->nextToken(WHITESPACE); + if (token == "invert") { + axisInfo.mode = AxisInfo::MODE_INVERT; + + mTokenizer->skipDelimiters(WHITESPACE); + String8 axisToken = mTokenizer->nextToken(WHITESPACE); + std::optional axis = InputEventLookup::getAxisByLabel(axisToken.c_str()); + if (!axis) { + LOGE("%s: Expected inverted axis label, got '%s'.", + mTokenizer->getLocation().c_str(), + axisToken.c_str()); + return BAD_VALUE; + } + axisInfo.axis = *axis; + } else if (token == "split") { + axisInfo.mode = AxisInfo::MODE_SPLIT; + + mTokenizer->skipDelimiters(WHITESPACE); + String8 splitToken = mTokenizer->nextToken(WHITESPACE); + std::optional splitValue = parseInt(splitToken.c_str()); + if (!splitValue) { + LOGE("%s: Expected split value, got '%s'.", mTokenizer->getLocation().c_str(), + splitToken.c_str()); + return BAD_VALUE; + } + axisInfo.splitValue = *splitValue; + + mTokenizer->skipDelimiters(WHITESPACE); + String8 lowAxisToken = mTokenizer->nextToken(WHITESPACE); + std::optional axis = InputEventLookup::getAxisByLabel(lowAxisToken.c_str()); + if (!axis) { + LOGE("%s: Expected low axis label, got '%s'.", mTokenizer->getLocation().c_str(), + lowAxisToken.c_str()); + return BAD_VALUE; + } + axisInfo.axis = *axis; + + mTokenizer->skipDelimiters(WHITESPACE); + String8 highAxisToken = mTokenizer->nextToken(WHITESPACE); + std::optional highAxis = InputEventLookup::getAxisByLabel(highAxisToken.c_str()); + if (!highAxis) { + LOGE("%s: Expected high axis label, got '%s'.", mTokenizer->getLocation().c_str(), + highAxisToken.c_str()); + return BAD_VALUE; + } + axisInfo.highAxis = *highAxis; + } else { + std::optional axis = InputEventLookup::getAxisByLabel(token.c_str()); + if (!axis) { + LOGE("%s: Expected axis label, 'split' or 'invert', got '%s'.", + mTokenizer->getLocation().c_str(), token.c_str()); + return BAD_VALUE; + } + axisInfo.axis = *axis; + } + + for (;;) { + mTokenizer->skipDelimiters(WHITESPACE); + if (mTokenizer->isEol() || mTokenizer->peekChar() == '#') { + break; + } + String8 keywordToken = mTokenizer->nextToken(WHITESPACE); + if (keywordToken == "flat") { + mTokenizer->skipDelimiters(WHITESPACE); + String8 flatToken = mTokenizer->nextToken(WHITESPACE); + std::optional flatOverride = parseInt(flatToken.c_str()); + if (!flatOverride) { + LOGE("%s: Expected flat value, got '%s'.", mTokenizer->getLocation().c_str(), + flatToken.c_str()); + return BAD_VALUE; + } + axisInfo.flatOverride = *flatOverride; + } else { + LOGE("%s: Expected keyword 'flat', got '%s'.", mTokenizer->getLocation().c_str(), + keywordToken.c_str()); + return BAD_VALUE; + } + } + + ALOGD_IF(DEBUG_PARSER, + "Parsed axis: scanCode=%d, mode=%d, axis=%d, highAxis=%d, " + "splitValue=%d, flatOverride=%d.", + *scanCode, axisInfo.mode, axisInfo.axis, axisInfo.highAxis, axisInfo.splitValue, + axisInfo.flatOverride); + mMap->mAxes.insert({*scanCode, axisInfo}); + return NO_ERROR; + } + +// Parse the name of a required kernel config. +// The layout won't be used if the specified kernel config is not present +// Examples: +// requires_kernel_config CONFIG_HID_PLAYSTATION + status_t KeyLayoutMap::Parser::parseRequiredKernelConfig() { + String8 codeToken = mTokenizer->nextToken(WHITESPACE); + std::string configName = codeToken.c_str(); + + const auto result = mMap->mRequiredKernelConfigs.emplace(configName); + if (!result.second) { + LOGE("%s: Duplicate entry for required kernel config %s.", + mTokenizer->getLocation().c_str(), configName.c_str()); + return BAD_VALUE; + } + +// ALOGD_IF(DEBUG_PARSER, "Parsed required kernel config: name=%s", configName.c_str()); + return NO_ERROR; + } +} // namespace android diff --git a/sysbridge/src/main/cpp/android/input/KeyLayoutMap.h b/sysbridge/src/main/cpp/android/input/KeyLayoutMap.h new file mode 100644 index 0000000000..f5646f33e3 --- /dev/null +++ b/sysbridge/src/main/cpp/android/input/KeyLayoutMap.h @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include "../utils/Tokenizer.h" +#include "../libbase/result.h" +#include + +namespace android { + + struct AxisInfo { + enum Mode { + // Axis value is reported directly. + MODE_NORMAL = 0, + // Axis value should be inverted before reporting. + MODE_INVERT = 1, + // Axis value should be split into two axes + MODE_SPLIT = 2, + }; + + // Axis mode. + Mode mode; + + // Axis id. + // When split, this is the axis used for values smaller than the split position. + int32_t axis; + + // When split, this is the axis used for values after higher than the split position. + int32_t highAxis; + + // The split value, or 0 if not split. + int32_t splitValue; + + // The flat value, or -1 if none. + int32_t flatOverride; + + AxisInfo() : mode(MODE_NORMAL), axis(-1), highAxis(-1), splitValue(0), flatOverride(-1) { + } + }; + +/** + * Describes a mapping from keyboard scan codes and joystick axes to Android key codes and axes. + * + * This object is immutable after it has been loaded. + */ + class KeyLayoutMap { + public: + static base::Result> load(const std::string &filename, + const char *contents = nullptr); + + static base::Result> loadContents(const std::string &filename, + const char *contents); + + status_t mapKey(int32_t scanCode, int32_t usageCode, + int32_t *outKeyCode, uint32_t *outFlags) const; + + std::vector findScanCodesForKey(int32_t keyCode) const; + + std::optional findScanCodeForLed(int32_t ledCode) const; + + std::vector findUsageCodesForKey(int32_t keyCode) const; + + std::optional findUsageCodeForLed(int32_t ledCode) const; + + std::optional mapAxis(int32_t scanCode) const; + + virtual ~KeyLayoutMap(); + + private: + static base::Result> load(Tokenizer *tokenizer); + + struct Key { + int32_t keyCode; + uint32_t flags; + }; + + std::unordered_map mKeysByScanCode; + std::unordered_map mKeysByUsageCode; + std::unordered_map mAxes; + std::set mRequiredKernelConfigs; + std::string mLoadFileName; + + KeyLayoutMap(); + + const Key *getKey(int32_t scanCode, int32_t usageCode) const; + + class Parser { + KeyLayoutMap *mMap; + Tokenizer *mTokenizer; + + public: + Parser(KeyLayoutMap *map, Tokenizer *tokenizer); + + ~Parser(); + + status_t parse(); + + private: + status_t parseKey(); + + status_t parseAxis(); + + status_t parseRequiredKernelConfig(); + }; + }; + +} // namespace android diff --git a/sysbridge/src/main/cpp/android/libbase/errors.h b/sysbridge/src/main/cpp/android/libbase/errors.h new file mode 100644 index 0000000000..0ad4ecabd8 --- /dev/null +++ b/sysbridge/src/main/cpp/android/libbase/errors.h @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Portable error handling functions. This is only necessary for host-side +// code that needs to be cross-platform; code that is only run on Unix should +// just use errno and strerror() for simplicity. +// +// There is some complexity since Windows has (at least) three different error +// numbers, not all of which share the same type: +// * errno: for C runtime errors. +// * GetLastError(): Windows non-socket errors. +// * WSAGetLastError(): Windows socket errors. +// errno can be passed to strerror() on all platforms, but the other two require +// special handling to get the error string. Refer to Microsoft documentation +// to determine which error code to check for each function. + +#pragma once + +#include + +#include + +namespace android { +namespace base { + +// Returns a string describing the given system error code. |error_code| must +// be errno on Unix or GetLastError()/WSAGetLastError() on Windows. Passing +// errno on Windows has undefined behavior. +std::string SystemErrorCodeToString(int error_code); + +} // namespace base +} // namespace android + +// Convenient macros for evaluating a statement, checking if the result is error, and returning it +// to the caller. If it is ok then the inner value is unwrapped (if applicable) and returned. +// +// Usage with Result: +// +// Result getFoo() {...} +// +// Result getBar() { +// Foo foo = OR_RETURN(getFoo()); +// return Bar{foo}; +// } +// +// Usage with status_t: +// +// status_t getFoo(Foo*) {...} +// +// status_t getBar(Bar* bar) { +// Foo foo; +// OR_RETURN(getFoo(&foo)); +// *bar = Bar{foo}; +// return OK; +// } +// +// Actually this can be used for any type as long as the OkOrFail contract is satisfied. See +// below. +// If implicit conversion compilation errors occur involving a value type with a templated +// forwarding ref ctor, compilation with cpp20 or explicitly converting to the desired +// return type is required. +#define OR_RETURN(expr) \ + UNWRAP_OR_DO(__or_return_expr, expr, { return ok_or_fail::Fail(std::move(__or_return_expr)); }) + +// Same as OR_RETURN, but aborts if expr is a failure. +#if defined(__BIONIC__) +#define OR_FATAL(expr) \ + UNWRAP_OR_DO(__or_fatal_expr, expr, { \ + __assert(__FILE__, __LINE__, ok_or_fail::ErrorMessage(__or_fatal_expr).c_str()); \ + }) +#else +#define OR_FATAL(expr) \ + UNWRAP_OR_DO(__or_fatal_expr, expr, { \ + fprintf(stderr, "%s:%d: assertion \"%s\" failed", __FILE__, __LINE__, \ + ok_or_fail::ErrorMessage(__or_fatal_expr).c_str()); \ + abort(); \ + }) +#endif + +// Variant for use in gtests, which aborts the test function with an assertion failure on error. +// This is akin to ASSERT_OK_AND_ASSIGN for absl::Status, except the assignment is external. It +// assumes the user depends on libgmock and includes gtest/gtest.h. +#define OR_ASSERT_FAIL(expr) \ + UNWRAP_OR_DO(__or_assert_expr, expr, { \ + FAIL() << "Value of: " << #expr << "\n" \ + << " Actual: " << __or_assert_expr.error().message() << "\n" \ + << "Expected: is ok\n"; \ + }) + +// Generic macro to execute any statement(s) on error. Execution should never reach the end of them. +// result_var is assigned expr and is only visible to on_error_stmts. +#define UNWRAP_OR_DO(result_var, expr, on_error_stmts) \ + ({ \ + decltype(expr)&& result_var = (expr); \ + typedef android::base::OkOrFail> ok_or_fail; \ + if (!ok_or_fail::IsOk(result_var)) { \ + { \ + on_error_stmts; \ + } \ + __builtin_unreachable(); \ + } \ + ok_or_fail::Unwrap(std::move(result_var)); \ + }) + +namespace android { +namespace base { + +// The OkOrFail contract for a type T. This must be implemented for a type T if you want to use +// OR_RETURN(stmt) where stmt evalues to a value of type T. +template +struct OkOrFail { + // Checks if T is ok or fail. + static bool IsOk(const T&); + + // Turns T into the success value. + template + static U Unwrap(T&&); + + // Moves T into OkOrFail, so that we can convert it to other types + OkOrFail(T&& v); + OkOrFail() = delete; + OkOrFail(const T&) = delete; + + // And there need to be one or more conversion operators that turns the error value of T into a + // target type. For example, for T = Result, there can be ... + // + // // for the case where OR_RETURN is called in a function expecting E + // operator E()&& { return val_.error().code(); } + // + // // for the case where OR_RETURN is called in a function expecting Result + // template + // operator Result()&& { return val_.error(); } + + // And there needs to be a method that returns the string representation of the fail value. + // static const std::string& ErrorMessage(const T& v); + // or + // static std::string ErrorMessage(const T& v); +}; + +} // namespace base +} // namespace android diff --git a/sysbridge/src/main/cpp/android/libbase/expected.h b/sysbridge/src/main/cpp/android/libbase/expected.h new file mode 100644 index 0000000000..ddab9e5b57 --- /dev/null +++ b/sysbridge/src/main/cpp/android/libbase/expected.h @@ -0,0 +1,622 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include +#include + +// android::base::expected is a partial implementation of C++23's std::expected +// for Android. +// +// Usage: +// using android::base::expected; +// using android::base::unexpected; +// +// expected safe_divide(double i, double j) { +// if (j == 0) return unexpected("divide by zero"); +// else return i / j; +// } +// +// void test() { +// auto q = safe_divide(10, 0); +// if (q.ok()) { printf("%f\n", q.value()); } +// else { printf("%s\n", q.error().c_str()); } +// } +// +// Once the Android platform has moved to C++23, this will be removed and +// android::base::expected will be type aliased to std::expected. +// + +namespace android { +namespace base { + +// Synopsis +template +class expected; + +template +class unexpected; +template +unexpected(E) -> unexpected; + +template +class bad_expected_access; + +template <> +class bad_expected_access; + +struct unexpect_t { + explicit unexpect_t() = default; +}; +inline constexpr unexpect_t unexpect{}; + +// macros for SFINAE +#define _ENABLE_IF(...) \ + , std::enable_if_t<(__VA_ARGS__)>* = nullptr + +// Define NODISCARD_EXPECTED to prevent expected from being +// ignored when used as a return value. This is off by default. +#ifdef NODISCARD_EXPECTED +#define _NODISCARD_ [[nodiscard]] +#else +#define _NODISCARD_ +#endif + +#define _EXPLICIT(cond) \ + _Pragma("clang diagnostic push") \ + _Pragma("clang diagnostic ignored \"-Wc++20-extensions\"") explicit(cond) \ + _Pragma("clang diagnostic pop") + +#define _COMMA , + +namespace expected_internal { + +template +struct remove_cvref { + using type = std::remove_cv_t>; +}; + +template +using remove_cvref_t = typename remove_cvref::type; + +// Can T be constructed from W (or W converted to T)? W can be lvalue or rvalue, +// const or not. +template +inline constexpr bool converts_from_any_cvref = + std::disjunction_v, std::is_convertible, + std::is_constructible, std::is_convertible, + std::is_constructible, std::is_convertible, + std::is_constructible, std::is_convertible>; + +template +struct is_expected : std::false_type {}; + +template +struct is_expected> : std::true_type {}; + +template +inline constexpr bool is_expected_v = is_expected::value; + +template +struct is_unexpected : std::false_type {}; + +template +struct is_unexpected> : std::true_type {}; + +template +inline constexpr bool is_unexpected_v = is_unexpected::value; + +// Constraints on constructing an expected from an expected +// related to T and U. UF is either "const U&" or "U". +template +inline constexpr bool convert_value_constraints = + std::is_constructible_v && + (std::is_same_v, bool> || !converts_from_any_cvref>); + +// Constraints on constructing an expected<..., E> from an expected +// related to E, G, and expected. GF is either "const G&" or "G". +template +inline constexpr bool convert_error_constraints = + std::is_constructible_v && + !std::is_constructible_v, expected&> && + !std::is_constructible_v, expected> && + !std::is_constructible_v, const expected&> && + !std::is_constructible_v, const expected>; + +// If an exception is thrown in expected::operator=, while changing the expected +// object between a value and an error, the expected object is supposed to +// retain its original value, which is only possible if certain constructors +// are noexcept. This implementation doesn't try to be exception-safe, but +// enforce these constraints anyway because std::expected also will enforce +// them, and we intend to switch to it eventually. +template +inline constexpr bool eh_assign_constraints = + std::is_nothrow_constructible_v || + std::is_nothrow_move_constructible_v || + std::is_nothrow_move_constructible_v; + +// Implement expected<..., E>::expected([const] unexpected [&/&&]). +#define _CONSTRUCT_EXPECTED_FROM_UNEXPECTED(GF, ParamType, forward_func) \ + template )> \ + constexpr _EXPLICIT((!std::is_convertible_v)) \ + expected(ParamType e) noexcept(std::is_nothrow_constructible_v) \ + : var_(std::in_place_index<1>, forward_func(e.error())) {} + +// Implement expected<..., E>::operator=([const] unexpected [&/&&]). +#define _ASSIGN_UNEXPECTED_TO_EXPECTED(GF, ParamType, forward_func, extra_constraints) \ + template && \ + std::is_assignable_v) && \ + extra_constraints> \ + constexpr expected& operator=(ParamType e) noexcept(std::is_nothrow_constructible_v && \ + std::is_nothrow_assignable_v) { \ + if (has_value()) { \ + var_.template emplace<1>(forward_func(e.error())); \ + } else { \ + error() = forward_func(e.error()); \ + } \ + return *this; \ + } + +} // namespace expected_internal + +// Class expected +template +class _NODISCARD_ expected { + static_assert(std::is_object_v && !std::is_array_v && + !std::is_same_v, std::in_place_t> && + !std::is_same_v, unexpect_t> && + !expected_internal::is_unexpected_v>, + "expected value type cannot be a reference, a function, an array, in_place_t, " + "unexpect_t, or unexpected"); + + public: + using value_type = T; + using error_type = E; + using unexpected_type = unexpected; + + template + using rebind = expected; + + // Delegate simple operations to the underlying std::variant. std::variant + // doesn't set noexcept well, at least for copy ctor/assign, so set it + // explicitly. Technically the copy/move assignment operators should also be + // deleted if neither T nor E satisfies is_nothrow_move_constructible_v, but + // that would require making these operator= methods into template functions. + constexpr expected() = default; + constexpr expected(const expected& rhs) noexcept( + std::is_nothrow_copy_constructible_v && std::is_nothrow_copy_constructible_v) = default; + constexpr expected(expected&& rhs) noexcept(std::is_nothrow_move_constructible_v && + std::is_nothrow_move_constructible_v) = default; + constexpr expected& operator=(const expected& rhs) noexcept( + std::is_nothrow_copy_constructible_v && std::is_nothrow_copy_assignable_v && + std::is_nothrow_copy_constructible_v && std::is_nothrow_copy_assignable_v) = default; + constexpr expected& operator=(expected&& rhs) noexcept( + std::is_nothrow_move_constructible_v && std::is_nothrow_move_assignable_v && + std::is_nothrow_move_constructible_v && std::is_nothrow_move_assignable_v) = default; + + // Construct this expected from a different expected type. +#define _CONVERTING_CTOR(UF, GF, ParamType, forward_func) \ + template && \ + expected_internal::convert_error_constraints)> \ + constexpr _EXPLICIT((!std::is_convertible_v || !std::is_convertible_v)) \ + expected(ParamType rhs) noexcept(std::is_nothrow_constructible_v && \ + std::is_nothrow_constructible_v) \ + : var_(rhs.has_value() ? variant_type(std::in_place_index<0>, forward_func(rhs.value())) \ + : variant_type(std::in_place_index<1>, forward_func(rhs.error()))) {} + + // NOLINTNEXTLINE(google-explicit-constructor) + _CONVERTING_CTOR(const U&, const G&, const expected&, ) + // NOLINTNEXTLINE(google-explicit-constructor) + _CONVERTING_CTOR(U, G, expected&&, std::move) + +#undef _CONVERTING_CTOR + + // Construct from (converted) success value, using a forwarding reference. + template , std::in_place_t> && + !std::is_same_v, expected> && + !expected_internal::is_unexpected_v> && + std::is_constructible_v && + (!std::is_same_v, bool> || + !expected_internal::is_expected_v>))> + constexpr _EXPLICIT((!std::is_convertible_v)) + // NOLINTNEXTLINE(google-explicit-constructor) + expected(U&& v) noexcept(std::is_nothrow_constructible_v) + : var_(std::in_place_index<0>, std::forward(v)) {} + + // NOLINTNEXTLINE(google-explicit-constructor) + _CONSTRUCT_EXPECTED_FROM_UNEXPECTED(const G&, const unexpected&, ) + // NOLINTNEXTLINE(google-explicit-constructor) + _CONSTRUCT_EXPECTED_FROM_UNEXPECTED(G, unexpected&&, std::move) + + // in_place_t construction + template )> + constexpr explicit expected(std::in_place_t, Args&&... args) + noexcept(std::is_nothrow_constructible_v) + : var_(std::in_place_index<0>, std::forward(args)...) {} + + // in_place_t with initializer_list construction + template &, Args...>)> + constexpr explicit expected(std::in_place_t, std::initializer_list il, Args&&... args) + noexcept(std::is_nothrow_constructible_v&, Args...>) + : var_(std::in_place_index<0>, il, std::forward(args)...) {} + + // unexpect_t construction + template )> + constexpr explicit expected(unexpect_t, Args&&... args) + noexcept(std::is_nothrow_constructible_v) + : var_(std::in_place_index<1>, unexpected_type(std::forward(args)...)) {} + + // unexpect_t with initializer_list construction + template &, Args...>)> + constexpr explicit expected(unexpect_t, std::initializer_list il, Args&&... args) + noexcept(std::is_nothrow_constructible_v&, Args...>) + : var_(std::in_place_index<1>, unexpected_type(il, std::forward(args)...)) {} + + // Assignment from (converted) success value, using a forwarding reference. + template > && + !expected_internal::is_unexpected_v> && + std::is_constructible_v && std::is_assignable_v && + expected_internal::eh_assign_constraints)> + constexpr expected& operator=(U&& v) noexcept(std::is_nothrow_constructible_v && + std::is_nothrow_assignable_v) { + if (has_value()) { + value() = std::forward(v); + } else { + var_.template emplace<0>(std::forward(v)); + } + return *this; + } + + _ASSIGN_UNEXPECTED_TO_EXPECTED(const G&, const unexpected&, , + (expected_internal::eh_assign_constraints)) + _ASSIGN_UNEXPECTED_TO_EXPECTED(G, unexpected&&, std::move, + (expected_internal::eh_assign_constraints)) + + // modifiers + template )> + constexpr T& emplace(Args&&... args) noexcept { + var_.template emplace<0>(std::forward(args)...); + return value(); + } + + template &, Args...>)> + constexpr T& emplace(std::initializer_list il, Args&&... args) noexcept { + var_.template emplace<0>(il, std::forward(args)...); + return value(); + } + + // Swap. This function takes a template argument so that _ENABLE_IF works. + template && + std::is_swappable_v && std::is_swappable_v && + std::is_move_constructible_v && std::is_move_constructible_v && + (std::is_nothrow_move_constructible_v || + std::is_nothrow_move_constructible_v))> + constexpr void swap(expected& rhs) noexcept(std::is_nothrow_move_constructible_v && + std::is_nothrow_swappable_v && + std::is_nothrow_move_constructible_v && + std::is_nothrow_swappable_v) { + var_.swap(rhs.var_); + } + + // observers + constexpr const T* operator->() const { return std::addressof(value()); } + constexpr T* operator->() { return std::addressof(value()); } + constexpr const T& operator*() const& { return value(); } + constexpr T& operator*() & { return value(); } + constexpr const T&& operator*() const&& { return std::move(std::get(var_)); } + constexpr T&& operator*() && { return std::move(std::get(var_)); } + + constexpr bool has_value() const noexcept { return var_.index() == 0; } + constexpr bool ok() const noexcept { return has_value(); } + constexpr explicit operator bool() const noexcept { return has_value(); } + + constexpr const T& value() const& { return std::get(var_); } + constexpr T& value() & { return std::get(var_); } + constexpr const T&& value() const&& { return std::move(std::get(var_)); } + constexpr T&& value() && { return std::move(std::get(var_)); } + + constexpr const E& error() const& { return std::get(var_).error(); } + constexpr E& error() & { return std::get(var_).error(); } + constexpr const E&& error() const&& { return std::move(std::get(var_)).error(); } + constexpr E&& error() && { return std::move(std::get(var_)).error(); } + + template && + std::is_convertible_v + )> + constexpr T value_or(U&& v) const& { + if (has_value()) return value(); + else return static_cast(std::forward(v)); + } + + template && + std::is_convertible_v + )> + constexpr T value_or(U&& v) && { + if (has_value()) return std::move(value()); + else return static_cast(std::forward(v)); + } + + // expected equality operators + template + friend constexpr bool operator==(const expected& x, const expected& y); + template + friend constexpr bool operator!=(const expected& x, const expected& y); + + // Comparison with unexpected + template + friend constexpr bool operator==(const expected&, const unexpected&); + template + friend constexpr bool operator==(const unexpected&, const expected&); + template + friend constexpr bool operator!=(const expected&, const unexpected&); + template + friend constexpr bool operator!=(const unexpected&, const expected&); + + private: + using variant_type = std::variant; + variant_type var_; +}; + +template +constexpr bool operator==(const expected& x, const expected& y) { + if (x.has_value() != y.has_value()) return false; + if (!x.has_value()) return x.error() == y.error(); + return *x == *y; +} + +template +constexpr bool operator!=(const expected& x, const expected& y) { + return !(x == y); +} + +// Comparison with unexpected +template +constexpr bool operator==(const expected& x, const unexpected& y) { + return !x.has_value() && (x.error() == y.error()); +} +template +constexpr bool operator==(const unexpected& x, const expected& y) { + return !y.has_value() && (x.error() == y.error()); +} +template +constexpr bool operator!=(const expected& x, const unexpected& y) { + return x.has_value() || (x.error() != y.error()); +} +template +constexpr bool operator!=(const unexpected& x, const expected& y) { + return y.has_value() || (x.error() != y.error()); +} + +template +class _NODISCARD_ expected { + public: + using value_type = void; + using error_type = E; + using unexpected_type = unexpected; + + template + using rebind = expected; + + // Delegate simple operations to the underlying std::variant. + constexpr expected() = default; + constexpr expected(const expected& rhs) noexcept(std::is_nothrow_copy_constructible_v) = + default; + constexpr expected(expected&& rhs) noexcept(std::is_nothrow_move_constructible_v) = default; + constexpr expected& operator=(const expected& rhs) noexcept( + std::is_nothrow_copy_constructible_v && std::is_nothrow_copy_assignable_v) = default; + constexpr expected& operator=(expected&& rhs) noexcept( + std::is_nothrow_move_constructible_v && std::is_nothrow_move_assignable_v) = default; + + // Construct this expected from a different expected type. +#define _CONVERTING_CTOR(GF, ParamType, forward_func) \ + template && \ + expected_internal::convert_error_constraints)> \ + constexpr _EXPLICIT((!std::is_convertible_v)) \ + expected(ParamType rhs) noexcept(std::is_nothrow_constructible_v) \ + : var_(rhs.has_value() ? variant_type(std::in_place_index<0>, std::monostate()) \ + : variant_type(std::in_place_index<1>, forward_func(rhs.error()))) {} + + // NOLINTNEXTLINE(google-explicit-constructor) + _CONVERTING_CTOR(const G&, const expected&, ) + // NOLINTNEXTLINE(google-explicit-constructor) + _CONVERTING_CTOR(G, expected&&, std::move) + +#undef _CONVERTING_CTOR + + // NOLINTNEXTLINE(google-explicit-constructor) + _CONSTRUCT_EXPECTED_FROM_UNEXPECTED(const G&, const unexpected&, ) + // NOLINTNEXTLINE(google-explicit-constructor) + _CONSTRUCT_EXPECTED_FROM_UNEXPECTED(G, unexpected&&, std::move) + + // in_place_t construction + constexpr explicit expected(std::in_place_t) noexcept {} + + // unexpect_t construction + template )> + constexpr explicit expected(unexpect_t, Args&&... args) + noexcept(std::is_nothrow_constructible_v) + : var_(std::in_place_index<1>, unexpected_type(std::forward(args)...)) {} + + // unexpect_t with initializer_list construction + template &, Args...>)> + constexpr explicit expected(unexpect_t, std::initializer_list il, Args&&... args) + noexcept(std::is_nothrow_constructible_v&, Args...>) + : var_(std::in_place_index<1>, unexpected_type(il, std::forward(args)...)) {} + + _ASSIGN_UNEXPECTED_TO_EXPECTED(const G&, const unexpected&, , true) + _ASSIGN_UNEXPECTED_TO_EXPECTED(G, unexpected&&, std::move, true) + + // modifiers + constexpr void emplace() noexcept { var_.template emplace<0>(std::monostate()); } + + // Swap. This function takes a template argument so that _ENABLE_IF works. + template && + std::is_swappable_v && std::is_move_constructible_v)> + constexpr void swap(expected& rhs) noexcept(std::is_nothrow_move_constructible_v && + std::is_nothrow_swappable_v) { + var_.swap(rhs.var_); + } + + // observers + constexpr bool has_value() const noexcept { return var_.index() == 0; } + constexpr bool ok() const noexcept { return has_value(); } + constexpr explicit operator bool() const noexcept { return has_value(); } + + constexpr void value() const& { if (!has_value()) std::get<0>(var_); } + + constexpr const E& error() const& { return std::get<1>(var_).error(); } + constexpr E& error() & { return std::get<1>(var_).error(); } + constexpr const E&& error() const&& { return std::move(std::get<1>(var_)).error(); } + constexpr E&& error() && { return std::move(std::get<1>(var_)).error(); } + + // expected equality operators + template + friend constexpr bool operator==(const expected& x, const expected& y); + + private: + using variant_type = std::variant; + variant_type var_; +}; + +template +constexpr bool operator==(const expected& x, const expected& y) { + if (x.has_value() != y.has_value()) return false; + if (!x.has_value()) return x.error() == y.error(); + return true; +} + +template +constexpr bool operator==(const expected& x, const expected& y) { + if (x.has_value() != y.has_value()) return false; + if (!x.has_value()) return x.error() == y.error(); + return false; +} + +template +constexpr bool operator==(const expected& x, const expected& y) { + if (x.has_value() != y.has_value()) return false; + if (!x.has_value()) return x.error() == y.error(); + return false; +} + +template &>().swap(std::declval&>()))> +constexpr void swap(expected& x, expected& y) noexcept(noexcept(x.swap(y))) { + x.swap(y); +} + +template +class unexpected { + static_assert(std::is_object_v && !std::is_array_v && !std::is_const_v && + !std::is_volatile_v && !expected_internal::is_unexpected_v, + "unexpected error type cannot be a reference, a function, an array, cv-qualified, " + "or unexpected"); + + public: + // constructors + constexpr unexpected(const unexpected&) = default; + constexpr unexpected(unexpected&&) = default; + + template , unexpected> && + !std::is_same_v, std::in_place_t> && + std::is_constructible_v)> + constexpr explicit unexpected(Err&& e) noexcept(std::is_nothrow_constructible_v) + : val_(std::forward(e)) {} + + template )> + constexpr explicit unexpected(std::in_place_t, Args&&... args) + noexcept(std::is_nothrow_constructible_v) + : val_(std::forward(args)...) {} + + template &, Args...>)> + constexpr explicit unexpected(std::in_place_t, std::initializer_list il, Args&&... args) + noexcept(std::is_nothrow_constructible_v&, Args...>) + : val_(il, std::forward(args)...) {} + + constexpr unexpected& operator=(const unexpected&) = default; + constexpr unexpected& operator=(unexpected&&) = default; + + // observer + constexpr const E& error() const& noexcept { return val_; } + constexpr E& error() & noexcept { return val_; } + constexpr const E&& error() const&& noexcept { return std::move(val_); } + constexpr E&& error() && noexcept { return std::move(val_); } + + // Swap. This function takes a template argument so that _ENABLE_IF works. + template && std::is_swappable_v)> + void swap(unexpected& other) noexcept(std::is_nothrow_swappable_v) { + // Make std::swap visible to provide swap for STL and builtin types, but use + // an unqualified swap to invoke argument-dependent lookup to find the swap + // functions for user-declared types. + using std::swap; + swap(val_, other.val_); + } + + template + friend constexpr bool + operator==(const unexpected& e1, const unexpected& e2); + template + friend constexpr bool + operator!=(const unexpected& e1, const unexpected& e2); + + private: + E val_; +}; + +template +constexpr bool +operator==(const unexpected& e1, const unexpected& e2) { + return e1.error() == e2.error(); +} + +template +constexpr bool +operator!=(const unexpected& e1, const unexpected& e2) { + return e1.error() != e2.error(); +} + +template )> +void swap(unexpected& x, unexpected& y) noexcept(noexcept(x.swap(y))) { + x.swap(y); +} + +// TODO: bad_expected_access class + +#undef _ENABLE_IF +#undef _NODISCARD_ +#undef _EXPLICIT +#undef _COMMA +#undef _CONSTRUCT_EXPECTED_FROM_UNEXPECTED +#undef _ASSIGN_UNEXPECTED_TO_EXPECTED + +} // namespace base +} // namespace android diff --git a/sysbridge/src/main/cpp/android/libbase/result.cpp b/sysbridge/src/main/cpp/android/libbase/result.cpp new file mode 100644 index 0000000000..c7163f270b --- /dev/null +++ b/sysbridge/src/main/cpp/android/libbase/result.cpp @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "result.h" + +namespace android { +namespace base { + +ResultError MakeResultErrorWithCode(std::string&& message, Errno code) { + return ResultError(std::move(message) + ": " + code.print(), code); +} + +} // namespace base +} // namespace android diff --git a/sysbridge/src/main/cpp/android/libbase/result.h b/sysbridge/src/main/cpp/android/libbase/result.h new file mode 100644 index 0000000000..68c93344f8 --- /dev/null +++ b/sysbridge/src/main/cpp/android/libbase/result.h @@ -0,0 +1,527 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Result is the type that is used to pass a success value of type T or an error code of type +// E, optionally together with an error message. T and E can be any type. If E is omitted it +// defaults to int, which is useful when errno(3) is used as the error code. +// +// Passing a success value or an error value: +// +// Result readFile() { +// std::string content; +// if (base::ReadFileToString("path", &content)) { +// return content; // ok case +// } else { +// return ErrnoError() << "failed to read"; // error case +// } +// } +// +// Checking the result and then unwrapping the value or propagating the error: +// +// Result hasAWord() { +// auto content = readFile(); +// if (!content.ok()) { +// return Error() << "failed to process: " << content.error(); +// } +// return (*content.find("happy") != std::string::npos); +// } +// +// Using custom error code type: +// +// enum class MyError { A, B }; // assume that this is the error code you already have +// +// // To use the error code with Result, define a wrapper class that provides the following +// operations and use the wrapper class as the second type parameter (E) when instantiating +// Result +// +// 1. default constructor +// 2. copy constructor / and move constructor if copying is expensive +// 3. conversion operator to the error code type +// 4. value() function that return the error code value +// 5. print() function that gives a string representation of the error ode value +// +// struct MyErrorWrapper { +// MyError val_; +// MyErrorWrapper() : val_(/* reasonable default value */) {} +// MyErrorWrapper(MyError&& e) : val_(std:forward(e)) {} +// operator const MyError&() const { return val_; } +// MyError value() const { return val_; } +// std::string print() const { +// switch(val_) { +// MyError::A: return "A"; +// MyError::B: return "B"; +// } +// } +// }; +// +// #define NewMyError(e) Error(MyError::e) +// +// Result val = NewMyError(A) << "some message"; +// +// Formatting the error message using fmtlib: +// +// Errorf("{} errors", num); // equivalent to Error() << num << " errors"; +// ErrnoErrorf("{} errors", num); // equivalent to ErrnoError() << num << " errors"; +// +// Returning success or failure, but not the value: +// +// Result doSomething() { +// if (success) return {}; +// else return Error() << "error occurred"; +// } +// +// Extracting error code: +// +// Result val = Error(3) << "some error occurred"; +// assert(3 == val.error().code()); +// + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +#include "errors.h" +#include "expected.h" + +namespace android { + namespace base { + +// Errno is a wrapper class for errno(3). Use this type instead of `int` when instantiating +// `Result` and `Error` template classes. This is required to distinguish errno from other +// integer-based error code types like `status_t`. + struct Errno { + Errno() : val_(0) {} + + Errno(int e) : val_(e) {} + + int value() const { return val_; } + + operator int() const { return value(); } + + const char *print() const { return strerror(value()); } + + int val_; + + // TODO(b/209929099): remove this conversion operator. This currently is needed to not break + // existing places where error().code() is used to construct enum values. + template>> + operator E() const { + return E(val_); + } + }; + + static_assert(std::is_trivially_copyable_v == true); + + template + struct ResultError { + template>> + ResultError(T &&message, P &&code) + : message_(std::forward(message)), code_(E(std::forward

(code))) {} + + ResultError(const ResultError &other) = default; + + ResultError(ResultError &&other) = default; + + ResultError &operator=(const ResultError &other) = default; + + ResultError &operator=(ResultError &&other) = default; + + template + // NOLINTNEXTLINE(google-explicit-constructor) + operator android::base::expected>() &&{ + return android::base::unexpected(std::move(*this)); + } + + template + // NOLINTNEXTLINE(google-explicit-constructor) + operator android::base::expected>() const &{ + return android::base::unexpected(*this); + } + + const std::string &message() const { return message_; } + + const E &code() const { return code_; } + + private: + std::string message_; + E code_; + }; + + template + auto format_as(ResultError error) { + return error.message(); + } + + template + struct ResultError { + template>> + ResultError(P &&code) : code_(E(std::forward

(code))) {} + + template + operator android::base::expected>() const { + return android::base::unexpected(ResultError(code_)); + } + + const E &code() const { return code_; } + + private: + E code_; + }; + + template + inline bool operator==(const ResultError &lhs, const ResultError &rhs) { + return lhs.message() == rhs.message() && lhs.code() == rhs.code(); + } + + template + inline bool operator!=(const ResultError &lhs, const ResultError &rhs) { + return !(lhs == rhs); + } + + template + inline std::ostream &operator<<(std::ostream &os, const ResultError &t) { + os << t.message(); + return os; + } + + namespace internal { +// Stream class that does nothing and is has zero (actually 1) size. It is used instead of +// std::stringstream when include_message is false so that we use less on stack. +// sizeof(std::stringstream) is 280 on arm64. + struct DoNothingStream { + template + DoNothingStream &operator<<(T &&) { + return *this; + } + + std::string str() const { return ""; } + }; + } // namespace internal + + template>> + class Error { + public: + Error() : code_(0), has_code_(false) {} + + template>> + // NOLINTNEXTLINE(google-explicit-constructor) + Error(P &&code) : code_(std::forward

(code)), has_code_(true) {} + + template>> + // NOLINTNEXTLINE(google-explicit-constructor) + operator android::base::expected>() const { + return android::base::unexpected(ResultError

(str(), static_cast

(code_))); + } + + template>> + // NOLINTNEXTLINE(google-explicit-constructor) + operator android::base::expected>() const { + return android::base::unexpected(ResultError(static_cast

(code_))); + } + + template + Error &operator<<(T &&t) { + static_assert(include_message, "<< not supported when include_message = false"); + // NOLINTNEXTLINE(bugprone-suspicious-semicolon) + if constexpr (std::is_same_v>, ResultError>) { + if (!has_code_) { + code_ = t.code(); + } + return (*this) << t.message(); + } + int saved = errno; + ss_ << t; + errno = saved; + return *this; + } + + const std::string str() const { + static_assert(include_message, "str() not supported when include_message = false"); + std::string str = ss_.str(); + if (has_code_) { + if (str.empty()) { + return code_.print(); + } + return std::move(str) + ": " + code_.print(); + } + return str; + } + + Error(const Error &) = delete; + + Error(Error &&) = delete; + + Error &operator=(const Error &) = delete; + + Error &operator=(Error &&) = delete; + + template + friend Error ErrorfImpl(const std::string &fmt, const Args &... args); + + template + friend Error ErrnoErrorfImpl(const std::string &fmt, const Args &... args); + + private: + Error(bool has_code, E code, const std::string &message) : code_(code), + has_code_(has_code) { + (*this) << message; + } + + std::conditional_t ss_; + E code_; + const bool has_code_; + }; + + inline Error ErrnoError() { + return Error(Errno{errno}); + } + + template + inline E ErrorCode(E code) { + return code; + } + +// Return the error code of the last ResultError object, if any. +// Otherwise, return `code` as it is. + template + inline E ErrorCode(E code, T &&t, const Args &... args) { + if constexpr (std::is_same_v>, ResultError>) { + return ErrorCode(t.code(), args...); + } + return ErrorCode(code, args...); + } + + __attribute__((noinline)) ResultError MakeResultErrorWithCode(std::string &&message, + Errno code); + + template + inline ResultError ErrorfImpl(const std::string &fmt, const Args &... args) { + return ResultError(fmt, ErrorCode(Errno{}, args...)); + } + + template + inline ResultError ErrnoErrorfImpl(const std::string &fmt, const Args &... args) { + Errno code{errno}; + return MakeResultErrorWithCode(std::string(fmt), code); + } + +#define Errorf(fmt, ...) android::base::ErrorfImpl(fmt, ##__VA_ARGS__) +#define ErrnoErrorf(fmt, ...) android::base::ErrnoErrorfImpl(fmt, ##__VA_ARGS__) + + template + using Result = android::base::expected>; + +// Specialization of android::base::OkOrFail for V = Result. See android-base/errors.h +// for the contract. + + namespace impl { + template + using Code = std::decay_t().error().code())>; + + template + using ErrorType = std::decay_t().error())>; + + template + constexpr bool IsNumeric = std::is_integral_v || std::is_floating_point_v || + (std::is_enum_v && std::is_convertible_v); + +// This base class exists to take advantage of shadowing +// We include the conversion in this base class so that if the conversion in NumericConversions +// overlaps, we (arbitrarily) choose the implementation in NumericConversions due to shadowing. + template + struct ConversionBase { + ErrorType error_; + + // T is a expected>. + operator T() const & { return unexpected(error_); } + + operator T() && { return unexpected(std::move(error_)); } + + operator Code() const { return error_.code(); } + }; + +// User defined conversions can be followed by numeric conversions +// Although we template specialize for the exact code type, we need +// specializations for conversions to all numeric types to avoid an +// ambiguous conversion sequence. + template + struct NumericConversions : public ConversionBase { + }; + + template + struct NumericConversions>> + > : public ConversionBase { +#pragma push_macro("SPECIALIZED_CONVERSION") +#define SPECIALIZED_CONVERSION(type) \ + operator expected>() const& { return unexpected(this->error_); } \ + operator expected>()&& { return unexpected(std::move(this->error_)); } + + SPECIALIZED_CONVERSION(int) + + SPECIALIZED_CONVERSION(short int) + + SPECIALIZED_CONVERSION(unsigned short int) + + SPECIALIZED_CONVERSION(unsigned int) + + SPECIALIZED_CONVERSION(long int) + + SPECIALIZED_CONVERSION(unsigned long int) + + SPECIALIZED_CONVERSION(long long int) + + SPECIALIZED_CONVERSION(unsigned long long int) + + SPECIALIZED_CONVERSION(bool) + + SPECIALIZED_CONVERSION(char) + + SPECIALIZED_CONVERSION(unsigned char) + + SPECIALIZED_CONVERSION(signed char) + + SPECIALIZED_CONVERSION(wchar_t) + + SPECIALIZED_CONVERSION(char16_t) + + SPECIALIZED_CONVERSION(char32_t) + + SPECIALIZED_CONVERSION(float) + + SPECIALIZED_CONVERSION(double) + + SPECIALIZED_CONVERSION(long double) + +#undef SPECIALIZED_CONVERSION +#pragma pop_macro("SPECIALIZED_CONVERSION") + // For debugging purposes + using IsNumericT = std::true_type; + }; + +#ifdef __cpp_concepts + template +// Define a concept which **any** type matches to + concept Universal = std::is_same_v; +#endif + +// A type that is never used. + struct Never { + }; + } // namespace impl + + template + struct OkOrFail> + : public impl::NumericConversions> { + using V = Result; + using Err = impl::ErrorType; + using C = impl::Code; + private: + OkOrFail(Err &&v) : impl::NumericConversions{std::move(v)} {} + + OkOrFail(const OkOrFail &other) = delete; + + OkOrFail(const OkOrFail &&other) = delete; + + public: + // Checks if V is ok or fail + static bool IsOk(const V &val) { return val.ok(); } + + // Turns V into a success value + static T Unwrap(V &&val) { + if constexpr (std::is_same_v) { + assert(IsOk(val)); + return; + } else { + return std::move(val.value()); + } + } + + // Consumes V when it's a fail value + static OkOrFail Fail(V &&v) { + assert(!IsOk(v)); + return OkOrFail{std::move(v.error())}; + } + + // We specialize as much as possible to avoid ambiguous conversion with templated expected ctor. + // We don't need this specialization if `C` is numeric because that case is already covered by + // `NumericConversions`. + operator Result, impl::Never, C>, E, include_message>() + const & { + return unexpected(this->error_); + } + + operator Result, impl::Never, C>, E, include_message>() && { + return unexpected(std::move(this->error_)); + } + +#ifdef __cpp_concepts + + // The idea here is to match this template method to any type (not simply trivial types). + // The reason for including a constraint is to take advantage of the fact that a constrained + // method always has strictly lower precedence than a non-constrained method in template + // specialization rules (thus avoiding ambiguity). So we use a universally matching constraint to + // mark this function as less preferable (but still accepting of all types). + template + operator Result() const &{ + return unexpected(this->error_); + } + + template + operator Result() &&{ + return unexpected(std::move(this->error_)); + } + +#else + template + operator Result() const& { + return unexpected(this->error_); + } + template + operator Result() && { + return unexpected(std::move(this->error_)); + } +#endif + + static const std::string &ErrorMessage(const V &val) { return val.error().message(); } + }; + +// Macros for testing the results of functions that return android::base::Result. These also work +// with base::android::expected. They assume the user depends on libgmock and includes +// gtest/gtest.h. For advanced matchers and customized error messages, see result-gmock.h. + +#define ASSERT_RESULT_OK(stmt) \ + if (const auto& tmp = (stmt); !tmp.ok()) \ + FAIL() << "Value of: " << #stmt << "\n" \ + << " Actual: " << tmp.error().message() << "\n" \ + << "Expected: is ok\n" + +#define EXPECT_RESULT_OK(stmt) \ + if (const auto& tmp = (stmt); !tmp.ok()) \ + ADD_FAILURE() << "Value of: " << #stmt << "\n" \ + << " Actual: " << tmp.error().message() << "\n" \ + << "Expected: is ok\n" + + } // namespace base +} // namespace android diff --git a/sysbridge/src/main/cpp/android/libbase/stringprintf.cpp b/sysbridge/src/main/cpp/android/libbase/stringprintf.cpp new file mode 100644 index 0000000000..d9eb0e501e --- /dev/null +++ b/sysbridge/src/main/cpp/android/libbase/stringprintf.cpp @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "stringprintf.h" + +#include + +#include + +namespace android { + namespace base { + + void StringAppendV(std::string *dst, const char *format, va_list ap) { + // First try with a small fixed size buffer + char space[1024] __attribute__((__uninitialized__)); + + // It's possible for methods that use a va_list to invalidate + // the data in it upon use. The fix is to make a copy + // of the structure before using it and use that copy instead. + va_list backup_ap; + va_copy(backup_ap, ap); + int result = vsnprintf(space, sizeof(space), format, backup_ap); + va_end(backup_ap); + + if (result < static_cast(sizeof(space))) { + if (result >= 0) { + // Normal case -- everything fit. + dst->append(space, result); + return; + } + + if (result < 0) { + // Just an error. + return; + } + } + + // Increase the buffer size to the size requested by vsnprintf, + // plus one for the closing \0. + int length = result + 1; + char *buf = new char[length]; + + // Restore the va_list before we use it again + va_copy(backup_ap, ap); + result = vsnprintf(buf, length, format, backup_ap); + va_end(backup_ap); + + if (result >= 0 && result < length) { + // It fit + dst->append(buf, result); + } + delete[] buf; + } + + std::string StringPrintf(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + std::string result; + StringAppendV(&result, fmt, ap); + va_end(ap); + return result; + } + + void StringAppendF(std::string *dst, const char *format, ...) { + va_list ap; + va_start(ap, format); + StringAppendV(dst, format, ap); + va_end(ap); + } + + } // namespace base +} // namespace android diff --git a/sysbridge/src/main/cpp/android/libbase/stringprintf.h b/sysbridge/src/main/cpp/android/libbase/stringprintf.h new file mode 100644 index 0000000000..02cc100d1a --- /dev/null +++ b/sysbridge/src/main/cpp/android/libbase/stringprintf.h @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include + +namespace android { + namespace base { + +// These printf-like functions are implemented in terms of vsnprintf, so they +// use the same attribute for compile-time format string checking. + +// Returns a string corresponding to printf-like formatting of the arguments. + std::string + StringPrintf(const char *fmt, ...) __attribute__((__format__(__printf__, 1, 2))); + +// Appends a printf-like formatting of the arguments to 'dst'. + void StringAppendF(std::string *dst, const char *fmt, ...) + __attribute__((__format__(__printf__, 2, 3))); + +// Appends a printf-like formatting of the arguments to 'dst'. + void StringAppendV(std::string *dst, const char *format, va_list ap) + __attribute__((__format__(__printf__, 2, 0))); + + } // namespace base +} // namespace android diff --git a/sysbridge/src/main/cpp/android/liblog/log_main.h b/sysbridge/src/main/cpp/android/liblog/log_main.h new file mode 100644 index 0000000000..8fc5ff4e79 --- /dev/null +++ b/sysbridge/src/main/cpp/android/liblog/log_main.h @@ -0,0 +1,369 @@ +/* + * Copyright (C) 2005-2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + + +#include +#include + +#include + +__BEGIN_DECLS + +/* + * Normally we strip the effects of ALOGV (VERBOSE messages), + * LOG_FATAL and LOG_FATAL_IF (FATAL assert messages) from the + * release builds by defining NDEBUG. You can modify this (for + * example with "#define LOG_NDEBUG 0" at the top of your source + * file) to change that behavior. + */ + +#ifndef LOG_NDEBUG +#ifdef NDEBUG +#define LOG_NDEBUG 1 +#else +#define LOG_NDEBUG 0 +#endif +#endif + +/* --------------------------------------------------------------------- */ + +/* + * This file uses ", ## __VA_ARGS__" zero-argument token pasting to + * work around issues with debug-only syntax errors in assertions + * that are missing format strings. See commit + * 19299904343daf191267564fe32e6cd5c165cd42 + */ +#if defined(__clang__) +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wgnu-zero-variadic-macro-arguments" +#endif + +/* + * Use __VA_ARGS__ if running a static analyzer, + * to avoid warnings of unused variables in __VA_ARGS__. + * Use constexpr function in C++ mode, so these macros can be used + * in other constexpr functions without warning. + */ +#ifdef __clang_analyzer__ +#ifdef __cplusplus +extern "C++" { +template +constexpr int __fake_use_va_args(Ts...) { + return 0; +} +} +#else +extern int __fake_use_va_args(int, ...); +#endif /* __cplusplus */ +#define __FAKE_USE_VA_ARGS(...) ((void)__fake_use_va_args(0, ##__VA_ARGS__)) +#else +#define __FAKE_USE_VA_ARGS(...) ((void)(0)) +#endif /* __clang_analyzer__ */ + +#ifndef __predict_false +#define __predict_false(exp) __builtin_expect((exp) != 0, 0) +#endif + +#define android_writeLog(prio, tag, text) __android_log_write(prio, tag, text) + +#define android_printLog(prio, tag, ...) \ + __android_log_print(prio, tag, __VA_ARGS__) + +#define android_vprintLog(prio, cond, tag, ...) \ + __android_log_vprint(prio, tag, __VA_ARGS__) + +/* + * Log macro that allows you to specify a number for the priority. + */ +#ifndef LOG_PRI +#define LOG_PRI(priority, tag, ...) android_printLog(priority, tag, __VA_ARGS__) +#endif + +/* + * Log macro that allows you to pass in a varargs ("args" is a va_list). + */ +#ifndef LOG_PRI_VA +#define LOG_PRI_VA(priority, tag, fmt, args) \ + android_vprintLog(priority, NULL, tag, fmt, args) +#endif + +/* --------------------------------------------------------------------- */ + +/* XXX Macros to work around syntax errors in places where format string + * arg is not passed to ALOG_ASSERT, LOG_ALWAYS_FATAL or LOG_ALWAYS_FATAL_IF + * (happens only in debug builds). + */ + +/* Returns 2nd arg. Used to substitute default value if caller's vararg list + * is empty. + */ +#define __android_second(dummy, second, ...) second + +/* If passed multiple args, returns ',' followed by all but 1st arg, otherwise + * returns nothing. + */ +#define __android_rest(first, ...) , ##__VA_ARGS__ + +#define android_printAssert(cond, tag, ...) \ + __android_log_assert(cond, tag, \ + __android_second(0, ##__VA_ARGS__, NULL) \ + __android_rest(__VA_ARGS__)) + +/* + * Log a fatal error. If the given condition fails, this stops program + * execution like a normal assertion, but also generating the given message. + * It is NOT stripped from release builds. Note that the condition test + * is -inverted- from the normal assert() semantics. + */ +#ifndef LOG_ALWAYS_FATAL_IF +#define LOG_ALWAYS_FATAL_IF(cond, ...) \ + ((__predict_false(cond)) ? (__FAKE_USE_VA_ARGS(__VA_ARGS__), \ + ((void)android_printAssert(#cond, LOG_TAG, ##__VA_ARGS__))) \ + : ((void)0)) +#endif + +#ifndef LOG_ALWAYS_FATAL +#define LOG_ALWAYS_FATAL(...) \ + (((void)android_printAssert(NULL, LOG_TAG, ##__VA_ARGS__))) +#endif + +/* + * Versions of LOG_ALWAYS_FATAL_IF and LOG_ALWAYS_FATAL that + * are stripped out of release builds. + */ + +#if LOG_NDEBUG + +#ifndef LOG_FATAL_IF +#define LOG_FATAL_IF(cond, ...) __FAKE_USE_VA_ARGS(__VA_ARGS__) +#endif +#ifndef LOG_FATAL +#define LOG_FATAL(...) __FAKE_USE_VA_ARGS(__VA_ARGS__) +#endif + +#else + +#ifndef LOG_FATAL_IF +#define LOG_FATAL_IF(cond, ...) LOG_ALWAYS_FATAL_IF(cond, ##__VA_ARGS__) +#endif +#ifndef LOG_FATAL +#define LOG_FATAL(...) LOG_ALWAYS_FATAL(__VA_ARGS__) +#endif + +#endif + +/* + * Assertion that generates a log message when the assertion fails. + * Stripped out of release builds. Uses the current LOG_TAG. + */ +#ifndef ALOG_ASSERT +#define ALOG_ASSERT(cond, ...) LOG_FATAL_IF(!(cond), ##__VA_ARGS__) +#endif + +/* --------------------------------------------------------------------- */ + +/* + * C/C++ logging functions. See the logging documentation for API details. + * + * We'd like these to be available from C code (in case we import some from + * somewhere), so this has a C interface. + * + * The output will be correct when the log file is shared between multiple + * threads and/or multiple processes so long as the operating system + * supports O_APPEND. These calls have mutex-protected data structures + * and so are NOT reentrant. Do not use LOG in a signal handler. + */ + +/* --------------------------------------------------------------------- */ + +/* + * Simplified macro to send a verbose log message using the current LOG_TAG. + */ +#ifndef ALOGV +#define __ALOGV(...) ((void)ALOG(LOG_VERBOSE, LOG_TAG, __VA_ARGS__)) +#if LOG_NDEBUG +#define ALOGV(...) \ + do { \ + __FAKE_USE_VA_ARGS(__VA_ARGS__); \ + if (false) { \ + __ALOGV(__VA_ARGS__); \ + } \ + } while (false) +#else +#define ALOGV(...) __ALOGV(__VA_ARGS__) +#endif +#endif + +#ifndef ALOGV_IF +#if LOG_NDEBUG +#define ALOGV_IF(cond, ...) __FAKE_USE_VA_ARGS(__VA_ARGS__) +#else +#define ALOGV_IF(cond, ...) \ + ((__predict_false(cond)) \ + ? (__FAKE_USE_VA_ARGS(__VA_ARGS__), (void)ALOG(LOG_VERBOSE, LOG_TAG, __VA_ARGS__)) \ + : ((void)0)) +#endif +#endif + +/* + * Simplified macro to send a debug log message using the current LOG_TAG. + */ +#ifndef ALOGD +#define ALOGD(...) ((void)ALOG(LOG_DEBUG, LOG_TAG, __VA_ARGS__)) +#endif + +#ifndef ALOGD_IF +#define ALOGD_IF(cond, ...) \ + ((__predict_false(cond)) \ + ? (__FAKE_USE_VA_ARGS(__VA_ARGS__), (void)ALOG(LOG_DEBUG, LOG_TAG, __VA_ARGS__)) \ + : ((void)0)) +#endif + +/* + * Simplified macro to send an info log message using the current LOG_TAG. + */ +#ifndef ALOGI +#define ALOGI(...) ((void)ALOG(LOG_INFO, LOG_TAG, __VA_ARGS__)) +#endif + +#ifndef ALOGI_IF +#define ALOGI_IF(cond, ...) \ + ((__predict_false(cond)) \ + ? (__FAKE_USE_VA_ARGS(__VA_ARGS__), (void)ALOG(LOG_INFO, LOG_TAG, __VA_ARGS__)) \ + : ((void)0)) +#endif + +/* + * Simplified macro to send a warning log message using the current LOG_TAG. + */ +#ifndef ALOGW +#define ALOGW(...) ((void)ALOG(LOG_WARN, LOG_TAG, __VA_ARGS__)) +#endif + +#ifndef ALOGW_IF +#define ALOGW_IF(cond, ...) \ + ((__predict_false(cond)) \ + ? (__FAKE_USE_VA_ARGS(__VA_ARGS__), (void)ALOG(LOG_WARN, LOG_TAG, __VA_ARGS__)) \ + : ((void)0)) +#endif + +/* + * Simplified macro to send an error log message using the current LOG_TAG. + */ +#ifndef ALOGE +#define ALOGE(...) ((void)ALOG(LOG_ERROR, LOG_TAG, __VA_ARGS__)) +#endif + +#ifndef ALOGE_IF +#define ALOGE_IF(cond, ...) \ + ((__predict_false(cond)) \ + ? (__FAKE_USE_VA_ARGS(__VA_ARGS__), (void)ALOG(LOG_ERROR, LOG_TAG, __VA_ARGS__)) \ + : ((void)0)) +#endif + +/* --------------------------------------------------------------------- */ + +/* + * Conditional based on whether the current LOG_TAG is enabled at + * verbose priority. + */ +#ifndef IF_ALOGV +#if LOG_NDEBUG +#define IF_ALOGV() if (false) +#else +#define IF_ALOGV() IF_ALOG(LOG_VERBOSE, LOG_TAG) +#endif +#endif + +/* + * Conditional based on whether the current LOG_TAG is enabled at + * debug priority. + */ +#ifndef IF_ALOGD +#define IF_ALOGD() IF_ALOG(LOG_DEBUG, LOG_TAG) +#endif + +/* + * Conditional based on whether the current LOG_TAG is enabled at + * info priority. + */ +#ifndef IF_ALOGI +#define IF_ALOGI() IF_ALOG(LOG_INFO, LOG_TAG) +#endif + +/* + * Conditional based on whether the current LOG_TAG is enabled at + * warn priority. + */ +#ifndef IF_ALOGW +#define IF_ALOGW() IF_ALOG(LOG_WARN, LOG_TAG) +#endif + +/* + * Conditional based on whether the current LOG_TAG is enabled at + * error priority. + */ +#ifndef IF_ALOGE +#define IF_ALOGE() IF_ALOG(LOG_ERROR, LOG_TAG) +#endif + +/* --------------------------------------------------------------------- */ + +/* + * Basic log message macro. + * + * Example: + * ALOG(LOG_WARN, NULL, "Failed with error %d", errno); + * + * The second argument may be NULL or "" to indicate the "global" tag. + */ +#ifndef ALOG +#define ALOG(priority, tag, ...) LOG_PRI(ANDROID_##priority, tag, __VA_ARGS__) +#endif + +/* + * Conditional given a desired logging priority and tag. + */ +#ifndef IF_ALOG +#define IF_ALOG(priority, tag) if (android_testLog(ANDROID_##priority, tag)) +#endif + +/* --------------------------------------------------------------------- */ + +/* + * IF_ALOG uses android_testLog, but IF_ALOG can be overridden. + * android_testLog will remain constant in its purpose as a wrapper + * for Android logging filter policy, and can be subject to + * change. It can be reused by the developers that override + * IF_ALOG as a convenient means to reimplement their policy + * over Android. + */ + +#if LOG_NDEBUG /* Production */ +#define android_testLog(prio, tag) \ + (__android_log_is_loggable_len(prio, tag, (tag) ? strlen(tag) : 0, ANDROID_LOG_DEBUG) != 0) +#else +#define android_testLog(prio, tag) \ + (__android_log_is_loggable_len(prio, tag, (tag) ? strlen(tag) : 0, ANDROID_LOG_VERBOSE) != 0) +#endif + +#if defined(__clang__) +#pragma clang diagnostic pop +#endif + +__END_DECLS diff --git a/sysbridge/src/main/cpp/android/ui/LogicalDisplayId.h b/sysbridge/src/main/cpp/android/ui/LogicalDisplayId.h new file mode 100644 index 0000000000..6cb99396ad --- /dev/null +++ b/sysbridge/src/main/cpp/android/ui/LogicalDisplayId.h @@ -0,0 +1,59 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include + +#include + +namespace android::ui { + +// Type-safe wrapper for a logical display id. + struct LogicalDisplayId : ftl::Constructible, + ftl::Equatable, + ftl::Orderable { + using Constructible::Constructible; + + constexpr auto val() const { return ftl::to_underlying(*this); } + + constexpr bool isValid() const { return val() >= 0; } + + std::string toString() const { return std::to_string(val()); } + + static const LogicalDisplayId INVALID; + static const LogicalDisplayId DEFAULT; + }; + + constexpr inline LogicalDisplayId LogicalDisplayId::INVALID{-1}; + constexpr inline LogicalDisplayId LogicalDisplayId::DEFAULT{0}; + + inline std::ostream &operator<<(std::ostream &stream, LogicalDisplayId displayId) { + return stream << displayId.val(); + } + +} // namespace android::ui + +namespace std { + template<> + struct hash { + size_t operator()(const android::ui::LogicalDisplayId &displayId) const { + return hash()(displayId.val()); + } + }; +} // namespace std \ No newline at end of file diff --git a/sysbridge/src/main/cpp/android/utils/Errors.h b/sysbridge/src/main/cpp/android/utils/Errors.h new file mode 100644 index 0000000000..22fb36d250 --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/Errors.h @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +#include +#include +#include +#include + +namespace android { + +/** + * The type used to return success/failure from frameworks APIs. + * See the anonymous enum below for valid values. + */ +typedef int32_t status_t; + +/* + * Error codes. + * All error codes are negative values. + */ + +enum { + OK = 0, // Preferred constant for checking success. +#ifndef NO_ERROR + // Win32 #defines NO_ERROR as well. It has the same value, so there's no + // real conflict, though it's a bit awkward. + NO_ERROR = OK, // Deprecated synonym for `OK`. Prefer `OK` because it doesn't conflict with Windows. +#endif + + UNKNOWN_ERROR = (-2147483647-1), // INT32_MIN value + + NO_MEMORY = -ENOMEM, + INVALID_OPERATION = -ENOSYS, + BAD_VALUE = -EINVAL, + BAD_TYPE = (UNKNOWN_ERROR + 1), + NAME_NOT_FOUND = -ENOENT, + PERMISSION_DENIED = -EPERM, + NO_INIT = -ENODEV, + ALREADY_EXISTS = -EEXIST, + DEAD_OBJECT = -EPIPE, + FAILED_TRANSACTION = (UNKNOWN_ERROR + 2), +#if !defined(_WIN32) + BAD_INDEX = -EOVERFLOW, + NOT_ENOUGH_DATA = -ENODATA, + WOULD_BLOCK = -EWOULDBLOCK, + TIMED_OUT = -ETIMEDOUT, + UNKNOWN_TRANSACTION = -EBADMSG, +#else + BAD_INDEX = -E2BIG, + NOT_ENOUGH_DATA = (UNKNOWN_ERROR + 3), + WOULD_BLOCK = (UNKNOWN_ERROR + 4), + TIMED_OUT = (UNKNOWN_ERROR + 5), + UNKNOWN_TRANSACTION = (UNKNOWN_ERROR + 6), +#endif + FDS_NOT_ALLOWED = (UNKNOWN_ERROR + 7), + UNEXPECTED_NULL = (UNKNOWN_ERROR + 8), +}; + +// Human readable name of error +std::string statusToString(status_t status); + +} // namespace android diff --git a/sysbridge/src/main/cpp/android/utils/FileMap.cpp b/sysbridge/src/main/cpp/android/utils/FileMap.cpp new file mode 100644 index 0000000000..21b7c65574 --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/FileMap.cpp @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2006 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// +// Shared file mapping class. +// + +#define LOG_TAG "filemap" + +#include "FileMap.h" +#include "../../logging.h" +#include + +#if defined(__MINGW32__) && !defined(__USE_MINGW_ANSI_STDIO) +# define PRId32 "I32d" +# define PRIx32 "I32x" +# define PRId64 "I64d" +#else + +#include + +#endif + +#include +#include + +#if !defined(__MINGW32__) + +#include + +#endif + +#include +#include +#include +#include +#include + +using namespace android; + +/*static*/ long FileMap::mPageSize = -1; + +// Constructor. Create an empty object. +FileMap::FileMap(void) + : mFileName(nullptr), + mBasePtr(nullptr), + mBaseLength(0), + mDataPtr(nullptr), + mDataLength(0) +#if defined(__MINGW32__) +, + mFileHandle(INVALID_HANDLE_VALUE), + mFileMapping(NULL) +#endif +{ +} + +// Move Constructor. +FileMap::FileMap(FileMap &&other) noexcept + : mFileName(other.mFileName), + mBasePtr(other.mBasePtr), + mBaseLength(other.mBaseLength), + mDataOffset(other.mDataOffset), + mDataPtr(other.mDataPtr), + mDataLength(other.mDataLength) +#if defined(__MINGW32__) +, + mFileHandle(other.mFileHandle), + mFileMapping(other.mFileMapping) +#endif +{ + other.mFileName = nullptr; + other.mBasePtr = nullptr; + other.mDataPtr = nullptr; +#if defined(__MINGW32__) + other.mFileHandle = INVALID_HANDLE_VALUE; + other.mFileMapping = NULL; +#endif +} + +// Move assign operator. +FileMap &FileMap::operator=(FileMap &&other) noexcept { + mFileName = other.mFileName; + mBasePtr = other.mBasePtr; + mBaseLength = other.mBaseLength; + mDataOffset = other.mDataOffset; + mDataPtr = other.mDataPtr; + mDataLength = other.mDataLength; + other.mFileName = nullptr; + other.mBasePtr = nullptr; + other.mDataPtr = nullptr; +#if defined(__MINGW32__) + mFileHandle = other.mFileHandle; + mFileMapping = other.mFileMapping; + other.mFileHandle = INVALID_HANDLE_VALUE; + other.mFileMapping = NULL; +#endif + return *this; +} + +// Destructor. +FileMap::~FileMap(void) { + if (mFileName != nullptr) { + free(mFileName); + } +#if defined(__MINGW32__) + if (mBasePtr && UnmapViewOfFile(mBasePtr) == 0) { + LOGD("UnmapViewOfFile(%p) failed, error = %lu\n", mBasePtr, + GetLastError() ); + } + if (mFileMapping != NULL) { + CloseHandle(mFileMapping); + } +#else + if (mBasePtr && munmap(mBasePtr, mBaseLength) != 0) { + LOGD("munmap(%p, %zu) failed\n", mBasePtr, mBaseLength); + } +#endif +} + + +// Create a new mapping on an open file. +// +// Closing the file descriptor does not unmap the pages, so we don't +// claim ownership of the fd. +// +// Returns "false" on failure. +bool FileMap::create(const char *origFileName, int fd, off64_t offset, size_t length, + bool readOnly) { +#if defined(__MINGW32__) + int adjust; + off64_t adjOffset; + size_t adjLength; + + if (mPageSize == -1) { + SYSTEM_INFO si; + + GetSystemInfo( &si ); + mPageSize = si.dwAllocationGranularity; + } + + DWORD protect = readOnly ? PAGE_READONLY : PAGE_READWRITE; + + mFileHandle = (HANDLE) _get_osfhandle(fd); + mFileMapping = CreateFileMapping( mFileHandle, NULL, protect, 0, 0, NULL); + if (mFileMapping == NULL) { + LOGE("CreateFileMapping(%p, %lx) failed with error %lu\n", + mFileHandle, protect, GetLastError() ); + return false; + } + + adjust = offset % mPageSize; + adjOffset = offset - adjust; + adjLength = length + adjust; + + mBasePtr = MapViewOfFile( mFileMapping, + readOnly ? FILE_MAP_READ : FILE_MAP_ALL_ACCESS, + 0, + (DWORD)(adjOffset), + adjLength ); + if (mBasePtr == NULL) { + LOGE("MapViewOfFile(%" PRId64 ", %zu) failed with error %lu\n", + adjOffset, adjLength, GetLastError() ); + CloseHandle(mFileMapping); + mFileMapping = NULL; + return false; + } +#else // !defined(__MINGW32__) + assert(fd >= 0); + assert(offset >= 0); + assert(length > 0); + + // init on first use + if (mPageSize == -1) { + mPageSize = sysconf(_SC_PAGESIZE); + if (mPageSize == -1) { + LOGE("could not get _SC_PAGESIZE\n"); + return false; + } + } + + int adjust = offset % mPageSize; + off64_t adjOffset = offset - adjust; + size_t adjLength; + if (__builtin_add_overflow(length, adjust, &adjLength)) { + LOGE("adjusted length overflow: length %zu adjust %d", length, adjust); + return false; + } + + int flags = MAP_SHARED; + int prot = PROT_READ; + if (!readOnly) prot |= PROT_WRITE; + + void *ptr = mmap64(nullptr, adjLength, prot, flags, fd, adjOffset); + if (ptr == MAP_FAILED) { + if (errno == EINVAL && length == 0) { + ptr = nullptr; + adjust = 0; + } else { + LOGE("mmap(%lld,%zu) failed: %s\n", (long long) adjOffset, adjLength, strerror(errno)); + return false; + } + } + mBasePtr = ptr; +#endif // !defined(__MINGW32__) + + mFileName = origFileName != nullptr ? strdup(origFileName) : nullptr; + mBaseLength = adjLength; + mDataOffset = offset; + mDataPtr = (char *) mBasePtr + adjust; + mDataLength = length; + + LOGV("MAP: base %p/%zu data %p/%zu\n", + mBasePtr, mBaseLength, mDataPtr, mDataLength); + + return true; +} + +// Provide guidance to the system. +#if !defined(_WIN32) + +int FileMap::advise(MapAdvice advice) { + int cc, sysAdvice; + + switch (advice) { + case NORMAL: + sysAdvice = MADV_NORMAL; + break; + case RANDOM: + sysAdvice = MADV_RANDOM; + break; + case SEQUENTIAL: + sysAdvice = MADV_SEQUENTIAL; + break; + case WILLNEED: + sysAdvice = MADV_WILLNEED; + break; + case DONTNEED: + sysAdvice = MADV_DONTNEED; + break; + default: + assert(false); + return -1; + } + + cc = madvise(mBasePtr, mBaseLength, sysAdvice); + if (cc != 0) + LOGW("madvise(%d) failed: %s\n", sysAdvice, strerror(errno)); + return cc; +} + +#else +int FileMap::advise(MapAdvice /* advice */) +{ + return -1; +} +#endif diff --git a/sysbridge/src/main/cpp/android/utils/FileMap.h b/sysbridge/src/main/cpp/android/utils/FileMap.h new file mode 100644 index 0000000000..4b37a5f7f4 --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/FileMap.h @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2006 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// +// Encapsulate a shared file mapping. +// +#ifndef __LIBS_FILE_MAP_H +#define __LIBS_FILE_MAP_H + +#include + +#if defined(__MINGW32__) +// Ensure that we always pull in winsock2.h before windows.h +#if defined(_WIN32) +#include +#endif +#include +#endif + +namespace android { + +/* + * This represents a memory-mapped file. It might be the entire file or + * only part of it. This requires a little bookkeeping because the mapping + * needs to be aligned on page boundaries, and in some cases we'd like to + * have multiple references to the mapped area without creating additional + * maps. + * + * This always uses MAP_SHARED. + * + * TODO: we should be able to create a new FileMap that is a subset of + * an existing FileMap and shares the underlying mapped pages. Requires + * completing the refcounting stuff and possibly introducing the notion + * of a FileMap hierarchy. + */ + class FileMap { + public: + FileMap(void); + + FileMap(FileMap &&f) noexcept; + + FileMap &operator=(FileMap &&f) noexcept; + + /* + * Create a new mapping on an open file. + * + * Closing the file descriptor does not unmap the pages, so we don't + * claim ownership of the fd. + * + * Returns "false" on failure. + */ + bool create(const char *origFileName, int fd, + off64_t offset, size_t length, bool readOnly); + + ~FileMap(void); + + /* + * Return the name of the file this map came from, if known. + */ + const char *getFileName(void) const { return mFileName; } + + /* + * Get a pointer to the piece of the file we requested. + */ + void *getDataPtr(void) const { return mDataPtr; } + + /* + * Get the length we requested. + */ + size_t getDataLength(void) const { return mDataLength; } + + /* + * Get the data offset used to create this map. + */ + off64_t getDataOffset(void) const { return mDataOffset; } + + /* + * This maps directly to madvise() values, but allows us to avoid + * including everywhere. + */ + enum MapAdvice { + NORMAL, RANDOM, SEQUENTIAL, WILLNEED, DONTNEED + }; + + /* + * Apply an madvise() call to the entire file. + * + * Returns 0 on success, -1 on failure. + */ + int advise(MapAdvice advice); + + protected: + + private: + // these are not implemented + FileMap(const FileMap &src); + + const FileMap &operator=(const FileMap &src); + + char *mFileName; // original file name, if known + void *mBasePtr; // base of mmap area; page aligned + size_t mBaseLength; // length, measured from "mBasePtr" + off64_t mDataOffset; // offset used when map was created + void *mDataPtr; // start of requested data, offset from base + size_t mDataLength; // length, measured from "mDataPtr" +#if defined(__MINGW32__) + HANDLE mFileHandle; // Win32 file handle + HANDLE mFileMapping; // Win32 file mapping handle +#endif + + static long mPageSize; + }; + +} // namespace android + +#endif // __LIBS_FILE_MAP_H diff --git a/sysbridge/src/main/cpp/android/utils/SharedBuffer.cpp b/sysbridge/src/main/cpp/android/utils/SharedBuffer.cpp new file mode 100644 index 0000000000..8ee62e3bb3 --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/SharedBuffer.cpp @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2005 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#define LOG_TAG "sharedbuffer" + +#include "SharedBuffer.h" + +#include +#include + +// --------------------------------------------------------------------------- + +namespace android { + + SharedBuffer *SharedBuffer::alloc(size_t size) { + SharedBuffer *sb = static_cast(malloc(sizeof(SharedBuffer) + size)); + if (sb) { + // Should be std::atomic_init(&sb->mRefs, 1); + // But that generates a warning with some compilers. + // The following is OK on Android-supported platforms. + sb->mRefs.store(1, std::memory_order_relaxed); + sb->mSize = size; + sb->mClientMetadata = 0; + } + return sb; + } + + + void SharedBuffer::dealloc(const SharedBuffer *released) { + free(const_cast(released)); + } + + SharedBuffer *SharedBuffer::edit() const { + if (onlyOwner()) { + return const_cast(this); + } + SharedBuffer *sb = alloc(mSize); + if (sb) { + memcpy(sb->data(), data(), size()); + release(); + } + return sb; + } + + SharedBuffer *SharedBuffer::editResize(size_t newSize) const { + if (onlyOwner()) { + SharedBuffer *buf = const_cast(this); + if (buf->mSize == newSize) return buf; + buf = (SharedBuffer *) realloc(reinterpret_cast(buf), + sizeof(SharedBuffer) + newSize); + if (buf != nullptr) { + buf->mSize = newSize; + return buf; + } + } + SharedBuffer *sb = alloc(newSize); + if (sb) { + const size_t mySize = mSize; + memcpy(sb->data(), data(), newSize < mySize ? newSize : mySize); + release(); + } + return sb; + } + + SharedBuffer *SharedBuffer::attemptEdit() const { + if (onlyOwner()) { + return const_cast(this); + } + return nullptr; + } + + SharedBuffer *SharedBuffer::reset(size_t new_size) const { + // cheap-o-reset. + SharedBuffer *sb = alloc(new_size); + if (sb) { + release(); + } + return sb; + } + + void SharedBuffer::acquire() const { + mRefs.fetch_add(1, std::memory_order_relaxed); + } + + int32_t SharedBuffer::release(uint32_t flags) const { + const bool useDealloc = ((flags & eKeepStorage) == 0); + if (onlyOwner()) { + // Since we're the only owner, our reference count goes to zero. + mRefs.store(0, std::memory_order_relaxed); + if (useDealloc) { + dealloc(this); + } + // As the only owner, our previous reference count was 1. + return 1; + } + // There's multiple owners, we need to use an atomic decrement. + int32_t prevRefCount = mRefs.fetch_sub(1, std::memory_order_release); + if (prevRefCount == 1) { + // We're the last reference, we need the acquire fence. + atomic_thread_fence(std::memory_order_acquire); + if (useDealloc) { + dealloc(this); + } + } + return prevRefCount; + } + + +}; // namespace android diff --git a/sysbridge/src/main/cpp/android/utils/SharedBuffer.h b/sysbridge/src/main/cpp/android/utils/SharedBuffer.h new file mode 100644 index 0000000000..c170df00e1 --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/SharedBuffer.h @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2005 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * DEPRECATED. DO NOT USE FOR NEW CODE. + */ + +#ifndef ANDROID_SHARED_BUFFER_H +#define ANDROID_SHARED_BUFFER_H + +#include +#include +#include + +// --------------------------------------------------------------------------- + +namespace android { + + class SharedBuffer { + public: + + /* flags to use with release() */ + enum { + eKeepStorage = 0x00000001 + }; + + /*! allocate a buffer of size 'size' and acquire() it. + * call release() to free it. + */ + static SharedBuffer *alloc(size_t size); + + /*! free the memory associated with the SharedBuffer. + * Fails if there are any users associated with this SharedBuffer. + * In other words, the buffer must have been release by all its + * users. + */ + static void dealloc(const SharedBuffer *released); + + //! access the data for read + inline const void *data() const; + + //! access the data for read/write + inline void *data(); + + //! get size of the buffer + inline size_t size() const; + + //! get back a SharedBuffer object from its data + static inline SharedBuffer *bufferFromData(void *data); + + //! get back a SharedBuffer object from its data + static inline const SharedBuffer *bufferFromData(const void *data); + + //! get the size of a SharedBuffer object from its data + static inline size_t sizeFromData(const void *data); + + //! edit the buffer (get a writtable, or non-const, version of it) + SharedBuffer *edit() const; + + //! edit the buffer, resizing if needed + SharedBuffer *editResize(size_t size) const; + + //! like edit() but fails if a copy is required + SharedBuffer *attemptEdit() const; + + //! resize and edit the buffer, loose it's content. + SharedBuffer *reset(size_t size) const; + + //! acquire/release a reference on this buffer + void acquire() const; + + /*! release a reference on this buffer, with the option of not + * freeing the memory associated with it if it was the last reference + * returns the previous reference count + */ + int32_t release(uint32_t flags = 0) const; + + //! returns wether or not we're the only owner + inline bool onlyOwner() const; + + + private: + inline SharedBuffer() {} + + inline ~SharedBuffer() {} + + SharedBuffer(const SharedBuffer &); + + SharedBuffer &operator=(const SharedBuffer &); + + // Must be sized to preserve correct alignment. + mutable std::atomic mRefs; + size_t mSize; + uint32_t mReserved; + public: + // mClientMetadata is reserved for client use. It is initialized to 0 + // and the clients can do whatever they want with it. Note that this is + // placed last so that it is adjcent to the buffer allocated. + uint32_t mClientMetadata; + }; + + static_assert(sizeof(SharedBuffer) % 8 == 0 + && (sizeof(size_t) > 4 || sizeof(SharedBuffer) == 16), + "SharedBuffer has unexpected size"); + +// --------------------------------------------------------------------------- + + const void *SharedBuffer::data() const { + return this + 1; + } + + void *SharedBuffer::data() { + return this + 1; + } + + size_t SharedBuffer::size() const { + return mSize; + } + + SharedBuffer *SharedBuffer::bufferFromData(void *data) { + return data ? static_cast(data) - 1 : nullptr; + } + + const SharedBuffer *SharedBuffer::bufferFromData(const void *data) { + return data ? static_cast(data) - 1 : nullptr; + } + + size_t SharedBuffer::sizeFromData(const void *data) { + return data ? bufferFromData(data)->mSize : 0; + } + + bool SharedBuffer::onlyOwner() const { + return (mRefs.load(std::memory_order_acquire) == 1); + } + +} // namespace android + +// --------------------------------------------------------------------------- + +#endif // ANDROID_VECTOR_H diff --git a/sysbridge/src/main/cpp/android/utils/String16.cpp b/sysbridge/src/main/cpp/android/utils/String16.cpp new file mode 100644 index 0000000000..0d61425d7c --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/String16.cpp @@ -0,0 +1,393 @@ +/* + * Copyright (C) 2005 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "String16.h" + +#include +#include + +#include "SharedBuffer.h" + +#define LIBUTILS_PRAGMA(arg) _Pragma(#arg) +#if defined(__clang__) +#define LIBUTILS_PRAGMA_FOR_COMPILER(arg) LIBUTILS_PRAGMA(clang arg) +#elif defined(__GNUC__) +#define LIBUTILS_PRAGMA_FOR_COMPILER(arg) LIBUTILS_PRAGMA(GCC arg) +#else +#define LIBUTILS_PRAGMA_FOR_COMPILER(arg) +#endif +#define LIBUTILS_IGNORE(warning_flag) \ + LIBUTILS_PRAGMA_FOR_COMPILER(diagnostic push) \ + LIBUTILS_PRAGMA_FOR_COMPILER(diagnostic ignored warning_flag) +#define LIBUTILS_IGNORE_END() LIBUTILS_PRAGMA_FOR_COMPILER(diagnostic pop) + +namespace android { + +static const StaticString16 emptyString(u""); +static inline char16_t* getEmptyString() { + return const_cast(emptyString.c_str()); +} + +// --------------------------------------------------------------------------- + +void* String16::alloc(size_t size) +{ + SharedBuffer* buf = SharedBuffer::alloc(size); + buf->mClientMetadata = kIsSharedBufferAllocated; + return buf; +} + +char16_t* String16::allocFromUTF8(const char* u8str, size_t u8len) +{ + if (u8len == 0) return getEmptyString(); + + const uint8_t* u8cur = (const uint8_t*) u8str; + + const ssize_t u16len = utf8_to_utf16_length(u8cur, u8len); + if (u16len < 0) { + return getEmptyString(); + } + + SharedBuffer* buf = static_cast(alloc(sizeof(char16_t) * (u16len + 1))); + if (buf) { + u8cur = (const uint8_t*) u8str; + char16_t* u16str = (char16_t*)buf->data(); + + utf8_to_utf16(u8cur, u8len, u16str, ((size_t) u16len) + 1); + + //printf("Created UTF-16 string from UTF-8 \"%s\":", in); + //printHexData(1, str, buf->size(), 16, 1); + //printf("\n"); + + return u16str; + } + + return getEmptyString(); +} + +char16_t* String16::allocFromUTF16(const char16_t* u16str, size_t u16len) { + if (u16len >= SIZE_MAX / sizeof(char16_t)) { + abort(); + } + + SharedBuffer* buf = static_cast(alloc((u16len + 1) * sizeof(char16_t))); + if (buf) { + char16_t* str = (char16_t*)buf->data(); + memcpy(str, u16str, u16len * sizeof(char16_t)); + str[u16len] = 0; + return str; + } + return getEmptyString(); +} + +// --------------------------------------------------------------------------- + +String16::String16() + : mString(getEmptyString()) +{ +} + +String16::String16(const String16& o) + : mString(o.mString) +{ + acquire(); +} + +String16::String16(String16&& o) noexcept + : mString(o.mString) +{ + o.mString = getEmptyString(); +} + +String16::String16(const String16& o, size_t len, size_t begin) + : mString(getEmptyString()) +{ + setTo(o, len, begin); +} + +String16::String16(const char16_t* o) : mString(allocFromUTF16(o, strlen16(o))) {} + +String16::String16(const char16_t* o, size_t len) : mString(allocFromUTF16(o, len)) {} + +String16::String16(const String8& o) : mString(allocFromUTF8(o.c_str(), o.size())) {} + +String16::String16(const char* o) + : mString(allocFromUTF8(o, strlen(o))) +{ +} + +String16::String16(const char* o, size_t len) + : mString(allocFromUTF8(o, len)) +{ +} + +String16::~String16() +{ + release(); +} + +String16& String16::operator=(String16&& other) noexcept { + release(); + mString = other.mString; + other.mString = getEmptyString(); + return *this; +} + +size_t String16::size() const +{ + if (isStaticString()) { + return staticStringSize(); + } else { + return SharedBuffer::sizeFromData(mString) / sizeof(char16_t) - 1; + } +} + +void String16::setTo(const String16& other) +{ + release(); + mString = other.mString; + acquire(); +} + +status_t String16::setTo(const String16& other, size_t len, size_t begin) +{ + const size_t N = other.size(); + if (begin >= N) { + release(); + mString = getEmptyString(); + return OK; + } + if ((begin+len) > N) len = N-begin; + if (begin == 0 && len == N) { + setTo(other); + return OK; + } + + return setTo(other.c_str() + begin, len); +} + +status_t String16::setTo(const char16_t* other) +{ + return setTo(other, strlen16(other)); +} + +status_t String16::setTo(const char16_t* other, size_t len) +{ + if (len >= SIZE_MAX / sizeof(char16_t)) { + abort(); + } + + SharedBuffer* buf = static_cast(editResize((len + 1) * sizeof(char16_t))); + if (buf) { + char16_t* str = (char16_t*)buf->data(); + memmove(str, other, len*sizeof(char16_t)); + str[len] = 0; + mString = str; + return OK; + } + return NO_MEMORY; +} + +status_t String16::append(const String16& other) { + return append(other.c_str(), other.size()); +} + +status_t String16::append(const char16_t* chrs, size_t otherLen) { + const size_t myLen = size(); + + if (myLen == 0) return setTo(chrs, otherLen); + + if (otherLen == 0) return OK; + + size_t size = myLen; + if (__builtin_add_overflow(size, otherLen, &size) || + __builtin_add_overflow(size, 1, &size) || + __builtin_mul_overflow(size, sizeof(char16_t), &size)) return NO_MEMORY; + + SharedBuffer* buf = static_cast(editResize(size)); + if (!buf) return NO_MEMORY; + + char16_t* str = static_cast(buf->data()); + memcpy(str + myLen, chrs, otherLen * sizeof(char16_t)); + str[myLen + otherLen] = 0; + mString = str; + return OK; +} + +status_t String16::insert(size_t pos, const char16_t* chrs) { + return insert(pos, chrs, strlen16(chrs)); +} + +status_t String16::insert(size_t pos, const char16_t* chrs, size_t otherLen) { + const size_t myLen = size(); + + if (myLen == 0) return setTo(chrs, otherLen); + + if (otherLen == 0) return OK; + + if (pos > myLen) pos = myLen; + + size_t size = myLen; + if (__builtin_add_overflow(size, otherLen, &size) || + __builtin_add_overflow(size, 1, &size) || + __builtin_mul_overflow(size, sizeof(char16_t), &size)) return NO_MEMORY; + + SharedBuffer* buf = static_cast(editResize(size)); + if (!buf) return NO_MEMORY; + + char16_t* str = static_cast(buf->data()); + if (pos < myLen) memmove(str + pos + otherLen, str + pos, (myLen - pos) * sizeof(char16_t)); + memcpy(str + pos, chrs, otherLen * sizeof(char16_t)); + str[myLen + otherLen] = 0; + mString = str; + return OK; +} + +ssize_t String16::findFirst(char16_t c) const +{ + const char16_t* str = string(); + const char16_t* p = str; + const char16_t* e = p + size(); + while (p < e) { + if (*p == c) { + return p-str; + } + p++; + } + return -1; +} + +ssize_t String16::findLast(char16_t c) const +{ + const char16_t* str = string(); + const char16_t* p = str; + const char16_t* e = p + size(); + while (p < e) { + e--; + if (*e == c) { + return e-str; + } + } + return -1; +} + +bool String16::startsWith(const String16& prefix) const +{ + const size_t ps = prefix.size(); + if (ps > size()) return false; + return strzcmp16(mString, ps, prefix.c_str(), ps) == 0; +} + +bool String16::startsWith(const char16_t* prefix) const +{ + const size_t ps = strlen16(prefix); + if (ps > size()) return false; + return strncmp16(mString, prefix, ps) == 0; +} + +bool String16::contains(const char16_t* chrs) const +{ + return strstr16(mString, chrs) != nullptr; +} + +void* String16::edit() { + SharedBuffer* buf; + if (isStaticString()) { + buf = static_cast(alloc((size() + 1) * sizeof(char16_t))); + if (buf) { + memcpy(buf->data(), mString, (size() + 1) * sizeof(char16_t)); + } + } else { + buf = SharedBuffer::bufferFromData(mString)->edit(); + buf->mClientMetadata = kIsSharedBufferAllocated; + } + return buf; +} + +void* String16::editResize(size_t newSize) { + SharedBuffer* buf; + if (isStaticString()) { + size_t copySize = (size() + 1) * sizeof(char16_t); + if (newSize < copySize) { + copySize = newSize; + } + buf = static_cast(alloc(newSize)); + if (buf) { + memcpy(buf->data(), mString, copySize); + } + } else { + buf = SharedBuffer::bufferFromData(mString)->editResize(newSize); + buf->mClientMetadata = kIsSharedBufferAllocated; + } + return buf; +} + +void String16::acquire() +{ + if (!isStaticString()) { + SharedBuffer::bufferFromData(mString)->acquire(); + } +} + +void String16::release() +{ + if (!isStaticString()) { + SharedBuffer::bufferFromData(mString)->release(); + } +} + +bool String16::isStaticString() const { + // See String16.h for notes on the memory layout of String16::StaticData and + // SharedBuffer. + LIBUTILS_IGNORE("-Winvalid-offsetof") + static_assert(sizeof(SharedBuffer) - offsetof(SharedBuffer, mClientMetadata) == 4); + LIBUTILS_IGNORE_END() + const uint32_t* p = reinterpret_cast(mString); + return (*(p - 1) & kIsSharedBufferAllocated) == 0; +} + +size_t String16::staticStringSize() const { + // See String16.h for notes on the memory layout of String16::StaticData and + // SharedBuffer. + LIBUTILS_IGNORE("-Winvalid-offsetof") + static_assert(sizeof(SharedBuffer) - offsetof(SharedBuffer, mClientMetadata) == 4); + LIBUTILS_IGNORE_END() + const uint32_t* p = reinterpret_cast(mString); + return static_cast(*(p - 1)); +} + +status_t String16::replaceAll(char16_t replaceThis, char16_t withThis) +{ + const size_t N = size(); + const char16_t* str = string(); + char16_t* edited = nullptr; + for (size_t i=0; i(edit()); + if (!buf) { + return NO_MEMORY; + } + edited = (char16_t*)buf->data(); + mString = str = edited; + } + edited[i] = withThis; + } + } + return OK; +} + +}; // namespace android diff --git a/sysbridge/src/main/cpp/android/utils/String16.h b/sysbridge/src/main/cpp/android/utils/String16.h new file mode 100644 index 0000000000..54c6d21ae6 --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/String16.h @@ -0,0 +1,411 @@ +/* + * Copyright (C) 2005 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef ANDROID_STRING16_H +#define ANDROID_STRING16_H + +#include +#include +#include + +#include "Errors.h" +#include "String8.h" +#include "TypeHelpers.h" + +#if __cplusplus >= 202002L +#include +#endif + +// --------------------------------------------------------------------------- + +namespace android { + +// --------------------------------------------------------------------------- + +template +class StaticString16; + +// DO NOT USE: please use std::u16string + +//! This is a string holding UTF-16 characters. +class String16 +{ +public: + String16(); + String16(const String16& o); + String16(String16&& o) noexcept; + String16(const String16& o, + size_t len, + size_t begin=0); + explicit String16(const char16_t* o); + explicit String16(const char16_t* o, size_t len); + explicit String16(const String8& o); + explicit String16(const char* o); + explicit String16(const char* o, size_t len); + + ~String16(); + + inline const char16_t* c_str() const; + + size_t size() const; + inline bool empty() const; + + inline size_t length() const; + + void setTo(const String16& other); + status_t setTo(const char16_t* other); + status_t setTo(const char16_t* other, size_t len); + status_t setTo(const String16& other, + size_t len, + size_t begin=0); + + status_t append(const String16& other); + status_t append(const char16_t* other, size_t len); + + inline String16& operator=(const String16& other); + String16& operator=(String16&& other) noexcept; + + inline String16& operator+=(const String16& other); + inline String16 operator+(const String16& other) const; + + status_t insert(size_t pos, const char16_t* chrs); + status_t insert(size_t pos, + const char16_t* chrs, size_t len); + + ssize_t findFirst(char16_t c) const; + ssize_t findLast(char16_t c) const; + + bool startsWith(const String16& prefix) const; + bool startsWith(const char16_t* prefix) const; + + bool contains(const char16_t* chrs) const; + inline bool contains(const String16& other) const; + + status_t replaceAll(char16_t replaceThis, + char16_t withThis); + + inline int compare(const String16& other) const; + + inline bool operator<(const String16& other) const; + inline bool operator<=(const String16& other) const; + inline bool operator==(const String16& other) const; + inline bool operator!=(const String16& other) const; + inline bool operator>=(const String16& other) const; + inline bool operator>(const String16& other) const; +#if __cplusplus >= 202002L + inline std::strong_ordering operator<=>(const String16& other) const; +#endif + + inline bool operator<(const char16_t* other) const; + inline bool operator<=(const char16_t* other) const; + inline bool operator==(const char16_t* other) const; + inline bool operator!=(const char16_t* other) const; + inline bool operator>=(const char16_t* other) const; + inline bool operator>(const char16_t* other) const; +#if __cplusplus >= 202002L + inline std::strong_ordering operator<=>(const char16_t* other) const; +#endif + + inline operator const char16_t*() const; + + // Implicit cast to std::u16string is not implemented on purpose - u16string_view is much + // lighter and if one needs, they can still create u16string from u16string_view. + inline operator std::u16string_view() const; + + // Static and non-static String16 behave the same for the users, so + // this method isn't of much use for the users. It is public for testing. + bool isStaticString() const; + + private: + /* + * A flag indicating the type of underlying buffer. + */ + static constexpr uint32_t kIsSharedBufferAllocated = 0x80000000; + + /* + * alloc() returns void* so that SharedBuffer class is not exposed. + */ + static void* alloc(size_t size); + static char16_t* allocFromUTF8(const char* u8str, size_t u8len); + static char16_t* allocFromUTF16(const char16_t* u16str, size_t u16len); + + /* + * edit() and editResize() return void* so that SharedBuffer class + * is not exposed. + */ + void* edit(); + void* editResize(size_t new_size); + + void acquire(); + void release(); + + size_t staticStringSize() const; + + const char16_t* mString; + +protected: + /* + * Data structure used to allocate static storage for static String16. + * + * Note that this data structure and SharedBuffer are used interchangably + * as the underlying data structure for a String16. Therefore, the layout + * of this data structure must match the part in SharedBuffer that is + * visible to String16. + */ + template + struct StaticData { + // The high bit of 'size' is used as a flag. + static_assert(N - 1 < kIsSharedBufferAllocated, "StaticString16 too long!"); + constexpr StaticData() : size(N - 1), data{0} {} + const uint32_t size; + char16_t data[N]; + + constexpr StaticData(const StaticData&) = default; + }; + + /* + * Helper function for constructing a StaticData object. + */ + template + static constexpr const StaticData makeStaticData(const char16_t (&s)[N]) { + StaticData r; + // The 'size' field is at the same location where mClientMetadata would + // be for a SharedBuffer. We do NOT set kIsSharedBufferAllocated flag + // here. + for (size_t i = 0; i < N - 1; ++i) r.data[i] = s[i]; + return r; + } + + template + explicit constexpr String16(const StaticData& s) : mString(s.data) {} + +// These symbols are for potential backward compatibility with prebuilts. To be removed. +#ifdef ENABLE_STRING16_OBSOLETE_METHODS +public: +#else +private: +#endif + inline const char16_t* string() const; +}; + +// String16 can be trivially moved using memcpy() because moving does not +// require any change to the underlying SharedBuffer contents or reference count. +ANDROID_TRIVIAL_MOVE_TRAIT(String16) + +static inline std::ostream& operator<<(std::ostream& os, const String16& str) { + os << String8(str); + return os; +} + +// --------------------------------------------------------------------------- + +/* + * A StaticString16 object is a specialized String16 object. Instead of holding + * the string data in a ref counted SharedBuffer object, it holds data in a + * buffer within StaticString16 itself. Note that this buffer is NOT ref + * counted and is assumed to be available for as long as there is at least a + * String16 object using it. Therefore, one must be extra careful to NEVER + * assign a StaticString16 to a String16 that outlives the StaticString16 + * object. + * + * THE SAFEST APPROACH IS TO USE StaticString16 ONLY AS GLOBAL VARIABLES. + * + * A StaticString16 SHOULD NEVER APPEAR IN APIs. USE String16 INSTEAD. + */ +template +class StaticString16 : public String16 { +public: + constexpr StaticString16(const char16_t (&s)[N]) : String16(mData), mData(makeStaticData(s)) {} + + constexpr StaticString16(const StaticString16& other) + : String16(mData), mData(other.mData) {} + + constexpr StaticString16(const StaticString16&&) = delete; + + // There is no reason why one would want to 'new' a StaticString16. Delete + // it to discourage misuse. + static void* operator new(std::size_t) = delete; + +private: + const StaticData mData; +}; + +template +StaticString16(const F&)->StaticString16; + +// --------------------------------------------------------------------------- +// No user servicable parts below. + +inline int compare_type(const String16& lhs, const String16& rhs) +{ + return lhs.compare(rhs); +} + +inline int strictly_order_type(const String16& lhs, const String16& rhs) +{ + return compare_type(lhs, rhs) < 0; +} + +inline const char16_t* String16::c_str() const +{ + return mString; +} + +inline const char16_t* String16::string() const +{ + return mString; +} + +inline bool String16::empty() const +{ + return length() == 0; +} + +inline size_t String16::length() const +{ + return size(); +} + +inline bool String16::contains(const String16& other) const +{ + return contains(other.c_str()); +} + +inline String16& String16::operator=(const String16& other) +{ + setTo(other); + return *this; +} + +inline String16& String16::operator+=(const String16& other) +{ + append(other); + return *this; +} + +inline String16 String16::operator+(const String16& other) const +{ + String16 tmp(*this); + tmp += other; + return tmp; +} + +inline int String16::compare(const String16& other) const +{ + return strzcmp16(mString, size(), other.mString, other.size()); +} + +inline bool String16::operator<(const String16& other) const +{ + return strzcmp16(mString, size(), other.mString, other.size()) < 0; +} + +inline bool String16::operator<=(const String16& other) const +{ + return strzcmp16(mString, size(), other.mString, other.size()) <= 0; +} + +inline bool String16::operator==(const String16& other) const +{ + return strzcmp16(mString, size(), other.mString, other.size()) == 0; +} + +inline bool String16::operator!=(const String16& other) const +{ + return strzcmp16(mString, size(), other.mString, other.size()) != 0; +} + +inline bool String16::operator>=(const String16& other) const +{ + return strzcmp16(mString, size(), other.mString, other.size()) >= 0; +} + +inline bool String16::operator>(const String16& other) const +{ + return strzcmp16(mString, size(), other.mString, other.size()) > 0; +} + +#if __cplusplus >= 202002L +inline std::strong_ordering String16::operator<=>(const String16& other) const { + int result = strzcmp16(mString, size(), other.mString, other.size()); + if (result == 0) { + return std::strong_ordering::equal; + } else if (result < 0) { + return std::strong_ordering::less; + } else { + return std::strong_ordering::greater; + } +} +#endif + +inline bool String16::operator<(const char16_t* other) const +{ + return strcmp16(mString, other) < 0; +} + +inline bool String16::operator<=(const char16_t* other) const +{ + return strcmp16(mString, other) <= 0; +} + +inline bool String16::operator==(const char16_t* other) const +{ + return strcmp16(mString, other) == 0; +} + +inline bool String16::operator!=(const char16_t* other) const +{ + return strcmp16(mString, other) != 0; +} + +inline bool String16::operator>=(const char16_t* other) const +{ + return strcmp16(mString, other) >= 0; +} + +inline bool String16::operator>(const char16_t* other) const +{ + return strcmp16(mString, other) > 0; +} + +#if __cplusplus >= 202002L +inline std::strong_ordering String16::operator<=>(const char16_t* other) const { + int result = strcmp16(mString, other); + if (result == 0) { + return std::strong_ordering::equal; + } else if (result < 0) { + return std::strong_ordering::less; + } else { + return std::strong_ordering::greater; + } +} +#endif + +inline String16::operator const char16_t*() const +{ + return mString; +} + +inline String16::operator std::u16string_view() const +{ + return {mString, length()}; +} + +} // namespace android + +// --------------------------------------------------------------------------- + +#endif // ANDROID_STRING16_H diff --git a/sysbridge/src/main/cpp/android/utils/String8.cpp b/sysbridge/src/main/cpp/android/utils/String8.cpp new file mode 100644 index 0000000000..3f8d9d634a --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/String8.cpp @@ -0,0 +1,453 @@ +/* + * Copyright (C) 2005 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "String8.h" + +#include +#include "String16.h" + +#include +#include + +#include +#include +#include + +#include "SharedBuffer.h" + +/* + * Functions outside android is below the namespace android, since they use + * functions and constants in android namespace. + */ + +// --------------------------------------------------------------------------- + +namespace android { + + static inline char *getEmptyString() { + static SharedBuffer *gEmptyStringBuf = [] { + SharedBuffer *buf = SharedBuffer::alloc(1); + char *str = static_cast(buf->data()); + *str = 0; + return buf; + }(); + + gEmptyStringBuf->acquire(); + return static_cast(gEmptyStringBuf->data()); + } + +// --------------------------------------------------------------------------- + + static char *allocFromUTF8(const char *in, size_t len) { + if (len > 0) { + if (len == SIZE_MAX) { + return nullptr; + } + SharedBuffer *buf = SharedBuffer::alloc(len + 1); +// ALOG_ASSERT(buf, "Unable to allocate shared buffer"); + if (buf) { + char *str = (char *) buf->data(); + memcpy(str, in, len); + str[len] = 0; + return str; + } + return nullptr; + } + + return getEmptyString(); + } + + static char *allocFromUTF16(const char16_t *in, size_t len) { + if (len == 0) return getEmptyString(); + + // Allow for closing '\0' + const ssize_t resultStrLen = utf16_to_utf8_length(in, len) + 1; + if (resultStrLen < 1) { + return getEmptyString(); + } + + SharedBuffer *buf = SharedBuffer::alloc(resultStrLen); +// ALOG_ASSERT(buf, "Unable to allocate shared buffer"); + if (!buf) { + return getEmptyString(); + } + + char *resultStr = (char *) buf->data(); + utf16_to_utf8(in, len, resultStr, resultStrLen); + return resultStr; + } + + static char *allocFromUTF32(const char32_t *in, size_t len) { + if (len == 0) { + return getEmptyString(); + } + + const ssize_t resultStrLen = utf32_to_utf8_length(in, len) + 1; + if (resultStrLen < 1) { + return getEmptyString(); + } + + SharedBuffer *buf = SharedBuffer::alloc(resultStrLen); +// ALOG_ASSERT(buf, "Unable to allocate shared buffer"); + if (!buf) { + return getEmptyString(); + } + + char *resultStr = (char *) buf->data(); + utf32_to_utf8(in, len, resultStr, resultStrLen); + + return resultStr; + } + +// --------------------------------------------------------------------------- + + String8::String8() + : mString(getEmptyString()) { + } + + String8::String8(const String8 &o) + : mString(o.mString) { + SharedBuffer::bufferFromData(mString)->acquire(); + } + + String8::String8(const char *o) + : mString(allocFromUTF8(o, strlen(o))) { + if (mString == nullptr) { + mString = getEmptyString(); + } + } + + String8::String8(const char *o, size_t len) + : mString(allocFromUTF8(o, len)) { + if (mString == nullptr) { + mString = getEmptyString(); + } + } + + String8::String8(const String16 &o) : mString(allocFromUTF16(o.c_str(), o.size())) {} + + String8::String8(const char16_t *o) + : mString(allocFromUTF16(o, strlen16(o))) { + } + + String8::String8(const char16_t *o, size_t len) + : mString(allocFromUTF16(o, len)) { + } + + String8::String8(const char32_t *o) + : mString(allocFromUTF32(o, std::char_traits::length(o))) {} + + String8::String8(const char32_t *o, size_t len) + : mString(allocFromUTF32(o, len)) { + } + + String8::~String8() { + SharedBuffer::bufferFromData(mString)->release(); + } + + size_t String8::length() const { + return SharedBuffer::sizeFromData(mString) - 1; + } + + String8 String8::format(const char *fmt, ...) { + va_list args; + va_start(args, fmt); + + String8 result(formatV(fmt, args)); + + va_end(args); + return result; + } + + String8 String8::formatV(const char *fmt, va_list args) { + String8 result; + result.appendFormatV(fmt, args); + return result; + } + + void String8::clear() { + SharedBuffer::bufferFromData(mString)->release(); + mString = getEmptyString(); + } + + void String8::setTo(const String8 &other) { + SharedBuffer::bufferFromData(other.mString)->acquire(); + SharedBuffer::bufferFromData(mString)->release(); + mString = other.mString; + } + + status_t String8::setTo(const char *other) { + const char *newString = allocFromUTF8(other, strlen(other)); + SharedBuffer::bufferFromData(mString)->release(); + mString = newString; + if (mString) return OK; + + mString = getEmptyString(); + return NO_MEMORY; + } + + status_t String8::setTo(const char *other, size_t len) { + const char *newString = allocFromUTF8(other, len); + SharedBuffer::bufferFromData(mString)->release(); + mString = newString; + if (mString) return OK; + + mString = getEmptyString(); + return NO_MEMORY; + } + + status_t String8::setTo(const char16_t *other, size_t len) { + const char *newString = allocFromUTF16(other, len); + SharedBuffer::bufferFromData(mString)->release(); + mString = newString; + if (mString) return OK; + + mString = getEmptyString(); + return NO_MEMORY; + } + + status_t String8::setTo(const char32_t *other, size_t len) { + const char *newString = allocFromUTF32(other, len); + SharedBuffer::bufferFromData(mString)->release(); + mString = newString; + if (mString) return OK; + + mString = getEmptyString(); + return NO_MEMORY; + } + + status_t String8::append(const String8 &other) { + const size_t otherLen = other.bytes(); + if (bytes() == 0) { + setTo(other); + return OK; + } else if (otherLen == 0) { + return OK; + } + + return real_append(other.c_str(), otherLen); + } + + status_t String8::append(const char *other) { + return append(other, strlen(other)); + } + + status_t String8::append(const char *other, size_t otherLen) { + if (bytes() == 0) { + return setTo(other, otherLen); + } else if (otherLen == 0) { + return OK; + } + + return real_append(other, otherLen); + } + + status_t String8::appendFormat(const char *fmt, ...) { + va_list args; + va_start(args, fmt); + + status_t result = appendFormatV(fmt, args); + + va_end(args); + return result; + } + + status_t String8::appendFormatV(const char *fmt, va_list args) { + int n, result = OK; + va_list tmp_args; + + /* args is undefined after vsnprintf. + * So we need a copy here to avoid the + * second vsnprintf access undefined args. + */ + va_copy(tmp_args, args); + n = vsnprintf(nullptr, 0, fmt, tmp_args); + va_end(tmp_args); + + if (n < 0) return UNKNOWN_ERROR; + + if (n > 0) { + size_t oldLength = length(); + if (static_cast(n) > std::numeric_limits::max() - 1 || + oldLength > std::numeric_limits::max() - n - 1) { + return NO_MEMORY; + } + char *buf = lockBuffer(oldLength + n); + if (buf) { + vsnprintf(buf + oldLength, n + 1, fmt, args); + } else { + result = NO_MEMORY; + } + } + return result; + } + + status_t String8::real_append(const char *other, size_t otherLen) { + const size_t myLen = bytes(); + + SharedBuffer *buf; + size_t newLen; + if (__builtin_add_overflow(myLen, otherLen, &newLen) || + __builtin_add_overflow(newLen, 1, &newLen) || + (buf = SharedBuffer::bufferFromData(mString)->editResize(newLen)) == nullptr) { + return NO_MEMORY; + } + + char *str = (char *) buf->data(); + mString = str; + str += myLen; + memcpy(str, other, otherLen); + str[otherLen] = '\0'; + return OK; + } + + char *String8::lockBuffer(size_t size) { + SharedBuffer *buf = SharedBuffer::bufferFromData(mString) + ->editResize(size + 1); + if (buf) { + char *str = (char *) buf->data(); + mString = str; + return str; + } + return nullptr; + } + + void String8::unlockBuffer() { + unlockBuffer(strlen(mString)); + } + + status_t String8::unlockBuffer(size_t size) { + if (size != this->size()) { + SharedBuffer *buf = SharedBuffer::bufferFromData(mString) + ->editResize(size + 1); + if (!buf) { + return NO_MEMORY; + } + + char *str = (char *) buf->data(); + str[size] = 0; + mString = str; + } + + return OK; + } + + ssize_t String8::find(const char *other, size_t start) const { + size_t len = size(); + if (start >= len) { + return -1; + } + const char *s = mString + start; + const char *p = strstr(s, other); + return p ? p - mString : -1; + } + + bool String8::removeAll(const char *other) { +// ALOG_ASSERT(other, "String8::removeAll() requires a non-NULL string"); + + if (*other == '\0') + return true; + + ssize_t index = find(other); + if (index < 0) return false; + + char *buf = lockBuffer(size()); + if (!buf) return false; // out of memory + + size_t skip = strlen(other); + size_t len = size(); + size_t tail = index; + while (size_t(index) < len) { + ssize_t next = find(other, index + skip); + if (next < 0) { + next = len; + } + + memmove(buf + tail, buf + index + skip, next - index - skip); + tail += next - index - skip; + index = next; + } + unlockBuffer(tail); + return true; + } + + void String8::toLower() { + const size_t length = size(); + if (length == 0) return; + + char *buf = lockBuffer(length); + for (size_t i = length; i > 0; --i) { + *buf = static_cast(tolower(*buf)); + buf++; + } + unlockBuffer(length); + } + +// --------------------------------------------------------------------------- +// Path functions + +// TODO: we should remove all the path functions from String8 +#if defined(_WIN32) +#define OS_PATH_SEPARATOR '\\' +#else +#define OS_PATH_SEPARATOR '/' +#endif + + String8 String8::getPathDir(void) const { + const char *cp; + const char *const str = mString; + + cp = strrchr(str, OS_PATH_SEPARATOR); + if (cp == nullptr) + return String8(""); + else + return String8(str, cp - str); + } + +/* + * Helper function for finding the start of an extension in a pathname. + * + * Returns a pointer inside mString, or NULL if no extension was found. + */ + static const char *find_extension(const char *str) { + const char *lastSlash; + const char *lastDot; + + // only look at the filename + lastSlash = strrchr(str, OS_PATH_SEPARATOR); + if (lastSlash == nullptr) + lastSlash = str; + else + lastSlash++; + + // find the last dot + lastDot = strrchr(lastSlash, '.'); + if (lastDot == nullptr) + return nullptr; + + // looks good, ship it + return lastDot; + } + + String8 String8::getPathExtension(void) const { + auto ext = find_extension(mString); + if (ext != nullptr) + return String8(ext); + else + return String8(""); + } + +}; // namespace android diff --git a/sysbridge/src/main/cpp/android/utils/String8.h b/sysbridge/src/main/cpp/android/utils/String8.h new file mode 100644 index 0000000000..8875750834 --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/String8.h @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2005 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef ANDROID_STRING8_H +#define ANDROID_STRING8_H + +#include +#include +#include // for strcmp +#include +#include +#include "Errors.h" +#include "Unicode.h" +#include "TypeHelpers.h" + +// --------------------------------------------------------------------------- + +namespace android { + + class String16; + +// DO NOT USE: please use std::string + +//! This is a string holding UTF-8 characters. Does not allow the value more +// than 0x10FFFF, which is not valid unicode codepoint. + class String8 { + public: + String8(); + + String8(const String8 &o); + + explicit String8(const char *o); + + explicit String8(const char *o, size_t numChars); + + explicit String8(std::string_view o); + + explicit String8(const String16 &o); + + explicit String8(const char16_t *o); + + explicit String8(const char16_t *o, size_t numChars); + + explicit String8(const char32_t *o); + + explicit String8(const char32_t *o, size_t numChars); + + ~String8(); + + static String8 format(const char *fmt, ...) __attribute__((format (printf, 1, 2))); + + static String8 formatV(const char *fmt, va_list args); + + inline const char *c_str() const; + + inline size_t size() const; + + inline size_t bytes() const; + + inline bool empty() const; + + size_t length() const; + + void clear(); + + void setTo(const String8 &other); + + status_t setTo(const char *other); + + status_t setTo(const char *other, size_t numChars); + + status_t setTo(const char16_t *other, size_t numChars); + + status_t setTo(const char32_t *other, + size_t length); + + status_t append(const String8 &other); + + status_t append(const char *other); + + status_t append(const char *other, size_t numChars); + + status_t appendFormat(const char *fmt, ...) + __attribute__((format (printf, 2, 3))); + + status_t appendFormatV(const char *fmt, va_list args); + + inline String8 &operator=(const String8 &other); + + inline String8 &operator=(const char *other); + + inline String8 &operator+=(const String8 &other); + + inline String8 operator+(const String8 &other) const; + + inline String8 &operator+=(const char *other); + + inline String8 operator+(const char *other) const; + + inline int compare(const String8 &other) const; + + inline bool operator<(const String8 &other) const; + + inline bool operator<=(const String8 &other) const; + + inline bool operator==(const String8 &other) const; + + inline bool operator!=(const String8 &other) const; + + inline bool operator>=(const String8 &other) const; + + inline bool operator>(const String8 &other) const; + +#if __cplusplus >= 202002L + inline std::strong_ordering operator<=>(const String8& other) const; +#endif + + inline bool operator<(const char *other) const; + + inline bool operator<=(const char *other) const; + + inline bool operator==(const char *other) const; + + inline bool operator!=(const char *other) const; + + inline bool operator>=(const char *other) const; + + inline bool operator>(const char *other) const; + +#if __cplusplus >= 202002L + inline std::strong_ordering operator<=>(const char* other) const; +#endif + + inline operator const char *() const; + + inline explicit operator std::string_view() const; + + char *lockBuffer(size_t size); + + void unlockBuffer(); + + status_t unlockBuffer(size_t size); + + // return the index of the first byte of other in this at or after + // start, or -1 if not found + ssize_t find(const char *other, size_t start = 0) const; + + inline ssize_t find(const String8 &other, size_t start = 0) const; + + // return true if this string contains the specified substring + inline bool contains(const char *other) const; + + inline bool contains(const String8 &other) const; + + // removes all occurrence of the specified substring + // returns true if any were found and removed + bool removeAll(const char *other); + + inline bool removeAll(const String8 &other); + + void toLower(); + + private: + String8 getPathDir(void) const; + + String8 getPathExtension(void) const; + + status_t real_append(const char *other, size_t numChars); + + const char *mString; + +// These symbols are for potential backward compatibility with prebuilts. To be removed. +#ifdef ENABLE_STRING8_OBSOLETE_METHODS + public: +#else + private: +#endif + + inline const char *string() const; + + inline bool isEmpty() const; + }; + +// String8 can be trivially moved using memcpy() because moving does not +// require any change to the underlying SharedBuffer contents or reference count. + ANDROID_TRIVIAL_MOVE_TRAIT(String8) + + static inline std::ostream &operator<<(std::ostream &os, const String8 &str) { + os << str.c_str(); + return os; + } + +// --------------------------------------------------------------------------- +// No user servicable parts below. + + inline int compare_type(const String8 &lhs, const String8 &rhs) { + return lhs.compare(rhs); + } + + inline int strictly_order_type(const String8 &lhs, const String8 &rhs) { + return compare_type(lhs, rhs) < 0; + } + + inline const char *String8::c_str() const { + return mString; + } + + inline const char *String8::string() const { + return mString; + } + + inline size_t String8::size() const { + return length(); + } + + inline bool String8::empty() const { + return length() == 0; + } + + inline bool String8::isEmpty() const { + return length() == 0; + } + + inline size_t String8::bytes() const { + return length(); + } + + inline ssize_t String8::find(const String8 &other, size_t start) const { + return find(other.c_str(), start); + } + + inline bool String8::contains(const char *other) const { + return find(other) >= 0; + } + + inline bool String8::contains(const String8 &other) const { + return contains(other.c_str()); + } + + inline bool String8::removeAll(const String8 &other) { + return removeAll(other.c_str()); + } + + inline String8 &String8::operator=(const String8 &other) { + setTo(other); + return *this; + } + + inline String8 &String8::operator=(const char *other) { + setTo(other); + return *this; + } + + inline String8 &String8::operator+=(const String8 &other) { + append(other); + return *this; + } + + inline String8 String8::operator+(const String8 &other) const { + String8 tmp(*this); + tmp += other; + return tmp; + } + + inline String8 &String8::operator+=(const char *other) { + append(other); + return *this; + } + + inline String8 String8::operator+(const char *other) const { + String8 tmp(*this); + tmp += other; + return tmp; + } + + inline int String8::compare(const String8 &other) const { + return strcmp(mString, other.mString); + } + + inline bool String8::operator<(const String8 &other) const { + return strcmp(mString, other.mString) < 0; + } + + inline bool String8::operator<=(const String8 &other) const { + return strcmp(mString, other.mString) <= 0; + } + + inline bool String8::operator==(const String8 &other) const { + return strcmp(mString, other.mString) == 0; + } + + inline bool String8::operator!=(const String8 &other) const { + return strcmp(mString, other.mString) != 0; + } + + inline bool String8::operator>=(const String8 &other) const { + return strcmp(mString, other.mString) >= 0; + } + + inline bool String8::operator>(const String8 &other) const { + return strcmp(mString, other.mString) > 0; + } + +#if __cplusplus >= 202002L + inline std::strong_ordering String8::operator<=>(const String8& other) const { + int result = strcmp(mString, other.mString); + if (result == 0) { + return std::strong_ordering::equal; + } else if (result < 0) { + return std::strong_ordering::less; + } else { + return std::strong_ordering::greater; + } + } +#endif + + inline bool String8::operator<(const char *other) const { + return strcmp(mString, other) < 0; + } + + inline bool String8::operator<=(const char *other) const { + return strcmp(mString, other) <= 0; + } + + inline bool String8::operator==(const char *other) const { + return strcmp(mString, other) == 0; + } + + inline bool String8::operator!=(const char *other) const { + return strcmp(mString, other) != 0; + } + + inline bool String8::operator>=(const char *other) const { + return strcmp(mString, other) >= 0; + } + + inline bool String8::operator>(const char *other) const { + return strcmp(mString, other) > 0; + } + +#if __cplusplus >= 202002L + inline std::strong_ordering String8::operator<=>(const char* other) const { + int result = strcmp(mString, other); + if (result == 0) { + return std::strong_ordering::equal; + } else if (result < 0) { + return std::strong_ordering::less; + } else { + return std::strong_ordering::greater; + } + } +#endif + + inline String8::operator const char *() const { + return mString; + } + + inline String8::String8(std::string_view o) : String8(o.data(), o.length()) {} + + inline String8::operator std::string_view() const { + return {mString, length()}; + } + +} // namespace android + +// --------------------------------------------------------------------------- + +#endif // ANDROID_STRING8_H diff --git a/sysbridge/src/main/cpp/android/utils/Tokenizer.cpp b/sysbridge/src/main/cpp/android/utils/Tokenizer.cpp new file mode 100644 index 0000000000..e9ad7bebf3 --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/Tokenizer.cpp @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "../../logging.h" +#include "Tokenizer.h" +#include "Errors.h" +#include "FileMap.h" +#include "String8.h" +#include +#include +#include +#include + +#ifndef DEBUG_TOKENIZER +// Enables debug output for the tokenizer. +#define DEBUG_TOKENIZER 0 +#endif + +namespace android { + + static inline bool isDelimiter(char ch, const char *delimiters) { + return strchr(delimiters, ch) != nullptr; + } + + Tokenizer::Tokenizer(const String8 &filename, FileMap *fileMap, char *buffer, + bool ownBuffer, size_t length) : + mFilename(filename), mFileMap(fileMap), + mBuffer(buffer), mOwnBuffer(ownBuffer), mLength(length), + mCurrent(buffer), mLineNumber(1) { + } + + Tokenizer::~Tokenizer() { + delete mFileMap; + if (mOwnBuffer) { + delete[] mBuffer; + } + } + + status_t Tokenizer::open(const String8 &filename, Tokenizer **outTokenizer) { + *outTokenizer = nullptr; + + int result = OK; + int fd = ::open(filename.c_str(), O_RDONLY); + if (fd < 0) { + result = -errno; + LOGE("Error opening file '%s': %s", filename.c_str(), strerror(errno)); + } else { + struct stat stat; + if (fstat(fd, &stat)) { + result = -errno; + LOGE("Error getting size of file '%s': %s", filename.c_str(), strerror(errno)); + } else { + size_t length = size_t(stat.st_size); + + FileMap *fileMap = new FileMap(); + bool ownBuffer = false; + char *buffer; + if (fileMap->create(nullptr, fd, 0, length, true)) { + fileMap->advise(FileMap::SEQUENTIAL); + buffer = static_cast(fileMap->getDataPtr()); + } else { + delete fileMap; + fileMap = nullptr; + + // Fall back to reading into a buffer since we can't mmap files in sysfs. + // The length we obtained from stat is wrong too (it will always be 4096) + // so we must trust that read will read the entire file. + buffer = new char[length]; + ownBuffer = true; + ssize_t nrd = read(fd, buffer, length); + if (nrd < 0) { + result = -errno; + LOGE("Error reading file '%s': %s", filename.c_str(), strerror(errno)); + delete[] buffer; + buffer = nullptr; + } else { + length = size_t(nrd); + } + } + + if (!result) { + *outTokenizer = new Tokenizer(filename, fileMap, buffer, ownBuffer, length); + } + } + close(fd); + } + return result; + } + + status_t Tokenizer::fromContents(const String8 &filename, + const char *contents, Tokenizer **outTokenizer) { + *outTokenizer = new Tokenizer(filename, nullptr, + const_cast(contents), false, strlen(contents)); + return OK; + } + + String8 Tokenizer::getLocation() const { + String8 result; + result.appendFormat("%s:%d", mFilename.c_str(), mLineNumber); + return result; + } + + String8 Tokenizer::peekRemainderOfLine() const { + const char *end = getEnd(); + const char *eol = mCurrent; + while (eol != end) { + char ch = *eol; + if (ch == '\n') { + break; + } + eol += 1; + } + return String8(mCurrent, eol - mCurrent); + } + + String8 Tokenizer::nextToken(const char *delimiters) { +#if DEBUG_TOKENIZER + LOGD("nextToken"); +#endif + const char *end = getEnd(); + const char *tokenStart = mCurrent; + while (mCurrent != end) { + char ch = *mCurrent; + if (ch == '\n' || isDelimiter(ch, delimiters)) { + break; + } + mCurrent += 1; + } + return String8(tokenStart, mCurrent - tokenStart); + } + + void Tokenizer::nextLine() { +#if DEBUG_TOKENIZER + LOGD("nextLine"); +#endif + const char *end = getEnd(); + while (mCurrent != end) { + char ch = *(mCurrent++); + if (ch == '\n') { + mLineNumber += 1; + break; + } + } + } + + void Tokenizer::skipDelimiters(const char *delimiters) { +#if DEBUG_TOKENIZER + LOGD("skipDelimiters"); +#endif + const char *end = getEnd(); + while (mCurrent != end) { + char ch = *mCurrent; + if (ch == '\n' || !isDelimiter(ch, delimiters)) { + break; + } + mCurrent += 1; + } + } + +} // namespace android diff --git a/sysbridge/src/main/cpp/android/utils/Tokenizer.h b/sysbridge/src/main/cpp/android/utils/Tokenizer.h new file mode 100644 index 0000000000..dee36a7bb0 --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/Tokenizer.h @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef _UTILS_TOKENIZER_H +#define _UTILS_TOKENIZER_H + +#include +#include +#include "FileMap.h" +#include "Errors.h" +#include "String8.h" + +namespace android { + +/** + * A simple tokenizer for loading and parsing ASCII text files line by line. + */ +class Tokenizer { + Tokenizer(const String8& filename, FileMap* fileMap, char* buffer, + bool ownBuffer, size_t length); + +public: + ~Tokenizer(); + + /** + * Opens a file and maps it into memory. + * + * Returns OK and a tokenizer for the file, if successful. + * Otherwise returns an error and sets outTokenizer to NULL. + */ + static status_t open(const String8& filename, Tokenizer** outTokenizer); + + /** + * Prepares to tokenize the contents of a string. + * + * Returns OK and a tokenizer for the string, if successful. + * Otherwise returns an error and sets outTokenizer to NULL. + */ + static status_t fromContents(const String8& filename, + const char* contents, Tokenizer** outTokenizer); + + /** + * Returns true if at the end of the file. + */ + inline bool isEof() const { return mCurrent == getEnd(); } + + /** + * Returns true if at the end of the line or end of the file. + */ + inline bool isEol() const { return isEof() || *mCurrent == '\n'; } + + /** + * Gets the name of the file. + */ + inline String8 getFilename() const { return mFilename; } + + /** + * Gets a 1-based line number index for the current position. + */ + inline int32_t getLineNumber() const { return mLineNumber; } + + /** + * Formats a location string consisting of the filename and current line number. + * Returns a string like "MyFile.txt:33". + */ + String8 getLocation() const; + + /** + * Gets the character at the current position. + * Returns null at end of file. + */ + inline char peekChar() const { return isEof() ? '\0' : *mCurrent; } + + /** + * Gets the remainder of the current line as a string, excluding the newline character. + */ + String8 peekRemainderOfLine() const; + + /** + * Gets the character at the current position and advances past it. + * Returns null at end of file. + */ + inline char nextChar() { return isEof() ? '\0' : *(mCurrent++); } + + /** + * Gets the next token on this line stopping at the specified delimiters + * or the end of the line whichever comes first and advances past it. + * Also stops at embedded nulls. + * Returns the token or an empty string if the current character is a delimiter + * or is at the end of the line. + */ + String8 nextToken(const char* delimiters); + + /** + * Advances to the next line. + * Does nothing if already at the end of the file. + */ + void nextLine(); + + /** + * Skips over the specified delimiters in the line. + * Also skips embedded nulls. + */ + void skipDelimiters(const char* delimiters); + +private: + Tokenizer(const Tokenizer& other); // not copyable + + String8 mFilename; + FileMap* mFileMap; + char* mBuffer; + bool mOwnBuffer; + size_t mLength; + + const char* mCurrent; + int32_t mLineNumber; + + inline const char* getEnd() const { return mBuffer + mLength; } + +}; + +} // namespace android + +#endif // _UTILS_TOKENIZER_H diff --git a/sysbridge/src/main/cpp/android/utils/TypeHelpers.h b/sysbridge/src/main/cpp/android/utils/TypeHelpers.h new file mode 100644 index 0000000000..d867a9a46c --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/TypeHelpers.h @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2005 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef ANDROID_TYPE_HELPERS_H +#define ANDROID_TYPE_HELPERS_H + +#include +#include + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- + +namespace android { + +/* + * Types traits + */ + +template struct trait_trivial_ctor { enum { value = false }; }; +template struct trait_trivial_dtor { enum { value = false }; }; +template struct trait_trivial_copy { enum { value = false }; }; +template struct trait_trivial_move { enum { value = false }; }; +template struct trait_pointer { enum { value = false }; }; +template struct trait_pointer { enum { value = true }; }; + +template +struct traits { + enum { + // whether this type is a pointer + is_pointer = trait_pointer::value, + // whether this type's constructor is a no-op + has_trivial_ctor = is_pointer || trait_trivial_ctor::value, + // whether this type's destructor is a no-op + has_trivial_dtor = is_pointer || trait_trivial_dtor::value, + // whether this type type can be copy-constructed with memcpy + has_trivial_copy = is_pointer || trait_trivial_copy::value, + // whether this type can be moved with memmove + has_trivial_move = is_pointer || trait_trivial_move::value + }; +}; + +template +struct aggregate_traits { + enum { + is_pointer = false, + has_trivial_ctor = + traits::has_trivial_ctor && traits::has_trivial_ctor, + has_trivial_dtor = + traits::has_trivial_dtor && traits::has_trivial_dtor, + has_trivial_copy = + traits::has_trivial_copy && traits::has_trivial_copy, + has_trivial_move = + traits::has_trivial_move && traits::has_trivial_move + }; +}; + +#define ANDROID_TRIVIAL_CTOR_TRAIT( T ) \ + template<> struct trait_trivial_ctor< T > { enum { value = true }; }; + +#define ANDROID_TRIVIAL_DTOR_TRAIT( T ) \ + template<> struct trait_trivial_dtor< T > { enum { value = true }; }; + +#define ANDROID_TRIVIAL_COPY_TRAIT( T ) \ + template<> struct trait_trivial_copy< T > { enum { value = true }; }; + +#define ANDROID_TRIVIAL_MOVE_TRAIT( T ) \ + template<> struct trait_trivial_move< T > { enum { value = true }; }; + +#define ANDROID_BASIC_TYPES_TRAITS( T ) \ + ANDROID_TRIVIAL_CTOR_TRAIT( T ) \ + ANDROID_TRIVIAL_DTOR_TRAIT( T ) \ + ANDROID_TRIVIAL_COPY_TRAIT( T ) \ + ANDROID_TRIVIAL_MOVE_TRAIT( T ) + +// --------------------------------------------------------------------------- + +/* + * basic types traits + */ + +ANDROID_BASIC_TYPES_TRAITS( void ) +ANDROID_BASIC_TYPES_TRAITS( bool ) +ANDROID_BASIC_TYPES_TRAITS( char ) +ANDROID_BASIC_TYPES_TRAITS( unsigned char ) +ANDROID_BASIC_TYPES_TRAITS( short ) +ANDROID_BASIC_TYPES_TRAITS( unsigned short ) +ANDROID_BASIC_TYPES_TRAITS( int ) +ANDROID_BASIC_TYPES_TRAITS( unsigned int ) +ANDROID_BASIC_TYPES_TRAITS( long ) +ANDROID_BASIC_TYPES_TRAITS( unsigned long ) +ANDROID_BASIC_TYPES_TRAITS( long long ) +ANDROID_BASIC_TYPES_TRAITS( unsigned long long ) +ANDROID_BASIC_TYPES_TRAITS( float ) +ANDROID_BASIC_TYPES_TRAITS( double ) + +template struct trait_trivial_ctor { enum { value = true }; }; +template struct trait_trivial_dtor { enum { value = true }; }; +template struct trait_trivial_copy { enum { value = true }; }; +template struct trait_trivial_move { enum { value = true }; }; + +// --------------------------------------------------------------------------- + + +/* + * compare and order types + */ + +template inline +int strictly_order_type(const TYPE& lhs, const TYPE& rhs) { + return (lhs < rhs) ? 1 : 0; +} + +template inline +int compare_type(const TYPE& lhs, const TYPE& rhs) { + return strictly_order_type(rhs, lhs) - strictly_order_type(lhs, rhs); +} + +/* + * create, destroy, copy and move types... + */ + +template inline +void construct_type(TYPE* p, size_t n) { + if (!traits::has_trivial_ctor) { + while (n > 0) { + n--; + new(p++) TYPE; + } + } +} + +template inline +void destroy_type(TYPE* p, size_t n) { + if (!traits::has_trivial_dtor) { + while (n > 0) { + n--; + p->~TYPE(); + p++; + } + } +} + +template +typename std::enable_if::has_trivial_copy>::type +inline +copy_type(TYPE* d, const TYPE* s, size_t n) { + memcpy(d,s,n*sizeof(TYPE)); +} + +template +typename std::enable_if::has_trivial_copy>::type +inline +copy_type(TYPE* d, const TYPE* s, size_t n) { + while (n > 0) { + n--; + new(d) TYPE(*s); + d++, s++; + } +} + +template inline +void splat_type(TYPE* where, const TYPE* what, size_t n) { + if (!traits::has_trivial_copy) { + while (n > 0) { + n--; + new(where) TYPE(*what); + where++; + } + } else { + while (n > 0) { + n--; + *where++ = *what; + } + } +} + +template +struct use_trivial_move : public std::integral_constant::has_trivial_dtor && traits::has_trivial_copy) + || traits::has_trivial_move +> {}; + +template +typename std::enable_if::value>::type +inline +move_forward_type(TYPE* d, const TYPE* s, size_t n = 1) { + memmove(reinterpret_cast(d), s, n * sizeof(TYPE)); +} + +template +typename std::enable_if::value>::type +inline +move_forward_type(TYPE* d, const TYPE* s, size_t n = 1) { + d += n; + s += n; + while (n > 0) { + n--; + --d, --s; + if (!traits::has_trivial_copy) { + new(d) TYPE(*s); + } else { + *d = *s; + } + if (!traits::has_trivial_dtor) { + s->~TYPE(); + } + } +} + +template +typename std::enable_if::value>::type +inline +move_backward_type(TYPE* d, const TYPE* s, size_t n = 1) { + memmove(reinterpret_cast(d), s, n * sizeof(TYPE)); +} + +template +typename std::enable_if::value>::type +inline +move_backward_type(TYPE* d, const TYPE* s, size_t n = 1) { + while (n > 0) { + n--; + if (!traits::has_trivial_copy) { + new(d) TYPE(*s); + } else { + *d = *s; + } + if (!traits::has_trivial_dtor) { + s->~TYPE(); + } + d++, s++; + } +} + +// --------------------------------------------------------------------------- + +/* + * a key/value pair + */ + +template +struct key_value_pair_t { + typedef KEY key_t; + typedef VALUE value_t; + + KEY key; + VALUE value; + key_value_pair_t() { } + key_value_pair_t(const key_value_pair_t& o) : key(o.key), value(o.value) { } + key_value_pair_t& operator=(const key_value_pair_t& o) { + key = o.key; + value = o.value; + return *this; + } + key_value_pair_t(const KEY& k, const VALUE& v) : key(k), value(v) { } + explicit key_value_pair_t(const KEY& k) : key(k) { } + inline bool operator < (const key_value_pair_t& o) const { + return strictly_order_type(key, o.key); + } + inline const KEY& getKey() const { + return key; + } + inline const VALUE& getValue() const { + return value; + } +}; + +template +struct trait_trivial_ctor< key_value_pair_t > +{ enum { value = aggregate_traits::has_trivial_ctor }; }; +template +struct trait_trivial_dtor< key_value_pair_t > +{ enum { value = aggregate_traits::has_trivial_dtor }; }; +template +struct trait_trivial_copy< key_value_pair_t > +{ enum { value = aggregate_traits::has_trivial_copy }; }; +template +struct trait_trivial_move< key_value_pair_t > +{ enum { value = aggregate_traits::has_trivial_move }; }; + +// --------------------------------------------------------------------------- + +/* + * Hash codes. + */ +typedef uint32_t hash_t; + +template +hash_t hash_type(const TKey& key); + +/* Built-in hash code specializations */ +#define ANDROID_INT32_HASH(T) \ + template <> inline hash_t hash_type(const T& value) { return hash_t(value); } +#define ANDROID_INT64_HASH(T) \ + template <> inline hash_t hash_type(const T& value) { \ + return hash_t((value >> 32) ^ value); } +#define ANDROID_REINTERPRET_HASH(T, R) \ + template <> inline hash_t hash_type(const T& value) { \ + R newValue; \ + static_assert(sizeof(newValue) == sizeof(value), "size mismatch"); \ + memcpy(&newValue, &value, sizeof(newValue)); \ + return hash_type(newValue); \ + } + +ANDROID_INT32_HASH(bool) +ANDROID_INT32_HASH(int8_t) +ANDROID_INT32_HASH(uint8_t) +ANDROID_INT32_HASH(int16_t) +ANDROID_INT32_HASH(uint16_t) +ANDROID_INT32_HASH(int32_t) +ANDROID_INT32_HASH(uint32_t) +ANDROID_INT64_HASH(int64_t) +ANDROID_INT64_HASH(uint64_t) +ANDROID_REINTERPRET_HASH(float, uint32_t) +ANDROID_REINTERPRET_HASH(double, uint64_t) + +template inline hash_t hash_type(T* const & value) { + return hash_type(uintptr_t(value)); +} + +} // namespace android + +// --------------------------------------------------------------------------- + +#endif // ANDROID_TYPE_HELPERS_H diff --git a/sysbridge/src/main/cpp/android/utils/Unicode.cpp b/sysbridge/src/main/cpp/android/utils/Unicode.cpp new file mode 100644 index 0000000000..49179cc181 --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/Unicode.cpp @@ -0,0 +1,538 @@ +/* + * Copyright (C) 2005 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#define LOG_TAG "unicode" + +#include +#include "Unicode.h" +#include "../../logging.h" +#include "../liblog/log_main.h" + +#include + +extern "C" { + +static const char32_t kByteMask = 0x000000BF; +static const char32_t kByteMark = 0x00000080; + +// Surrogates aren't valid for UTF-32 characters, so define some +// constants that will let us screen them out. +static const char32_t kUnicodeSurrogateHighStart = 0x0000D800; +// Unused, here for completeness: +// static const char32_t kUnicodeSurrogateHighEnd = 0x0000DBFF; +// static const char32_t kUnicodeSurrogateLowStart = 0x0000DC00; +static const char32_t kUnicodeSurrogateLowEnd = 0x0000DFFF; +static const char32_t kUnicodeSurrogateStart = kUnicodeSurrogateHighStart; +static const char32_t kUnicodeSurrogateEnd = kUnicodeSurrogateLowEnd; +static const char32_t kUnicodeMaxCodepoint = 0x0010FFFF; + +// Mask used to set appropriate bits in first byte of UTF-8 sequence, +// indexed by number of bytes in the sequence. +// 0xxxxxxx +// -> (00-7f) 7bit. Bit mask for the first byte is 0x00000000 +// 110yyyyx 10xxxxxx +// -> (c0-df)(80-bf) 11bit. Bit mask is 0x000000C0 +// 1110yyyy 10yxxxxx 10xxxxxx +// -> (e0-ef)(80-bf)(80-bf) 16bit. Bit mask is 0x000000E0 +// 11110yyy 10yyxxxx 10xxxxxx 10xxxxxx +// -> (f0-f7)(80-bf)(80-bf)(80-bf) 21bit. Bit mask is 0x000000F0 +static const char32_t kFirstByteMark[] = { + 0x00000000, 0x00000000, 0x000000C0, 0x000000E0, 0x000000F0 +}; + +// -------------------------------------------------------------------------- +// UTF-32 +// -------------------------------------------------------------------------- + +/** + * Return number of UTF-8 bytes required for the character. If the character + * is invalid, return size of 0. + */ +static inline size_t utf32_codepoint_utf8_length(char32_t srcChar) { + // Figure out how many bytes the result will require. + if (srcChar < 0x00000080) { + return 1; + } else if (srcChar < 0x00000800) { + return 2; + } else if (srcChar < 0x00010000) { + if ((srcChar < kUnicodeSurrogateStart) || (srcChar > kUnicodeSurrogateEnd)) { + return 3; + } else { + // Surrogates are invalid UTF-32 characters. + return 0; + } + } + // Max code point for Unicode is 0x0010FFFF. + else if (srcChar <= kUnicodeMaxCodepoint) { + return 4; + } else { + // Invalid UTF-32 character. + return 0; + } +} + +// Write out the source character to . + +static inline void utf32_codepoint_to_utf8(uint8_t *dstP, char32_t srcChar, size_t bytes) { + dstP += bytes; + switch (bytes) { /* note: everything falls through. */ + case 4: + *--dstP = (uint8_t) ((srcChar | kByteMark) & kByteMask); + srcChar >>= 6; + [[fallthrough]]; + case 3: + *--dstP = (uint8_t) ((srcChar | kByteMark) & kByteMask); + srcChar >>= 6; + [[fallthrough]]; + case 2: + *--dstP = (uint8_t) ((srcChar | kByteMark) & kByteMask); + srcChar >>= 6; + [[fallthrough]]; + case 1: + *--dstP = (uint8_t) (srcChar | kFirstByteMark[bytes]); + } +} + +static inline int32_t utf32_at_internal(const char *cur, size_t *num_read) { + const char first_char = *cur; + if ((first_char & 0x80) == 0) { // ASCII + *num_read = 1; + return *cur; + } + cur++; + char32_t mask, to_ignore_mask; + size_t num_to_read = 0; + char32_t utf32 = first_char; + for (num_to_read = 1, mask = 0x40, to_ignore_mask = 0xFFFFFF80; + (first_char & mask); + num_to_read++, to_ignore_mask |= mask, mask >>= 1) { + // 0x3F == 00111111 + utf32 = (utf32 << 6) + (*cur++ & 0x3F); + } + to_ignore_mask |= mask; + utf32 &= ~(to_ignore_mask << (6 * (num_to_read - 1))); + + *num_read = num_to_read; + return static_cast(utf32); +} + +int32_t utf32_from_utf8_at(const char *src, size_t src_len, size_t index, size_t *next_index) { + if (index >= src_len) { + return -1; + } + size_t unused_index; + if (next_index == nullptr) { + next_index = &unused_index; + } + size_t num_read; + int32_t ret = utf32_at_internal(src + index, &num_read); + if (ret >= 0) { + *next_index = index + num_read; + } + + return ret; +} + +ssize_t utf32_to_utf8_length(const char32_t *src, size_t src_len) { + if (src == nullptr || src_len == 0) { + return -1; + } + + size_t ret = 0; + const char32_t *end = src + src_len; + while (src < end) { + size_t char_len = utf32_codepoint_utf8_length(*src++); + if (SSIZE_MAX - char_len < ret) { + // If this happens, we would overflow the ssize_t type when + // returning from this function, so we cannot express how + // long this string is in an ssize_t. +// android_errorWriteLog(0x534e4554, "37723026"); + return -1; + } + ret += char_len; + } + return ret; +} + +void utf32_to_utf8(const char32_t *src, size_t src_len, char *dst, size_t dst_len) { + if (src == nullptr || src_len == 0 || dst == nullptr) { + return; + } + + const char32_t *cur_utf32 = src; + const char32_t *end_utf32 = src + src_len; + char *cur = dst; + while (cur_utf32 < end_utf32) { + size_t len = utf32_codepoint_utf8_length(*cur_utf32); + LOG_ALWAYS_FATAL_IF(dst_len < len, "%zu < %zu", dst_len, len); + utf32_codepoint_to_utf8((uint8_t *) cur, *cur_utf32++, len); + cur += len; + dst_len -= len; + } + LOG_ALWAYS_FATAL_IF(dst_len < 1, "dst_len < 1: %zu < 1", dst_len); + *cur = '\0'; +} + +// -------------------------------------------------------------------------- +// UTF-16 +// -------------------------------------------------------------------------- + +int strcmp16(const char16_t *s1, const char16_t *s2) { + char16_t ch; + int d = 0; + + while (1) { + d = (int) (ch = *s1++) - (int) *s2++; + if (d || !ch) + break; + } + + return d; +} + +int strncmp16(const char16_t *s1, const char16_t *s2, size_t n) { + char16_t ch; + int d = 0; + + if (n == 0) { + return 0; + } + + do { + d = (int) (ch = *s1++) - (int) *s2++; + if (d || !ch) { + break; + } + } while (--n); + + return d; +} + +size_t strlen16(const char16_t *s) { + const char16_t *ss = s; + while (*ss) + ss++; + return ss - s; +} + +size_t strnlen16(const char16_t *s, size_t maxlen) { + const char16_t *ss = s; + + /* Important: the maxlen test must precede the reference through ss; + since the byte beyond the maximum may segfault */ + while ((maxlen > 0) && *ss) { + ss++; + maxlen--; + } + return ss - s; +} + +char16_t *strstr16(const char16_t *src, const char16_t *target) { + const char16_t needle = *target; + if (needle == '\0') return (char16_t *) src; + + const size_t target_len = strlen16(++target); + do { + do { + if (*src == '\0') { + return nullptr; + } + } while (*src++ != needle); + } while (strncmp16(src, target, target_len) != 0); + src--; + + return (char16_t *) src; +} + +int strzcmp16(const char16_t *s1, size_t n1, const char16_t *s2, size_t n2) { + const char16_t *e1 = s1 + n1; + const char16_t *e2 = s2 + n2; + + while (s1 < e1 && s2 < e2) { + const int d = (int) *s1++ - (int) *s2++; + if (d) { + return d; + } + } + + return n1 < n2 + ? (0 - (int) *s2) + : (n1 > n2 + ? ((int) *s1 - 0) + : 0); +} + +// is_any_surrogate() returns true if w is either a high or low surrogate +static constexpr bool is_any_surrogate(char16_t w) { + return (w & 0xf800) == 0xd800; +} + +// is_surrogate_pair() returns true if w1 and w2 form a valid surrogate pair +static constexpr bool is_surrogate_pair(char16_t w1, char16_t w2) { + return ((w1 & 0xfc00) == 0xd800) && ((w2 & 0xfc00) == 0xdc00); +} + +// TODO: currently utf16_to_utf8_length() returns -1 if src_len == 0, +// which is inconsistent with utf8_to_utf16_length(), here we keep the +// current behavior as intended not to break compatibility +ssize_t utf16_to_utf8_length(const char16_t *src, size_t src_len) { + if (src == nullptr || src_len == 0) + return -1; + + const char16_t *const end = src + src_len; + const char16_t *in = src; + size_t utf8_len = 0; + + while (in < end) { + char16_t w = *in++; + if (w < 0x0080) [[likely]] { + utf8_len += 1; + continue; + } + if (w < 0x0800) [[likely]] { + utf8_len += 2; + continue; + } + if (!is_any_surrogate(w)) [[likely]] { + utf8_len += 3; + continue; + } + if (in < end && is_surrogate_pair(w, *in)) { + utf8_len += 4; + in++; + continue; + } + /* skip if at the end of the string or invalid surrogate pair */ + } + return (in == end && utf8_len < SSIZE_MAX) ? utf8_len : -1; +} + +void utf16_to_utf8(const char16_t *src, size_t src_len, char *dst, size_t dst_len) { + if (src == nullptr || src_len == 0 || dst == nullptr) { + return; + } + + const char16_t *in = src; + const char16_t *const in_end = src + src_len; + char *out = dst; + const char *const out_end = dst + dst_len; + char16_t w2; + + auto err_out = [&out, &out_end, &dst_len]() { + LOG_ALWAYS_FATAL_IF(out >= out_end, + "target utf8 string size %zu too short", dst_len); + }; + + while (in < in_end) { + char16_t w = *in++; + if (w < 0x0080) [[likely]] { + if (out + 1 > out_end) + return err_out(); + *out++ = (char) (w & 0xff); + continue; + } + if (w < 0x0800) [[likely]] { + if (out + 2 > out_end) + return err_out(); + *out++ = (char) (0xc0 | ((w >> 6) & 0x1f)); + *out++ = (char) (0x80 | ((w >> 0) & 0x3f)); + continue; + } + if (!is_any_surrogate(w)) [[likely]] { + if (out + 3 > out_end) + return err_out(); + *out++ = (char) (0xe0 | ((w >> 12) & 0xf)); + *out++ = (char) (0x80 | ((w >> 6) & 0x3f)); + *out++ = (char) (0x80 | ((w >> 0) & 0x3f)); + continue; + } + /* surrogate pair */ + if (in < in_end && (w2 = *in, is_surrogate_pair(w, w2))) { + if (out + 4 > out_end) + return err_out(); + char32_t dw = (char32_t) (0x10000 + ((w - 0xd800) << 10) + (w2 - 0xdc00)); + *out++ = (char) (0xf0 | ((dw >> 18) & 0x07)); + *out++ = (char) (0x80 | ((dw >> 12) & 0x3f)); + *out++ = (char) (0x80 | ((dw >> 6) & 0x3f)); + *out++ = (char) (0x80 | ((dw >> 0) & 0x3f)); + in++; + } + /* We reach here in two cases: + * 1) (in == in_end), which means end of the input string + * 2) (w2 & 0xfc00) != 0xdc00, which means invalid surrogate pair + * In either case, we intentionally do nothing and skip + */ + } + *out = '\0'; + return; +} + +// -------------------------------------------------------------------------- +// UTF-8 +// -------------------------------------------------------------------------- + +static char32_t utf8_4b_to_utf32(uint8_t c1, uint8_t c2, uint8_t c3, uint8_t c4) { + return ((c1 & 0x07) << 18) | ((c2 & 0x3f) << 12) | ((c3 & 0x3f) << 6) | (c4 & 0x3f); +} + +// TODO: current behavior of converting UTF8 to UTF-16 has a few issues below +// +// 1. invalid trailing bytes (i.e. not b'10xxxxxx) are treated as valid trailing +// bytes and follows normal conversion rules +// 2. invalid leading byte (b'10xxxxxx) is treated as a valid single UTF-8 byte +// 3. invalid leading byte (b'11111xxx) is treated as a valid leading byte +// (same as b'11110xxx) for a 4-byte UTF-8 sequence +// 4. an invalid 4-byte UTF-8 sequence that translates to a codepoint < U+10000 +// will be converted as a valid UTF-16 character +// +// We keep the current behavior as is but with warnings logged, so as not to +// break compatibility. However, this needs to be addressed later. + +ssize_t utf8_to_utf16_length(const uint8_t *u8str, size_t u8len, bool overreadIsFatal) { + if (u8str == nullptr) + return -1; + + const uint8_t *const in_end = u8str + u8len; + const uint8_t *in = u8str; + size_t utf16_len = 0; + + while (in < in_end) { + uint8_t c = *in; + utf16_len++; + if ((c & 0x80) == 0) [[likely]] { + in++; + continue; + } + if (c < 0xc0) [[unlikely]] { + LOGW("Invalid UTF-8 leading byte: 0x%02x", c); + in++; + continue; + } + if (c < 0xe0) [[likely]] { + in += 2; + continue; + } + if (c < 0xf0) [[likely]] { + in += 3; + continue; + } else { + uint8_t c2, c3, c4; + if (c >= 0xf8) [[unlikely]] { + LOGW("Invalid UTF-8 leading byte: 0x%02x", c); + } + c2 = in[1]; + c3 = in[2]; + c4 = in[3]; + if (utf8_4b_to_utf32(c, c2, c3, c4) >= 0x10000) { + utf16_len++; + } + in += 4; + continue; + } + } + if (in == in_end) { + return utf16_len < SSIZE_MAX ? utf16_len : -1; + } + if (overreadIsFatal) + LOG_ALWAYS_FATAL("Attempt to overread computing length of utf8 string"); + return -1; +} + +char16_t *utf8_to_utf16(const uint8_t *u8str, size_t u8len, char16_t *u16str, size_t u16len) { + // A value > SSIZE_MAX is probably a negative value returned as an error and casted. + LOG_ALWAYS_FATAL_IF(u16len == 0 || u16len > SSIZE_MAX, "u16len is %zu", u16len); + char16_t *end = utf8_to_utf16_no_null_terminator(u8str, u8len, u16str, u16len - 1); + *end = 0; + return end; +} + +char16_t *utf8_to_utf16_no_null_terminator( + const uint8_t *src, size_t srcLen, char16_t *dst, size_t dstLen) { + if (src == nullptr || srcLen == 0 || dstLen == 0) { + return dst; + } + // A value > SSIZE_MAX is probably a negative value returned as an error and casted. + LOG_ALWAYS_FATAL_IF(dstLen > SSIZE_MAX, "dstLen is %zu", dstLen); + + const uint8_t *const in_end = src + srcLen; + const uint8_t *in = src; + const char16_t *const out_end = dst + dstLen; + char16_t *out = dst; + uint8_t c, c2, c3, c4; + char32_t w; + + auto err_in = [&c, &out]() { + LOGW("Unended UTF-8 byte: 0x%02x", c); + return out; + }; + + while (in < in_end && out < out_end) { + c = *in++; + if ((c & 0x80) == 0) [[likely]] { + *out++ = (char16_t) (c); + continue; + } + if (c < 0xc0) [[unlikely]] { + ALOGW("Invalid UTF-8 leading byte: 0x%02x", c); + *out++ = (char16_t) (c); + continue; + } + if (c < 0xe0) [[likely]] { + if (in + 1 > in_end) [[unlikely]] { + return err_in(); + } + c2 = *in++; + *out++ = (char16_t) (((c & 0x1f) << 6) | (c2 & 0x3f)); + continue; + } + if (c < 0xf0) [[likely]] { + if (in + 2 > in_end) [[unlikely]] { + return err_in(); + } + c2 = *in++; + c3 = *in++; + *out++ = (char16_t) (((c & 0x0f) << 12) | + ((c2 & 0x3f) << 6) | (c3 & 0x3f)); + continue; + } else { + if (in + 3 > in_end) [[unlikely]] { + return err_in(); + } + if (c >= 0xf8) [[unlikely]] { + LOGW("Invalid UTF-8 leading byte: 0x%02x", c); + } + // Multiple UTF16 characters with surrogates + c2 = *in++; + c3 = *in++; + c4 = *in++; + w = utf8_4b_to_utf32(c, c2, c3, c4); + if (w < 0x10000) [[unlikely]] { + *out++ = (char16_t) (w); + } else { + if (out + 2 > out_end) [[unlikely]] { + // Ooops.... not enough room for this surrogate pair. + return out; + } + *out++ = (char16_t) (((w - 0x10000) >> 10) + 0xd800); + *out++ = (char16_t) (((w - 0x10000) & 0x3ff) + 0xdc00); + } + continue; + } + } + return out; +} + +} diff --git a/sysbridge/src/main/cpp/android/utils/Unicode.h b/sysbridge/src/main/cpp/android/utils/Unicode.h new file mode 100644 index 0000000000..d60d5d6ba6 --- /dev/null +++ b/sysbridge/src/main/cpp/android/utils/Unicode.h @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2005 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef ANDROID_UNICODE_H +#define ANDROID_UNICODE_H + +#include +#include + +extern "C" { + +// Standard string functions on char16_t strings. +int strcmp16(const char16_t *, const char16_t *); +int strncmp16(const char16_t *s1, const char16_t *s2, size_t n); +size_t strlen16(const char16_t *); +size_t strnlen16(const char16_t *, size_t); +char16_t *strstr16(const char16_t*, const char16_t*); + +// Version of comparison that supports embedded NULs. +// This is different than strncmp() because we don't stop +// at a nul character and consider the strings to be different +// if the lengths are different (thus we need to supply the +// lengths of both strings). This can also be used when +// your string is not nul-terminated as it will have the +// equivalent result as strcmp16 (unlike strncmp16). +int strzcmp16(const char16_t *s1, size_t n1, const char16_t *s2, size_t n2); + +/** + * Measure the length of a UTF-32 string in UTF-8. If the string is invalid + * such as containing a surrogate character, -1 will be returned. + */ +ssize_t utf32_to_utf8_length(const char32_t *src, size_t src_len); + +/** + * Stores a UTF-8 string converted from "src" in "dst", if "dst_length" is not + * large enough to store the string, the part of the "src" string is stored + * into "dst" as much as possible. See the examples for more detail. + * Returns the size actually used for storing the string. + * dst" is not nul-terminated when dst_len is fully used (like strncpy). + * + * \code + * Example 1 + * "src" == \u3042\u3044 (\xE3\x81\x82\xE3\x81\x84) + * "src_len" == 2 + * "dst_len" >= 7 + * -> + * Returned value == 6 + * "dst" becomes \xE3\x81\x82\xE3\x81\x84\0 + * (note that "dst" is nul-terminated) + * + * Example 2 + * "src" == \u3042\u3044 (\xE3\x81\x82\xE3\x81\x84) + * "src_len" == 2 + * "dst_len" == 5 + * -> + * Returned value == 3 + * "dst" becomes \xE3\x81\x82\0 + * (note that "dst" is nul-terminated, but \u3044 is not stored in "dst" + * since "dst" does not have enough size to store the character) + * + * Example 3 + * "src" == \u3042\u3044 (\xE3\x81\x82\xE3\x81\x84) + * "src_len" == 2 + * "dst_len" == 6 + * -> + * Returned value == 6 + * "dst" becomes \xE3\x81\x82\xE3\x81\x84 + * (note that "dst" is NOT nul-terminated, like strncpy) + * \endcode + */ +void utf32_to_utf8(const char32_t* src, size_t src_len, char* dst, size_t dst_len); + +/** + * Returns the unicode value at "index". + * Returns -1 when the index is invalid (equals to or more than "src_len"). + * If returned value is positive, it is able to be converted to char32_t, which + * is unsigned. Then, if "next_index" is not NULL, the next index to be used is + * stored in "next_index". "next_index" can be NULL. + */ +int32_t utf32_from_utf8_at(const char *src, size_t src_len, size_t index, size_t *next_index); + + +/** + * Returns the UTF-8 length of UTF-16 string "src". + */ +ssize_t utf16_to_utf8_length(const char16_t *src, size_t src_len); + +/** + * Converts a UTF-16 string to UTF-8. The destination buffer must be large + * enough to fit the UTF-16 as measured by utf16_to_utf8_length with an added + * NUL terminator. + */ +void utf16_to_utf8(const char16_t* src, size_t src_len, char* dst, size_t dst_len); + +/** + * Returns the UTF-16 length of UTF-8 string "src". Returns -1 in case + * it's invalid utf8. No buffer over-read occurs because of bound checks. Using overreadIsFatal you + * can ask to log a message and fail in case the invalid utf8 could have caused an override if no + * bound checks were used (otherwise -1 is returned). + */ +ssize_t utf8_to_utf16_length(const uint8_t* src, size_t srcLen, bool overreadIsFatal = false); + +/** + * Convert UTF-8 to UTF-16 including surrogate pairs. + * Returns a pointer to the end of the string (where a NUL terminator might go + * if you wanted to add one). At most dstLen characters are written; it won't emit half a surrogate + * pair. If dstLen == 0 nothing is written and dst is returned. If dstLen > SSIZE_MAX it aborts + * (this being probably a negative number returned as an error and casted to unsigned). + */ +char16_t* utf8_to_utf16_no_null_terminator( + const uint8_t* src, size_t srcLen, char16_t* dst, size_t dstLen); + +/** + * Convert UTF-8 to UTF-16 including surrogate pairs. At most dstLen - 1 + * characters are written; it won't emit half a surrogate pair; and a NUL terminator is appended + * after. dstLen - 1 can be measured beforehand using utf8_to_utf16_length. Aborts if dstLen == 0 + * (at least one character is needed for the NUL terminator) or dstLen > SSIZE_MAX (the latter + * case being likely a negative number returned as an error and casted to unsigned) . Returns a + * pointer to the NUL terminator. + */ +char16_t *utf8_to_utf16( + const uint8_t* src, size_t srcLen, char16_t* dst, size_t dstLen); + +} + +#endif diff --git a/sysbridge/src/main/cpp/cgroup.cpp b/sysbridge/src/main/cpp/cgroup.cpp new file mode 100644 index 0000000000..2fe9af242b --- /dev/null +++ b/sysbridge/src/main/cpp/cgroup.cpp @@ -0,0 +1,73 @@ +#include +#include +#include +#include + +namespace cgroup { + + static ssize_t fdgets(char *buf, const size_t size, int fd) { + ssize_t len = 0; + buf[0] = '\0'; + while (len < size - 1) { + ssize_t ret = read(fd, buf + len, 1); + if (ret < 0) + return -1; + if (ret == 0) + break; + if (buf[len] == '\0' || buf[len++] == '\n') { + break; + } + } + buf[len] = '\0'; + buf[size - 1] = '\0'; + return len; + } + + int get_cgroup(int pid, int *cuid, int *cpid) { + char buf[PATH_MAX]; + snprintf(buf, PATH_MAX, "/proc/%d/cgroup", pid); + + int fd = open(buf, O_RDONLY); + if (fd == -1) + return -1; + + while (fdgets(buf, PATH_MAX, fd) > 0) { + if (sscanf(buf, "%*d:cpuacct:/uid_%d/pid_%d", cuid, cpid) == 2) { + close(fd); + return 0; + } + } + close(fd); + return -1; + } + + static int switch_cgroup(int pid, int cuid, int cpid, const char *name) { + char buf[PATH_MAX]; + if (cuid != -1 && cpid != -1) { + snprintf(buf, PATH_MAX, "/acct/uid_%d/pid_%d/%s", cuid, cpid, name); + } else { + snprintf(buf, PATH_MAX, "/acct/%s", name); + } + + int fd = open(buf, O_WRONLY | O_APPEND); + if (fd == -1) + return -1; + + snprintf(buf, PATH_MAX, "%d\n", pid); + if (write(fd, buf, strlen(buf)) == -1) { + close(fd); + return -1; + } + + close(fd); + return 0; + } + + int switch_cgroup(int pid, int cuid, int cpid) { + int res = 0; + res += switch_cgroup(pid, cuid, cpid, "cgroup.procs"); + res += switch_cgroup(pid, cuid, cpid, "tasks"); + return res; + } + +} \ No newline at end of file diff --git a/sysbridge/src/main/cpp/cgroup.h b/sysbridge/src/main/cpp/cgroup.h new file mode 100644 index 0000000000..361a0b1919 --- /dev/null +++ b/sysbridge/src/main/cpp/cgroup.h @@ -0,0 +1,9 @@ +#ifndef CGROUP_H +#define CGROUP_H + +namespace cgroup { + int get_cgroup(int pid, int* cuid, int *cpid); + int switch_cgroup(int pid, int cuid, int cpid); +} + +#endif // CGROUP_H diff --git a/sysbridge/src/main/cpp/libevdev/Makefile.am b/sysbridge/src/main/cpp/libevdev/Makefile.am new file mode 100644 index 0000000000..f577900827 --- /dev/null +++ b/sysbridge/src/main/cpp/libevdev/Makefile.am @@ -0,0 +1,42 @@ +lib_LTLIBRARIES=libevdev.la + +AM_CPPFLAGS = $(GCC_CFLAGS) $(GCOV_CFLAGS) -I$(top_srcdir)/include -I$(top_srcdir) +AM_LDFLAGS = $(GCOV_LDFLAGS) + +libevdev_la_SOURCES = \ + libevdev.h \ + libevdev-int.h \ + libevdev-util.h \ + libevdev-uinput.c \ + libevdev-uinput.h \ + libevdev-uinput-int.h \ + libevdev.c \ + libevdev-names.c \ + ../include/linux/input.h \ + ../include/linux/uinput.h \ + ../include/linux/@OS@/input-event-codes.h \ + ../include/linux/@OS@/input.h \ + ../include/linux/@OS@/uinput.h + +libevdev_la_LDFLAGS = \ + $(AM_LDFLAGS) \ + -version-info $(LIBEVDEV_LT_VERSION) \ + -Wl,--version-script="$(srcdir)/libevdev.sym" \ + $(GNU_LD_FLAGS) + +EXTRA_libevdev_la_DEPENDENCIES = $(srcdir)/libevdev.sym + +libevdevincludedir = $(includedir)/libevdev-1.0/libevdev +libevdevinclude_HEADERS = libevdev.h libevdev-uinput.h + +event-names.h: Makefile make-event-names.py + $(PYTHON) $(srcdir)/make-event-names.py $(top_srcdir)/include/linux/@OS@/input.h $(top_srcdir)/include/linux/@OS@/input-event-codes.h > $@ + + +EXTRA_DIST = make-event-names.py libevdev.sym ../include +CLEANFILES = event-names.h +BUILT_SOURCES = event-names.h + +if GCOV_ENABLED +CLEANFILES += *.gcno +endif diff --git a/sysbridge/src/main/cpp/libevdev/libevdev-int.h b/sysbridge/src/main/cpp/libevdev/libevdev-int.h new file mode 100644 index 0000000000..3f47dfba15 --- /dev/null +++ b/sysbridge/src/main/cpp/libevdev/libevdev-int.h @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: MIT +/* + * Copyright © 2013 Red Hat, Inc. + */ + +#ifndef LIBEVDEV_INT_H +#define LIBEVDEV_INT_H + +#include +#include +#include +#include +#include "libevdev.h" +#include "libevdev-util.h" + +#define MAX_NAME 256 +#define ABS_MT_MIN ABS_MT_SLOT +#define ABS_MT_MAX ABS_MT_TOOL_Y +#define ABS_MT_CNT (ABS_MT_MAX - ABS_MT_MIN + 1) +#define LIBEVDEV_EXPORT __attribute__((visibility("default"))) +#define ALIAS(_to) __attribute__((alias(#_to))) + +/** + * Sync state machine: + * default state: SYNC_NONE + * + * SYNC_NONE → SYN_DROPPED or forced sync → SYNC_NEEDED + * SYNC_NEEDED → libevdev_next_event(LIBEVDEV_READ_FLAG_SYNC) → SYNC_IN_PROGRESS + * SYNC_NEEDED → libevdev_next_event(LIBEVDEV_READ_FLAG_SYNC_NONE) → SYNC_NONE + * SYNC_IN_PROGRESS → libevdev_next_event(LIBEVDEV_READ_FLAG_SYNC_NONE) → SYNC_NONE + * SYNC_IN_PROGRESS → no sync events left → SYNC_NONE + * + */ +enum SyncState { + SYNC_NONE, + SYNC_NEEDED, + SYNC_IN_PROGRESS, +}; + +/** + * Internal only: log data used to send messages to the respective log + * handler. We re-use the same struct for a global and inside + * struct libevdev. + * For the global, device_handler is NULL, for per-device instance + * global_handler is NULL. + */ +struct logdata { + enum libevdev_log_priority priority; /** minimum logging priority */ + libevdev_log_func_t global_handler; /** global handler function */ + libevdev_device_log_func_t device_handler; /** per-device handler function */ + void *userdata; /** user-defined data pointer */ +}; + +struct libevdev { + int fd; + bool initialized; + char *name; + char *phys; + char *uniq; + struct input_id ids; + int driver_version; + unsigned long bits[NLONGS(EV_CNT)]; + unsigned long props[NLONGS(INPUT_PROP_CNT)]; + unsigned long key_bits[NLONGS(KEY_CNT)]; + unsigned long rel_bits[NLONGS(REL_CNT)]; + unsigned long abs_bits[NLONGS(ABS_CNT)]; + unsigned long led_bits[NLONGS(LED_CNT)]; + unsigned long msc_bits[NLONGS(MSC_CNT)]; + unsigned long sw_bits[NLONGS(SW_CNT)]; + unsigned long rep_bits[NLONGS(REP_CNT)]; /* convenience, always 1 */ + unsigned long ff_bits[NLONGS(FF_CNT)]; + unsigned long snd_bits[NLONGS(SND_CNT)]; + unsigned long key_values[NLONGS(KEY_CNT)]; + unsigned long led_values[NLONGS(LED_CNT)]; + unsigned long sw_values[NLONGS(SW_CNT)]; + struct input_absinfo abs_info[ABS_CNT]; + int *mt_slot_vals; /* [num_slots * ABS_MT_CNT] */ + int num_slots; /**< valid slots in mt_slot_vals */ + int current_slot; + int rep_values[REP_CNT]; + + enum SyncState sync_state; + enum libevdev_grab_mode grabbed; + + struct input_event *queue; + size_t queue_size; /**< size of queue in elements */ + size_t queue_next; /**< next event index */ + size_t queue_nsync; /**< number of sync events */ + + struct timeval last_event_time; + + struct logdata log; +}; + +#define log_msg_cond(dev, priority, ...) \ + do { \ + if (_libevdev_log_priority(dev) >= priority) \ + _libevdev_log_msg(dev, priority, __FILE__, __LINE__, __func__, __VA_ARGS__); \ + } while(0) + +#define log_error(dev, ...) log_msg_cond(dev, LIBEVDEV_LOG_ERROR, __VA_ARGS__) +#define log_info(dev, ...) log_msg_cond(dev, LIBEVDEV_LOG_INFO, __VA_ARGS__) +#define log_dbg(dev, ...) log_msg_cond(dev, LIBEVDEV_LOG_DEBUG, __VA_ARGS__) +#define log_bug(dev, ...) log_msg_cond(dev, LIBEVDEV_LOG_ERROR, "BUG: "__VA_ARGS__) + +extern void +_libevdev_log_msg(const struct libevdev *dev, + enum libevdev_log_priority priority, + const char *file, int line, const char *func, + const char *format, ...) LIBEVDEV_ATTRIBUTE_PRINTF(6, 7); + +extern enum libevdev_log_priority +_libevdev_log_priority(const struct libevdev *dev); + +static inline void +init_event(struct libevdev *dev, struct input_event *ev, int type, int code, int value) { + ev->input_event_sec = dev->last_event_time.tv_sec; + ev->input_event_usec = dev->last_event_time.tv_usec; + ev->type = type; + ev->code = code; + ev->value = value; +} + +/** + * @return a pointer to the next element in the queue, or NULL if the queue + * is full. + */ +static inline struct input_event * +queue_push(struct libevdev *dev) { + if (dev->queue_next >= dev->queue_size) + return NULL; + + return &dev->queue[dev->queue_next++]; +} + +static inline bool +queue_push_event(struct libevdev *dev, unsigned int type, + unsigned int code, int value) { + struct input_event *ev = queue_push(dev); + + if (ev) + init_event(dev, ev, type, code, value); + + return ev != NULL; +} + +/** + * Set ev to the last element in the queue, removing it from the queue. + * + * @return 0 on success, 1 if the queue is empty. + */ +static inline int +queue_pop(struct libevdev *dev, struct input_event *ev) { + if (dev->queue_next == 0) + return 1; + + *ev = dev->queue[--dev->queue_next]; + + return 0; +} + +static inline int +queue_peek(struct libevdev *dev, size_t idx, struct input_event *ev) { + if (dev->queue_next == 0 || idx > dev->queue_next) + return 1; + *ev = dev->queue[idx]; + return 0; +} + +/** + * Shift the first n elements into ev and return the number of elements + * shifted. + * ev must be large enough to store n elements. + * + * @param ev The buffer to copy into, or NULL + * @return The number of elements in ev. + */ +static inline int +queue_shift_multiple(struct libevdev *dev, size_t n, struct input_event *ev) { + size_t remaining; + + if (dev->queue_next == 0) + return 0; + + remaining = dev->queue_next; + n = min(n, remaining); + remaining -= n; + + if (ev) + memcpy(ev, dev->queue, n * sizeof(*ev)); + + memmove(dev->queue, &dev->queue[n], remaining * sizeof(*dev->queue)); + + dev->queue_next = remaining; + return n; +} + +/** + * Set ev to the first element in the queue, shifting everything else + * forward by one. + * + * @return 0 on success, 1 if the queue is empty. + */ +static inline int +queue_shift(struct libevdev *dev, struct input_event *ev) { + return queue_shift_multiple(dev, 1, ev) == 1 ? 0 : 1; +} + +static inline int +queue_alloc(struct libevdev *dev, size_t size) { + if (size == 0) + return -ENOMEM; + + dev->queue = calloc(size, sizeof(struct input_event)); + if (!dev->queue) + return -ENOMEM; + + dev->queue_size = size; + dev->queue_next = 0; + return 0; +} + +static inline void +queue_free(struct libevdev *dev) { + free(dev->queue); + dev->queue_size = 0; + dev->queue_next = 0; +} + +static inline size_t +queue_num_elements(struct libevdev *dev) { + return dev->queue_next; +} + +static inline size_t +queue_size(struct libevdev *dev) { + return dev->queue_size; +} + +static inline size_t +queue_num_free_elements(struct libevdev *dev) { + if (dev->queue_size == 0) + return 0; + + return dev->queue_size - dev->queue_next; +} + +static inline struct input_event * +queue_next_element(struct libevdev *dev) { + if (dev->queue_next == dev->queue_size) + return NULL; + + return &dev->queue[dev->queue_next]; +} + +static inline int +queue_set_num_elements(struct libevdev *dev, size_t nelem) { + if (nelem > dev->queue_size) + return 1; + + dev->queue_next = nelem; + + return 0; +} + +#define max_mask(uc, lc) \ + case EV_##uc: \ + *mask = dev->lc##_bits; \ + max = libevdev_event_type_get_max(type); \ + break; + +static inline int +type_to_mask_const(const struct libevdev *dev, unsigned int type, const unsigned long **mask) { + int max; + + switch (type) { + max_mask(ABS, abs); + max_mask(REL, rel); + max_mask(KEY, key); + max_mask(LED, led); + max_mask(MSC, msc); + max_mask(SW, sw); + max_mask(FF, ff); + max_mask(REP, rep); + max_mask(SND, snd); + default: + max = -1; + break; + } + + return max; +} + +static inline int +type_to_mask(struct libevdev *dev, unsigned int type, unsigned long **mask) { + int max; + + switch (type) { + max_mask(ABS, abs); + max_mask(REL, rel); + max_mask(KEY, key); + max_mask(LED, led); + max_mask(MSC, msc); + max_mask(SW, sw); + max_mask(FF, ff); + max_mask(REP, rep); + max_mask(SND, snd); + default: + max = -1; + break; + } + + return max; +} + +#undef max_mask +#endif diff --git a/sysbridge/src/main/cpp/libevdev/libevdev-names.c b/sysbridge/src/main/cpp/libevdev/libevdev-names.c new file mode 100644 index 0000000000..f8c991b84a --- /dev/null +++ b/sysbridge/src/main/cpp/libevdev/libevdev-names.c @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: MIT +/* + * Copyright © 2013 David Herrmann + */ + +#include +#include +#include +#include + +#include "libevdev-int.h" +#include "libevdev-util.h" +#include "libevdev.h" + +#include "event-names.h" + +struct name_lookup { + const char *name; + size_t len; +}; + +static int cmp_entry(const void *vlookup, const void *ventry) +{ + const struct name_lookup *lookup = vlookup; + const struct name_entry *entry = ventry; + int r; + + r = strncmp(lookup->name, entry->name, lookup->len); + if (!r) { + if (entry->name[lookup->len]) + r = -1; + else + r = 0; + } + + return r; +} + +static const struct name_entry* +lookup_name(const struct name_entry *array, size_t asize, + struct name_lookup *lookup) +{ + const struct name_entry *entry; + + entry = bsearch(lookup, array, asize, sizeof(*array), cmp_entry); + if (!entry) + return NULL; + + return entry; +} + +LIBEVDEV_EXPORT int +libevdev_event_type_from_name(const char *name) +{ + return libevdev_event_type_from_name_n(name, strlen(name)); +} + +LIBEVDEV_EXPORT int +libevdev_event_type_from_name_n(const char *name, size_t len) +{ + struct name_lookup lookup; + const struct name_entry *entry; + + lookup.name = name; + lookup.len = len; + + entry = lookup_name(ev_names, ARRAY_LENGTH(ev_names), &lookup); + + return entry ? (int)entry->value : -1; +} + +static int type_from_prefix(const char *name, ssize_t len) +{ + const char *e; + size_t i; + ssize_t l; + + /* MAX_ is not allowed, even though EV_MAX exists */ + if (startswith(name, len, "MAX_", 4)) + return -1; + /* BTN_ is special as there is no EV_BTN type */ + if (startswith(name, len, "BTN_", 4)) + return EV_KEY; + /* FF_STATUS_ is special as FF_ is a prefix of it, so test it first */ + if (startswith(name, len, "FF_STATUS_", 10)) + return EV_FF_STATUS; + + for (i = 0; i < ARRAY_LENGTH(ev_names); ++i) { + /* skip EV_ prefix so @e is suffix of [EV_]XYZ */ + e = &ev_names[i].name[3]; + l = strlen(e); + + /* compare prefix and test for trailing _ */ + if (len > l && startswith(name, len, e, l) && name[l] == '_') + return ev_names[i].value; + } + + return -1; +} + +LIBEVDEV_EXPORT int +libevdev_event_code_from_name(unsigned int type, const char *name) +{ + return libevdev_event_code_from_name_n(type, name, strlen(name)); +} + +LIBEVDEV_EXPORT int +libevdev_event_code_from_name_n(unsigned int type, const char *name, size_t len) +{ + struct name_lookup lookup; + const struct name_entry *entry; + int real_type; + + /* verify that @name is really of type @type */ + real_type = type_from_prefix(name, len); + if (real_type < 0 || (unsigned int)real_type != type) + return -1; + + /* now look up the name @name and return the constant */ + lookup.name = name; + lookup.len = len; + + entry = lookup_name(code_names, ARRAY_LENGTH(code_names), &lookup); + + return entry ? (int)entry->value : -1; +} + +LIBEVDEV_EXPORT int +libevdev_event_value_from_name(unsigned int type, unsigned int code, const char *name) +{ + return libevdev_event_value_from_name_n(type, code, name, strlen(name)); +} + +LIBEVDEV_EXPORT int +libevdev_event_value_from_name_n(unsigned int type, unsigned int code, const char *name, size_t len) +{ + struct name_lookup lookup; + const struct name_entry *entry; + + if (type != EV_ABS || code != ABS_MT_TOOL_TYPE) + return -1; + + lookup.name = name; + lookup.len = len; + + entry = lookup_name(tool_type_names, ARRAY_LENGTH(tool_type_names), &lookup); + + return entry ? (int)entry->value : -1; +} + +LIBEVDEV_EXPORT int +libevdev_property_from_name(const char *name) +{ + return libevdev_property_from_name_n(name, strlen(name)); +} + +LIBEVDEV_EXPORT int +libevdev_property_from_name_n(const char *name, size_t len) +{ + struct name_lookup lookup; + const struct name_entry *entry; + + lookup.name = name; + lookup.len = len; + + entry = lookup_name(prop_names, ARRAY_LENGTH(prop_names), &lookup); + + return entry ? (int)entry->value : -1; +} + +LIBEVDEV_EXPORT int +libevdev_event_code_from_code_name(const char *name) +{ + return libevdev_event_code_from_code_name_n(name, strlen(name)); +} + +LIBEVDEV_EXPORT int +libevdev_event_code_from_code_name_n(const char *name, size_t len) +{ + const struct name_entry *entry; + struct name_lookup lookup; + + /* now look up the name @name and return the constant */ + lookup.name = name; + lookup.len = len; + + entry = lookup_name(code_names, ARRAY_LENGTH(code_names), &lookup); + + return entry ? (int)entry->value : -1; +} + +LIBEVDEV_EXPORT int +libevdev_event_type_from_code_name(const char *name) +{ + return libevdev_event_type_from_code_name_n(name, strlen(name)); +} + +LIBEVDEV_EXPORT int +libevdev_event_type_from_code_name_n(const char *name, size_t len) +{ + const struct name_entry *entry; + struct name_lookup lookup; + + /* First look up if the name exists, we dont' want to return a valid + * type for an invalid code name */ + lookup.name = name; + lookup.len = len; + + entry = lookup_name(code_names, ARRAY_LENGTH(code_names), &lookup); + + return entry ? type_from_prefix(name, len) : -1; +} diff --git a/sysbridge/src/main/cpp/libevdev/libevdev-uinput-int.h b/sysbridge/src/main/cpp/libevdev/libevdev-uinput-int.h new file mode 100644 index 0000000000..c6bf015497 --- /dev/null +++ b/sysbridge/src/main/cpp/libevdev/libevdev-uinput-int.h @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +/* + * Copyright © 2013 Red Hat, Inc. + */ + +struct libevdev_uinput { + int fd; /**< file descriptor to uinput */ + int fd_is_managed; /**< do we need to close it? */ + char *name; /**< device name */ + char *syspath; /**< /sys path */ + char *devnode; /**< device node */ + time_t ctime[2]; /**< before/after UI_DEV_CREATE */ +}; diff --git a/sysbridge/src/main/cpp/libevdev/libevdev-uinput.c b/sysbridge/src/main/cpp/libevdev/libevdev-uinput.c new file mode 100644 index 0000000000..cdce8dead4 --- /dev/null +++ b/sysbridge/src/main/cpp/libevdev/libevdev-uinput.c @@ -0,0 +1,494 @@ +// SPDX-License-Identifier: MIT +/* + * Copyright © 2013 Red Hat, Inc. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "libevdev-int.h" +#include "libevdev-uinput-int.h" +#include "libevdev-uinput.h" +#include "libevdev-util.h" +#include "libevdev.h" + +#ifndef UINPUT_IOCTL_BASE +#define UINPUT_IOCTL_BASE 'U' +#endif + +#ifndef UI_SET_PROPBIT +#define UI_SET_PROPBIT _IOW(UINPUT_IOCTL_BASE, 110, int) +#endif + +static struct libevdev_uinput * +alloc_uinput_device(const char *name) { + struct libevdev_uinput *uinput_dev; + + uinput_dev = calloc(1, sizeof(struct libevdev_uinput)); + if (uinput_dev) { + uinput_dev->name = strdup(name); + uinput_dev->fd = -1; + } + + return uinput_dev; +} + +static inline int +set_abs(const struct libevdev *dev, int fd, unsigned int code) { + const struct input_absinfo *abs = libevdev_get_abs_info(dev, code); + struct uinput_abs_setup abs_setup = {0}; + int rc; + + abs_setup.code = code; + abs_setup.absinfo = *abs; + rc = ioctl(fd, UI_ABS_SETUP, &abs_setup); + return rc; +} + +static int +set_evbits(const struct libevdev *dev, int fd, struct uinput_user_dev *uidev) { + int rc = 0; + unsigned int type; + + for (type = 0; type < EV_CNT; type++) { + unsigned int code; + int max; + int uinput_bit; + const unsigned long *mask; + + if (!libevdev_has_event_type(dev, type)) + continue; + + rc = ioctl(fd, UI_SET_EVBIT, type); + if (rc == -1) + break; + + /* uinput can't set EV_REP */ + if (type == EV_REP) + continue; + + max = type_to_mask_const(dev, type, &mask); + if (max == -1) + continue; + + switch (type) { + case EV_KEY: + uinput_bit = UI_SET_KEYBIT; + break; + case EV_REL: + uinput_bit = UI_SET_RELBIT; + break; + case EV_ABS: + uinput_bit = UI_SET_ABSBIT; + break; + case EV_MSC: + uinput_bit = UI_SET_MSCBIT; + break; + case EV_LED: + uinput_bit = UI_SET_LEDBIT; + break; + case EV_SND: + uinput_bit = UI_SET_SNDBIT; + break; + case EV_FF: + uinput_bit = UI_SET_FFBIT; + break; + case EV_SW: + uinput_bit = UI_SET_SWBIT; + break; + default: + rc = -1; + errno = EINVAL; + goto out; + } + + for (code = 0; code <= (unsigned int) max; code++) { + if (!libevdev_has_event_code(dev, type, code)) + continue; + + rc = ioctl(fd, uinput_bit, code); + if (rc == -1) + goto out; + + if (type == EV_ABS) { + if (uidev == NULL) { + rc = set_abs(dev, fd, code); + if (rc != 0) + goto out; + } else { + const struct input_absinfo *abs = + libevdev_get_abs_info(dev, code); + + uidev->absmin[code] = abs->minimum; + uidev->absmax[code] = abs->maximum; + uidev->absfuzz[code] = abs->fuzz; + uidev->absflat[code] = abs->flat; + /* uinput has no resolution in the + * device struct */ + } + } + } + + } + + out: + return rc; +} + +static int +set_props(const struct libevdev *dev, int fd) { + unsigned int prop; + int rc = 0; + + for (prop = 0; prop <= INPUT_PROP_MAX; prop++) { + if (!libevdev_has_property(dev, prop)) + continue; + + rc = ioctl(fd, UI_SET_PROPBIT, prop); + if (rc == -1) { + /* If UI_SET_PROPBIT is not supported, treat -EINVAL + * as success. The kernel only sends -EINVAL for an + * invalid ioctl, invalid INPUT_PROP_MAX or if the + * ioctl is called on an already created device. The + * last two can't happen here. + */ + if (errno == EINVAL) + rc = 0; + break; + } + } + return rc; +} + +LIBEVDEV_EXPORT int +libevdev_uinput_get_fd(const struct libevdev_uinput *uinput_dev) { + return uinput_dev->fd; +} + +#ifdef __FreeBSD__ +/* + * FreeBSD does not have anything similar to sysfs. + * Set libevdev_uinput->syspath to NULL unconditionally. + * Look up the device nodes directly instead of via sysfs, as this matches what + * is returned by the UI_GET_SYSNAME ioctl() on FreeBSD. + */ +static int +fetch_syspath_and_devnode(struct libevdev_uinput *uinput_dev) +{ +#define DEV_INPUT_DIR "/dev/input/" + int rc; + char buf[sizeof(DEV_INPUT_DIR) + 64] = DEV_INPUT_DIR; + + rc = ioctl(uinput_dev->fd, + UI_GET_SYSNAME(sizeof(buf) - strlen(DEV_INPUT_DIR)), + &buf[strlen(DEV_INPUT_DIR)]); + if (rc == -1) + return -1; + + uinput_dev->syspath = NULL; + uinput_dev->devnode = strdup(buf); + + return 0; +#undef DEV_INPUT_DIR +} + +#else /* !__FreeBSD__ */ + +static int is_event_device(const struct dirent *dent) { + return strncmp("event", dent->d_name, 5) == 0; +} + +static char * +fetch_device_node(const char *path) { + char *devnode = NULL; + struct dirent **namelist; + int ndev, i; + + ndev = scandir(path, &namelist, is_event_device, alphasort); + if (ndev <= 0) + return NULL; + + /* ndev should only ever be 1 */ + + for (i = 0; i < ndev; i++) { + if (!devnode && asprintf(&devnode, "/dev/input/%s", namelist[i]->d_name) == -1) + devnode = NULL; + free(namelist[i]); + } + + free(namelist); + + return devnode; +} + +static int is_input_device(const struct dirent *dent) { + return strncmp("input", dent->d_name, 5) == 0; +} + +static int +fetch_syspath_and_devnode(struct libevdev_uinput *uinput_dev) { +#define SYS_INPUT_DIR "/sys/devices/virtual/input/" + struct dirent **namelist; + int ndev, i; + int rc; + char buf[sizeof(SYS_INPUT_DIR) + 64] = SYS_INPUT_DIR; + + rc = ioctl(uinput_dev->fd, + UI_GET_SYSNAME(sizeof(buf) - strlen(SYS_INPUT_DIR)), + &buf[strlen(SYS_INPUT_DIR)]); + if (rc != -1) { + uinput_dev->syspath = strdup(buf); + uinput_dev->devnode = fetch_device_node(buf); + return 0; + } + + ndev = scandir(SYS_INPUT_DIR, &namelist, is_input_device, alphasort); + if (ndev <= 0) + return -1; + + for (i = 0; i < ndev; i++) { + int fd, len; + struct stat st; + + rc = snprintf(buf, sizeof(buf), "%s%s/name", + SYS_INPUT_DIR, + namelist[i]->d_name); + if (rc < 0 || (size_t) rc >= sizeof(buf)) { + continue; + } + + /* created within time frame */ + fd = open(buf, O_RDONLY); + if (fd < 0) + continue; + + /* created before UI_DEV_CREATE, or after it finished */ + if (fstat(fd, &st) == -1 || + st.st_ctime < uinput_dev->ctime[0] || + st.st_ctime > uinput_dev->ctime[1]) { + close(fd); + continue; + } + + len = read(fd, buf, sizeof(buf)); + close(fd); + if (len <= 0) + continue; + + buf[len - 1] = '\0'; /* file contains \n */ + if (strcmp(buf, uinput_dev->name) == 0) { + if (uinput_dev->syspath) { + /* FIXME: could descend into bit comparison here */ + log_info(NULL, "multiple identical devices found. syspath is unreliable\n"); + break; + } + + rc = snprintf(buf, sizeof(buf), "%s%s", + SYS_INPUT_DIR, + namelist[i]->d_name); + + if (rc < 0 || (size_t) rc >= sizeof(buf)) { + log_error(NULL, "Invalid syspath, syspath is unreliable\n"); + break; + } + + uinput_dev->syspath = strdup(buf); + uinput_dev->devnode = fetch_device_node(buf); + } + } + + for (i = 0; i < ndev; i++) + free(namelist[i]); + free(namelist); + + return uinput_dev->devnode ? 0 : -1; +#undef SYS_INPUT_DIR +} + +#endif /* __FreeBSD__*/ + +static int +uinput_create_write(const struct libevdev *dev, int fd) { + int rc; + struct uinput_user_dev uidev; + + memset(&uidev, 0, sizeof(uidev)); + + strncpy(uidev.name, libevdev_get_name(dev), UINPUT_MAX_NAME_SIZE - 1); + uidev.id.vendor = libevdev_get_id_vendor(dev); + uidev.id.product = libevdev_get_id_product(dev); + uidev.id.bustype = libevdev_get_id_bustype(dev); + uidev.id.version = libevdev_get_id_version(dev); + + if (set_evbits(dev, fd, &uidev) != 0) + goto error; + if (set_props(dev, fd) != 0) + goto error; + + rc = write(fd, &uidev, sizeof(uidev)); + if (rc < 0) { + goto error; + } else if ((size_t) rc < sizeof(uidev)) { + errno = EINVAL; + goto error; + } + + errno = 0; + + error: + return -errno; +} + +static int +uinput_create_DEV_SETUP(const struct libevdev *dev, int fd, + struct libevdev_uinput *new_device) { + int rc; + struct uinput_setup setup; + + if (set_evbits(dev, fd, NULL) != 0) + goto error; + if (set_props(dev, fd) != 0) + goto error; + + memset(&setup, 0, sizeof(setup)); + strncpy(setup.name, libevdev_get_name(dev), UINPUT_MAX_NAME_SIZE - 1); + setup.id.vendor = libevdev_get_id_vendor(dev); + setup.id.product = libevdev_get_id_product(dev); + setup.id.bustype = libevdev_get_id_bustype(dev); + setup.id.version = libevdev_get_id_version(dev); + setup.ff_effects_max = libevdev_has_event_type(dev, EV_FF) ? 10 : 0; + + rc = ioctl(fd, UI_DEV_SETUP, &setup); + if (rc == 0) + errno = 0; + error: + return -errno; +} + +LIBEVDEV_EXPORT int +libevdev_uinput_create_from_device(const struct libevdev *dev, int fd, + struct libevdev_uinput **uinput_dev) { + int rc; + struct libevdev_uinput *new_device; + int close_fd_on_error = (fd == LIBEVDEV_UINPUT_OPEN_MANAGED); + unsigned int uinput_version = 0; + + new_device = alloc_uinput_device(libevdev_get_name(dev)); + if (!new_device) + return -ENOMEM; + + if (fd == LIBEVDEV_UINPUT_OPEN_MANAGED) { + fd = open("/dev/uinput", O_RDWR | O_CLOEXEC); + if (fd < 0) + goto error; + + new_device->fd_is_managed = 1; + } else if (fd < 0) { + log_bug(NULL, "Invalid fd %d\n", fd); + errno = EBADF; + goto error; + } + + if (ioctl(fd, UI_GET_VERSION, &uinput_version) == 0 && + uinput_version >= 5) + rc = uinput_create_DEV_SETUP(dev, fd, new_device); + else + rc = uinput_create_write(dev, fd); + + if (rc != 0) + goto error; + + /* ctime notes time before/after ioctl to help us filter out devices + when traversing /sys/devices/virtual/input to find the device + node. + + this is in seconds, so ctime[0]/[1] will almost always be + identical but /sys doesn't give us sub-second ctime so... + */ + new_device->ctime[0] = time(NULL); + + rc = ioctl(fd, UI_DEV_CREATE, NULL); + if (rc == -1) + goto error; + + new_device->ctime[1] = time(NULL); + new_device->fd = fd; + + if (fetch_syspath_and_devnode(new_device) == -1) { + log_error(NULL, "unable to fetch syspath or device node.\n"); + errno = ENODEV; + goto error; + } + + *uinput_dev = new_device; + + return 0; + + error: + rc = -errno; + libevdev_uinput_destroy(new_device); + if (fd != -1 && close_fd_on_error) + close(fd); + return rc; +} + +LIBEVDEV_EXPORT void +libevdev_uinput_destroy(struct libevdev_uinput *uinput_dev) { + if (!uinput_dev) + return; + + if (uinput_dev->fd >= 0) { + (void) ioctl(uinput_dev->fd, UI_DEV_DESTROY, NULL); + if (uinput_dev->fd_is_managed) + close(uinput_dev->fd); + } + free(uinput_dev->syspath); + free(uinput_dev->devnode); + free(uinput_dev->name); + free(uinput_dev); +} + +LIBEVDEV_EXPORT const char * +libevdev_uinput_get_syspath(struct libevdev_uinput *uinput_dev) { + return uinput_dev->syspath; +} + +LIBEVDEV_EXPORT const char * +libevdev_uinput_get_devnode(struct libevdev_uinput *uinput_dev) { + return uinput_dev->devnode; +} + +LIBEVDEV_EXPORT int +libevdev_uinput_write_event(const struct libevdev_uinput *uinput_dev, + unsigned int type, + unsigned int code, + int value) { + struct input_event ev = { + .input_event_sec = 0, + .input_event_usec = 0, + .type = type, + .code = code, + .value = value + }; + int fd = libevdev_uinput_get_fd(uinput_dev); + int rc, max; + + if (type > EV_MAX) + return -EINVAL; + + max = libevdev_event_type_get_max(type); + if (max == -1 || code > (unsigned int) max) + return -EINVAL; + + rc = write(fd, &ev, sizeof(ev)); + + return rc < 0 ? -errno : 0; +} diff --git a/sysbridge/src/main/cpp/libevdev/libevdev-uinput.h b/sysbridge/src/main/cpp/libevdev/libevdev-uinput.h new file mode 100644 index 0000000000..2919788905 --- /dev/null +++ b/sysbridge/src/main/cpp/libevdev/libevdev-uinput.h @@ -0,0 +1,255 @@ +/* SPDX-License-Identifier: MIT */ +/* + * Copyright © 2013 Red Hat, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + */ + +#ifndef LIBEVDEV_UINPUT_H +#define LIBEVDEV_UINPUT_H + +#ifdef __cplusplus +extern "C" { +#endif + +struct libevdev_uinput; + +/** + * @defgroup uinput uinput device creation + * + * Creation of uinput devices based on existing libevdev devices. These functions + * help to create uinput devices that emulate libevdev devices. In the simplest + * form it serves to duplicate an existing device: + * + * @code + * int err; + * int fd, uifd; + * struct libevdev *dev; + * struct libevdev_uinput *uidev; + * + * fd = open("/dev/input/event0", O_RDONLY); + * if (fd < 0) + * return -errno; + * + * err = libevdev_new_from_fd(fd, &dev); + * if (err != 0) + * return err; + * + * uifd = open("/dev/uinput", O_RDWR); + * if (uifd < 0) + * return -errno; + * + * err = libevdev_uinput_create_from_device(dev, uifd, &uidev); + * if (err != 0) + * return err; + * + * // post a REL_X event + * err = libevdev_uinput_write_event(uidev, EV_REL, REL_X, -1); + * if (err != 0) + * return err; + * err = libevdev_uinput_write_event(uidev, EV_SYN, SYN_REPORT, 0); + * if (err != 0) + * return err; + * + * libevdev_uinput_destroy(uidev); + * libevdev_free(dev); + * close(uifd); + * close(fd); + * + * @endcode + * + * Alternatively, a device can be constructed from scratch: + * + * @code + * int err; + * struct libevdev *dev; + * struct libevdev_uinput *uidev; + * + * dev = libevdev_new(); + * libevdev_set_name(dev, "test device"); + * libevdev_enable_event_type(dev, EV_REL); + * libevdev_enable_event_code(dev, EV_REL, REL_X, NULL); + * libevdev_enable_event_code(dev, EV_REL, REL_Y, NULL); + * libevdev_enable_event_type(dev, EV_KEY); + * libevdev_enable_event_code(dev, EV_KEY, BTN_LEFT, NULL); + * libevdev_enable_event_code(dev, EV_KEY, BTN_MIDDLE, NULL); + * libevdev_enable_event_code(dev, EV_KEY, BTN_RIGHT, NULL); + * + * err = libevdev_uinput_create_from_device(dev, + * LIBEVDEV_UINPUT_OPEN_MANAGED, + * &uidev); + * if (err != 0) + * return err; + * + * // ... do something ... + * + * libevdev_uinput_destroy(uidev); + * + * @endcode + */ + +enum libevdev_uinput_open_mode { + /* intentionally -2 to avoid code like below from accidentally working: + fd = open("/dev/uinput", O_RDWR); // fails, fd is -1 + libevdev_uinput_create_from_device(dev, fd, &uidev); // may hide the error */ + LIBEVDEV_UINPUT_OPEN_MANAGED = -2 /**< let libevdev open and close @c /dev/uinput */ +}; + +/** + * @ingroup uinput + * + * Create a uinput device based on the given libevdev device. The uinput device + * will be an exact copy of the libevdev device, minus the bits that uinput doesn't + * allow to be set. + * + * If uinput_fd is @ref LIBEVDEV_UINPUT_OPEN_MANAGED, libevdev_uinput_create_from_device() + * will open @c /dev/uinput in read/write mode and manage the file descriptor. + * Otherwise, uinput_fd must be opened by the caller and opened with the + * appropriate permissions. + * + * The device's lifetime is tied to the uinput file descriptor, closing it will + * destroy the uinput device. You should call libevdev_uinput_destroy() before + * closing the file descriptor to free allocated resources. + * A file descriptor can only create one uinput device at a time; the second device + * will fail with -EINVAL. + * + * You don't need to keep the file descriptor variable around, + * libevdev_uinput_get_fd() will return it when needed. + * + * @note Due to limitations in the uinput kernel module, REP_DELAY and + * REP_PERIOD will default to the kernel defaults, not to the ones set in the + * source device. + * + * @note On FreeBSD, if the UI_GET_SYSNAME ioctl() fails, there is no other way + * to get a device, and the function call will fail. + * + * @param dev The device to duplicate + * @param uinput_fd @ref LIBEVDEV_UINPUT_OPEN_MANAGED or a file descriptor to @c /dev/uinput, + * @param[out] uinput_dev The newly created libevdev device. + * + * @return 0 on success or a negative errno on failure. On failure, the value of + * uinput_dev is unmodified. + * + * @see libevdev_uinput_destroy + */ +int libevdev_uinput_create_from_device(const struct libevdev *dev, + int uinput_fd, + struct libevdev_uinput **uinput_dev); + +/** + * @ingroup uinput + * + * Destroy a previously created uinput device and free associated memory. + * + * If the device was opened with @ref LIBEVDEV_UINPUT_OPEN_MANAGED, + * libevdev_uinput_destroy() also closes the file descriptor. Otherwise, the + * fd is left as-is and must be closed by the caller. + * + * @param uinput_dev A previously created uinput device. + */ +void libevdev_uinput_destroy(struct libevdev_uinput *uinput_dev); + +/** + * @ingroup uinput + * + * Return the file descriptor used to create this uinput device. This is the + * fd pointing to /dev/uinput. This file descriptor may be used to write + * events that are emitted by the uinput device. + * Closing this file descriptor will destroy the uinput device, you should + * call libevdev_uinput_destroy() first to free allocated resources. + * + * @param uinput_dev A previously created uinput device. + * + * @return The file descriptor used to create this device + */ +int libevdev_uinput_get_fd(const struct libevdev_uinput *uinput_dev); + +/** + * @ingroup uinput + * + * Return the syspath representing this uinput device. If the UI_GET_SYSNAME + * ioctl is not available, libevdev makes an educated guess. + * The UI_GET_SYSNAME ioctl is available since Linux 3.15. + * + * The syspath returned is the one of the input node itself + * (e.g. /sys/devices/virtual/input/input123), not the syspath of the device + * node returned with libevdev_uinput_get_devnode(). + * + * @note This function may return NULL if UI_GET_SYSNAME is not available. + * In that case, libevdev uses ctime and the device name to guess devices. + * To avoid false positives, wait at least 1.5s between creating devices that + * have the same name. + * + * @note FreeBSD does not have sysfs, on FreeBSD this function always returns + * NULL. + * + * @param uinput_dev A previously created uinput device. + * @return The syspath for this device, including the preceding /sys + * + * @see libevdev_uinput_get_devnode + */ +const char *libevdev_uinput_get_syspath(struct libevdev_uinput *uinput_dev); + +/** + * @ingroup uinput + * + * Return the device node representing this uinput device. + * + * This relies on libevdev_uinput_get_syspath() to provide a valid syspath. + * See libevdev_uinput_get_syspath() for more details. + * + * @note This function may return NULL. libevdev may have to guess the + * syspath and the device node. See libevdev_uinput_get_syspath() for details. + * + * @note On FreeBSD, this function can not return NULL. libudev uses the + * UI_GET_SYSNAME ioctl to get the device node on this platform and if that + * fails, the call to libevdev_uinput_create_from_device() fails. + * + * @param uinput_dev A previously created uinput device. + * @return The device node for this device, in the form of /dev/input/eventN + * + * @see libevdev_uinput_get_syspath + */ +const char *libevdev_uinput_get_devnode(struct libevdev_uinput *uinput_dev); + +/** + * @ingroup uinput + * + * Post an event through the uinput device. It is the caller's responsibility + * that any event sequence is terminated with an EV_SYN/SYN_REPORT/0 event. + * Otherwise, listeners on the device node will not see the events until the + * next EV_SYN event is posted. + * + * @param uinput_dev A previously created uinput device. + * @param type Event type (EV_ABS, EV_REL, etc.) + * @param code Event code (ABS_X, REL_Y, etc.) + * @param value The event value + * @return 0 on success or a negative errno on error + */ +int libevdev_uinput_write_event(const struct libevdev_uinput *uinput_dev, + unsigned int type, + unsigned int code, + int value); + +#ifdef __cplusplus +} +#endif + +#endif /* LIBEVDEV_UINPUT_H */ diff --git a/sysbridge/src/main/cpp/libevdev/libevdev-util.h b/sysbridge/src/main/cpp/libevdev/libevdev-util.h new file mode 100644 index 0000000000..380636d2b8 --- /dev/null +++ b/sysbridge/src/main/cpp/libevdev/libevdev-util.h @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +/* + * Copyright © 2013 Red Hat, Inc. + */ + +#ifndef _UTIL_H_ +#define _UTIL_H_ + +#include +#include + +#define LONG_BITS (sizeof(long) * 8) +#define NLONGS(x) (((x) + LONG_BITS - 1) / LONG_BITS) +#define ARRAY_LENGTH(a) (sizeof(a) / (sizeof((a)[0]))) +#define unlikely(x) (__builtin_expect(!!(x),0)) + +#undef min +#undef max +#ifdef __GNUC__ +#define min(a, b) \ + ({ __typeof__ (a) _a = (a); \ + __typeof__ (b) _b = (b); \ + _a > _b ? _b : _a; \ + }) +#define max(a, b) \ + ({ __typeof__ (a) _a = (a); \ + __typeof__ (b) _b = (b); \ + _a > _b ? _a : _b; \ + }) +#else +#define min(a,b) ((a) > (b) ? (b) : (a)) +#define max(a,b) ((a) > (b) ? (a) : (b)) +#endif + +static inline bool +startswith(const char *str, size_t len, const char *prefix, size_t plen) { + return len >= plen && !strncmp(str, prefix, plen); +} + +static inline int +bit_is_set(const unsigned long *array, int bit) { + return !!(array[bit / LONG_BITS] & (1LL << (bit % LONG_BITS))); +} + +static inline void +set_bit(unsigned long *array, int bit) { + array[bit / LONG_BITS] |= (1LL << (bit % LONG_BITS)); +} + +static inline void +clear_bit(unsigned long *array, int bit) { + array[bit / LONG_BITS] &= ~(1LL << (bit % LONG_BITS)); +} + +static inline void +set_bit_state(unsigned long *array, int bit, int state) { + if (state) + set_bit(array, bit); + else + clear_bit(array, bit); +} + +#endif diff --git a/sysbridge/src/main/cpp/libevdev/libevdev.c b/sysbridge/src/main/cpp/libevdev/libevdev.c new file mode 100644 index 0000000000..78f8f3cfae --- /dev/null +++ b/sysbridge/src/main/cpp/libevdev/libevdev.c @@ -0,0 +1,1848 @@ +// SPDX-License-Identifier: MIT +/* + * Copyright © 2013 Red Hat, Inc. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "libevdev-int.h" +#include "libevdev-util.h" +#include "libevdev.h" + +#include "event-names.h" + +#define MAXEVENTS 64 + +enum event_filter_status { + EVENT_FILTER_NONE, /**< Event untouched by filters */ + EVENT_FILTER_MODIFIED, /**< Event was modified */ + EVENT_FILTER_DISCARD, /**< Discard current event */ +}; + +/* Keeps a record of touches during SYN_DROPPED */ +enum touch_state { + TOUCH_OFF, + TOUCH_STARTED, /* Started during SYN_DROPPED */ + TOUCH_STOPPED, /* Stopped during SYN_DROPPED */ + TOUCH_ONGOING, /* Existed before, still have same tracking ID */ + TOUCH_CHANGED, /* Existed before but have new tracking ID now, so + stopped + started in that slot */ +}; + +struct slot_change_state { + enum touch_state state; + unsigned long axes[NLONGS(ABS_CNT)]; /* bitmask for updated axes */ +}; + +static int sync_mt_state(struct libevdev *dev, + struct slot_change_state changes_out[dev->num_slots]); + +static int +update_key_state(struct libevdev *dev, const struct input_event *e); + +static inline int * +slot_value(const struct libevdev *dev, int slot, int axis) { + if (unlikely(slot > dev->num_slots)) { + log_bug(dev, "Slot %d exceeds number of slots (%d)\n", slot, dev->num_slots); + slot = 0; + } + if (unlikely(axis < ABS_MT_MIN || axis > ABS_MT_MAX)) { + log_bug(dev, "MT axis %d is outside the valid range [%d,%d]\n", + axis, ABS_MT_MIN, ABS_MT_MAX); + axis = ABS_MT_MIN; + } + return &dev->mt_slot_vals[slot * ABS_MT_CNT + axis - ABS_MT_MIN]; +} + +static int +init_event_queue(struct libevdev *dev) { + const int MIN_QUEUE_SIZE = 256; + int nevents = 1; /* terminating SYN_REPORT */ + int nslots; + unsigned int type, code; + + /* count the number of axes, keys, etc. to get a better idea at how + many events per EV_SYN we could possibly get. That's the max we + may get during SYN_DROPPED too. Use double that, just so we have + room for events while syncing a device. + */ + for (type = EV_KEY; type < EV_MAX; type++) { + int max = libevdev_event_type_get_max(type); + for (code = 0; max > 0 && code < (unsigned int) max; code++) { + if (libevdev_has_event_code(dev, type, code)) + nevents++; + } + } + + nslots = libevdev_get_num_slots(dev); + if (nslots > 1) { + int num_mt_axes = 0; + + for (code = ABS_MT_SLOT; code <= ABS_MAX; code++) { + if (libevdev_has_event_code(dev, EV_ABS, code)) + num_mt_axes++; + } + + /* We already counted the first slot in the initial count */ + nevents += num_mt_axes * (nslots - 1); + } + + return queue_alloc(dev, max(MIN_QUEUE_SIZE, nevents * 2)); +} + +static void +libevdev_dflt_log_func(enum libevdev_log_priority priority, + void *data, + const char *file, int line, const char *func, + const char *format, va_list args) { + const char *prefix; + switch (priority) { + case LIBEVDEV_LOG_ERROR: + prefix = "libevdev error"; + break; + case LIBEVDEV_LOG_INFO: + prefix = "libevdev info"; + break; + case LIBEVDEV_LOG_DEBUG: + prefix = "libevdev debug"; + break; + default: + prefix = "libevdev INVALID LOG PRIORITY"; + break; + } + /* default logging format: + libevev error in libevdev_some_func: blah blah + libevev info in libevdev_some_func: blah blah + libevev debug in file.c:123:libevdev_some_func: blah blah + */ + + fprintf(stderr, "%s in ", prefix); + if (priority == LIBEVDEV_LOG_DEBUG) + fprintf(stderr, "%s:%d:", file, line); + fprintf(stderr, "%s: ", func); + vfprintf(stderr, format, args); +} + +static void +fix_invalid_absinfo(const struct libevdev *dev, + int axis, + struct input_absinfo *abs_info) { + /* + * The reported absinfo for ABS_MT_TRACKING_ID is sometimes + * uninitialized for certain mtk-soc, due to init code mangling + * in the vendor kernel. + */ + if (axis == ABS_MT_TRACKING_ID && + abs_info->maximum == abs_info->minimum) { + abs_info->minimum = -1; + abs_info->maximum = 0xFFFF; + log_bug(dev, + "Device \"%s\" has invalid ABS_MT_TRACKING_ID range", + dev->name); + } +} + +/* + * Global logging settings. + */ +static struct logdata log_data = { + .priority = LIBEVDEV_LOG_INFO, + .global_handler = libevdev_dflt_log_func, + .userdata = NULL, +}; + +void +_libevdev_log_msg(const struct libevdev *dev, + enum libevdev_log_priority priority, + const char *file, int line, const char *func, + const char *format, ...) { + va_list args; + + if (dev && dev->log.device_handler) { + /** + * if both global handler and device handler are set + * we've set up the handlers wrong. And that means we'll + * likely get the printf args wrong and cause all sorts of + * mayhem. Seppuku is called for. + */ + if (unlikely(dev->log.global_handler)) + abort(); + + if (priority > dev->log.priority) + return; + } else if (!log_data.global_handler || priority > log_data.priority) { + return; + } else if (unlikely(log_data.device_handler)) { + abort(); /* Seppuku, see above */ + } + + va_start(args, format); + if (dev && dev->log.device_handler) + dev->log.device_handler(dev, priority, dev->log.userdata, file, line, func, format, args); + else + log_data.global_handler(priority, log_data.userdata, file, line, func, format, args); + va_end(args); +} + +static void +libevdev_reset(struct libevdev *dev) { + enum libevdev_log_priority pri = dev->log.priority; + libevdev_device_log_func_t handler = dev->log.device_handler; + + free(dev->name); + free(dev->phys); + free(dev->uniq); + free(dev->mt_slot_vals); + memset(dev, 0, sizeof(*dev)); + dev->fd = -1; + dev->initialized = false; + dev->num_slots = -1; + dev->current_slot = -1; + dev->grabbed = LIBEVDEV_UNGRAB; + dev->sync_state = SYNC_NONE; + dev->log.priority = pri; + dev->log.device_handler = handler; + libevdev_enable_event_type(dev, EV_SYN); +} + +LIBEVDEV_EXPORT struct libevdev * +libevdev_new(void) { + struct libevdev *dev; + + dev = calloc(1, sizeof(*dev)); + if (!dev) + return NULL; + + libevdev_reset(dev); + + return dev; +} + +LIBEVDEV_EXPORT int +libevdev_new_from_fd(int fd, struct libevdev **dev) { + struct libevdev *d; + int rc; + + d = libevdev_new(); + if (!d) + return -ENOMEM; + + rc = libevdev_set_fd(d, fd); + if (rc < 0) + libevdev_free(d); + else + *dev = d; + return rc; +} + +LIBEVDEV_EXPORT void +libevdev_free(struct libevdev *dev) { + if (!dev) + return; + + queue_free(dev); + libevdev_reset(dev); + free(dev); +} + +LIBEVDEV_EXPORT void +libevdev_set_log_function(libevdev_log_func_t logfunc, void *data) { + log_data.global_handler = logfunc; + log_data.userdata = data; +} + +LIBEVDEV_EXPORT void +libevdev_set_log_priority(enum libevdev_log_priority priority) { + if (priority > LIBEVDEV_LOG_DEBUG) + priority = LIBEVDEV_LOG_DEBUG; + log_data.priority = priority; +} + +LIBEVDEV_EXPORT enum libevdev_log_priority +libevdev_get_log_priority(void) { + return log_data.priority; +} + +LIBEVDEV_EXPORT void +libevdev_set_device_log_function(struct libevdev *dev, + libevdev_device_log_func_t logfunc, + enum libevdev_log_priority priority, + void *data) { + if (!dev) { + log_bug(NULL, "device must not be NULL\n"); + return; + } + + dev->log.priority = priority; + dev->log.device_handler = logfunc; + dev->log.userdata = data; +} + +enum libevdev_log_priority +_libevdev_log_priority(const struct libevdev *dev) { + if (dev && dev->log.device_handler) + return dev->log.priority; + return libevdev_get_log_priority(); +} + +LIBEVDEV_EXPORT int +libevdev_change_fd(struct libevdev *dev, int fd) { + if (!dev->initialized) { + log_bug(dev, "device not initialized. call libevdev_set_fd() first\n"); + return -1; + } + dev->fd = fd; + dev->grabbed = LIBEVDEV_UNGRAB; + return 0; +} + +static void +reset_tracking_ids(struct libevdev *dev) { + if (dev->num_slots == -1 || + !libevdev_has_event_code(dev, EV_ABS, ABS_MT_TRACKING_ID)) + return; + + for (int slot = 0; slot < dev->num_slots; slot++) + libevdev_set_slot_value(dev, slot, ABS_MT_TRACKING_ID, -1); +} + +static inline void +free_slots(struct libevdev *dev) { + dev->num_slots = -1; + free(dev->mt_slot_vals); + dev->mt_slot_vals = NULL; +} + +static int +init_slots(struct libevdev *dev) { + const struct input_absinfo *abs_info; + int rc = 0; + + free(dev->mt_slot_vals); + dev->mt_slot_vals = NULL; + + /* devices with ABS_RESERVED aren't MT devices, + see the documentation for multitouch-related + functions for more details */ + if (libevdev_has_event_code(dev, EV_ABS, ABS_RESERVED) || + !libevdev_has_event_code(dev, EV_ABS, ABS_MT_SLOT)) { + if (dev->num_slots != -1) { + free_slots(dev); + } + return rc; + } + + abs_info = libevdev_get_abs_info(dev, ABS_MT_SLOT); + + free_slots(dev); + dev->num_slots = abs_info->maximum + 1; + dev->mt_slot_vals = calloc(dev->num_slots * ABS_MT_CNT, sizeof(int)); + if (!dev->mt_slot_vals) { + rc = -ENOMEM; + goto out; + } + dev->current_slot = abs_info->value; + + reset_tracking_ids(dev); + out: + return rc; +} + +LIBEVDEV_EXPORT int +libevdev_set_fd(struct libevdev *dev, int fd) { + int rc; + int i; + char buf[256]; + + if (dev->initialized) { + log_bug(dev, "device already initialized.\n"); + return -EBADF; + } + + if (fd < 0) { + return -EBADF; + } + + libevdev_reset(dev); + + rc = ioctl(fd, EVIOCGBIT(0, sizeof(dev->bits)), dev->bits); + if (rc < 0) + goto out; + + memset(buf, 0, sizeof(buf)); + rc = ioctl(fd, EVIOCGNAME(sizeof(buf) - 1), buf); + if (rc < 0) + goto out; + + free(dev->name); + dev->name = strdup(buf); + if (!dev->name) { + errno = ENOMEM; + goto out; + } + + free(dev->phys); + dev->phys = NULL; + memset(buf, 0, sizeof(buf)); + rc = ioctl(fd, EVIOCGPHYS(sizeof(buf) - 1), buf); + if (rc < 0) { + /* uinput has no phys */ + if (errno != ENOENT) + goto out; + } else { + dev->phys = strdup(buf); + if (!dev->phys) { + errno = ENOMEM; + goto out; + } + } + + free(dev->uniq); + dev->uniq = NULL; + memset(buf, 0, sizeof(buf)); + rc = ioctl(fd, EVIOCGUNIQ(sizeof(buf) - 1), buf); + if (rc < 0) { + if (errno != ENOENT) + goto out; + } else { + dev->uniq = strdup(buf); + if (!dev->uniq) { + errno = ENOMEM; + goto out; + } + } + + rc = ioctl(fd, EVIOCGID, &dev->ids); + if (rc < 0) + goto out; + + rc = ioctl(fd, EVIOCGVERSION, &dev->driver_version); + if (rc < 0) + goto out; + + /* Built on a kernel with props, running against a kernel without property + support. This should not be a fatal case, we'll be missing properties but other + than that everything is as expected. + */ + rc = ioctl(fd, EVIOCGPROP(sizeof(dev->props)), dev->props); + if (rc < 0 && errno != EINVAL) + goto out; + + rc = ioctl(fd, EVIOCGBIT(EV_REL, sizeof(dev->rel_bits)), dev->rel_bits); + if (rc < 0) + goto out; + + rc = ioctl(fd, EVIOCGBIT(EV_ABS, sizeof(dev->abs_bits)), dev->abs_bits); + if (rc < 0) + goto out; + + rc = ioctl(fd, EVIOCGBIT(EV_LED, sizeof(dev->led_bits)), dev->led_bits); + if (rc < 0) + goto out; + + rc = ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(dev->key_bits)), dev->key_bits); + if (rc < 0) + goto out; + + rc = ioctl(fd, EVIOCGBIT(EV_SW, sizeof(dev->sw_bits)), dev->sw_bits); + if (rc < 0) + goto out; + + rc = ioctl(fd, EVIOCGBIT(EV_MSC, sizeof(dev->msc_bits)), dev->msc_bits); + if (rc < 0) + goto out; + + rc = ioctl(fd, EVIOCGBIT(EV_FF, sizeof(dev->ff_bits)), dev->ff_bits); + if (rc < 0) + goto out; + + rc = ioctl(fd, EVIOCGBIT(EV_SND, sizeof(dev->snd_bits)), dev->snd_bits); + if (rc < 0) + goto out; + + rc = ioctl(fd, EVIOCGKEY(sizeof(dev->key_values)), dev->key_values); + if (rc < 0) + goto out; + + rc = ioctl(fd, EVIOCGLED(sizeof(dev->led_values)), dev->led_values); + if (rc < 0) + goto out; + + rc = ioctl(fd, EVIOCGSW(sizeof(dev->sw_values)), dev->sw_values); + if (rc < 0) + goto out; + + /* rep is a special case, always set it to 1 for both values if EV_REP is set */ + if (bit_is_set(dev->bits, EV_REP)) { + for (i = 0; i < REP_CNT; i++) + set_bit(dev->rep_bits, i); + rc = ioctl(fd, EVIOCGREP, dev->rep_values); + if (rc < 0) + goto out; + } + + for (i = ABS_X; i <= ABS_MAX; i++) { + if (bit_is_set(dev->abs_bits, i)) { + struct input_absinfo abs_info; + rc = ioctl(fd, EVIOCGABS(i), &abs_info); + if (rc < 0) + goto out; + + fix_invalid_absinfo(dev, i, &abs_info); + + dev->abs_info[i] = abs_info; + } + } + + dev->fd = fd; + + rc = init_slots(dev); + if (rc != 0) + goto out; + + if (dev->num_slots != -1) { + struct slot_change_state unused[dev->num_slots]; + sync_mt_state(dev, unused); + } + + rc = init_event_queue(dev); + if (rc < 0) { + dev->fd = -1; + return -rc; + } + + /* not copying key state because we won't know when we'll start to + * use this fd and key's are likely to change state by then. + * Same with the valuators, really, but they may not change. + */ + + dev->initialized = true; + out: + if (rc) + libevdev_reset(dev); + return rc ? -errno : 0; +} + +LIBEVDEV_EXPORT int +libevdev_get_fd(const struct libevdev *dev) { + return dev->fd; +} + +static int +sync_key_state(struct libevdev *dev) { + int rc; + int i; + unsigned long keystate[NLONGS(KEY_CNT)] = {0}; + + rc = ioctl(dev->fd, EVIOCGKEY(sizeof(keystate)), keystate); + if (rc < 0) + goto out; + + for (i = 0; i < KEY_CNT; i++) { + int old, new; + old = bit_is_set(dev->key_values, i); + new = bit_is_set(keystate, i); + if (old ^ new) + queue_push_event(dev, EV_KEY, i, new ? 1 : 0); + } + + memcpy(dev->key_values, keystate, rc); + + rc = 0; + out: + return rc ? -errno : 0; +} + +static int +sync_sw_state(struct libevdev *dev) { + int rc; + int i; + unsigned long swstate[NLONGS(SW_CNT)] = {0}; + + rc = ioctl(dev->fd, EVIOCGSW(sizeof(swstate)), swstate); + if (rc < 0) + goto out; + + for (i = 0; i < SW_CNT; i++) { + int old, new; + old = bit_is_set(dev->sw_values, i); + new = bit_is_set(swstate, i); + if (old ^ new) + queue_push_event(dev, EV_SW, i, new ? 1 : 0); + } + + memcpy(dev->sw_values, swstate, rc); + + rc = 0; + out: + return rc ? -errno : 0; +} + +static int +sync_led_state(struct libevdev *dev) { + int rc; + int i; + unsigned long ledstate[NLONGS(LED_CNT)] = {0}; + + rc = ioctl(dev->fd, EVIOCGLED(sizeof(ledstate)), ledstate); + if (rc < 0) + goto out; + + for (i = 0; i < LED_CNT; i++) { + int old, new; + old = bit_is_set(dev->led_values, i); + new = bit_is_set(ledstate, i); + if (old ^ new) { + queue_push_event(dev, EV_LED, i, new ? 1 : 0); + } + } + + memcpy(dev->led_values, ledstate, rc); + + rc = 0; + out: + return rc ? -errno : 0; +} + +static int +sync_abs_state(struct libevdev *dev) { + int rc; + int i; + + for (i = ABS_X; i < ABS_CNT; i++) { + struct input_absinfo abs_info; + + if (i >= ABS_MT_MIN && i <= ABS_MT_MAX) + continue; + + if (!bit_is_set(dev->abs_bits, i)) + continue; + + rc = ioctl(dev->fd, EVIOCGABS(i), &abs_info); + if (rc < 0) + goto out; + + if (dev->abs_info[i].value != abs_info.value) { + queue_push_event(dev, EV_ABS, i, abs_info.value); + dev->abs_info[i].value = abs_info.value; + } + } + + rc = 0; + out: + return rc ? -errno : 0; +} + +static int +sync_mt_state(struct libevdev *dev, + struct slot_change_state changes_out[dev->num_slots]) { +#define MAX_SLOTS 256 + int rc = 0; + struct slot_change_state changes[MAX_SLOTS] = {0}; + unsigned int nslots = min(MAX_SLOTS, dev->num_slots); + + for (int axis = ABS_MT_MIN; axis <= ABS_MT_MAX; axis++) { + /* EVIOCGMTSLOTS required format */ + struct mt_sync_state { + uint32_t code; + int32_t val[MAX_SLOTS]; + } mt_state; + + if (axis == ABS_MT_SLOT || + !libevdev_has_event_code(dev, EV_ABS, axis)) + continue; + + mt_state.code = axis; + rc = ioctl(dev->fd, EVIOCGMTSLOTS(sizeof(mt_state)), &mt_state); + if (rc < 0) + goto out; + + for (unsigned int slot = 0; slot < nslots; slot++) { + int val_before = *slot_value(dev, slot, axis), + val_after = mt_state.val[slot]; + + if (axis == ABS_MT_TRACKING_ID) { + if (val_before == -1 && val_after != -1) { + changes[slot].state = TOUCH_STARTED; + } else if (val_before != -1 && val_after == -1) { + changes[slot].state = TOUCH_STOPPED; + } else if (val_before != -1 && val_after != -1 && + val_before == val_after) { + changes[slot].state = TOUCH_ONGOING; + } else if (val_before != -1 && val_after != -1 && + val_before != val_after) { + changes[slot].state = TOUCH_CHANGED; + } else { + changes[slot].state = TOUCH_OFF; + } + } + + if (val_before == val_after) + continue; + + *slot_value(dev, slot, axis) = val_after; + + set_bit(changes[slot].axes, axis); + /* note that this slot has updates */ + set_bit(changes[slot].axes, ABS_MT_SLOT); + } + } + + if (dev->num_slots > MAX_SLOTS) + memset(changes_out, 0, sizeof(*changes) * dev->num_slots); + + memcpy(changes_out, changes, sizeof(*changes) * nslots); + out: + return rc; +} + +static void +terminate_slots(struct libevdev *dev, + const struct slot_change_state changes[dev->num_slots], + int *last_reported_slot) { + const unsigned int map[] = {BTN_TOOL_FINGER, BTN_TOOL_DOUBLETAP, + BTN_TOOL_TRIPLETAP, BTN_TOOL_QUADTAP, + BTN_TOOL_QUINTTAP}; + bool touches_stopped = false; + int ntouches_before = 0, ntouches_after = 0; + + /* For BTN_TOOL_* emulation, we need to know how many touches we had + * before and how many we have left once we terminate all the ones + * that changed and all the ones that stopped. + */ + for (int slot = 0; slot < dev->num_slots; slot++) { + switch (changes[slot].state) { + case TOUCH_OFF: + break; + case TOUCH_CHANGED: + case TOUCH_STOPPED: + queue_push_event(dev, EV_ABS, ABS_MT_SLOT, slot); + queue_push_event(dev, EV_ABS, ABS_MT_TRACKING_ID, -1); + + *last_reported_slot = slot; + touches_stopped = true; + ntouches_before++; + break; + case TOUCH_ONGOING: + ntouches_before++; + ntouches_after++; + break; + case TOUCH_STARTED: + break; + } + } + + /* If any of the touches stopped, we need to split the sync state + into two frames - one with all the stopped touches, one with the + new touches starting (if any) */ + if (touches_stopped) { + /* Send through the required BTN_TOOL_ 0 and 1 events for + * the previous and current number of fingers. And update + * our own key state accordingly, so that during the second + * sync event frame sync_key_state() sets everything correctly + * for the *real* number of touches. + */ + if (ntouches_before > 0 && ntouches_before <= 5) { + struct input_event ev = { + .type = EV_KEY, + .code = map[ntouches_before - 1], + .value = 0, + }; + queue_push_event(dev, ev.type, ev.code, ev.value); + update_key_state(dev, &ev); + } + + if (ntouches_after > 0 && ntouches_after <= 5) { + struct input_event ev = { + .type = EV_KEY, + .code = map[ntouches_after - 1], + .value = 1, + }; + queue_push_event(dev, ev.type, ev.code, ev.value); + update_key_state(dev, &ev); + } + + queue_push_event(dev, EV_SYN, SYN_REPORT, 0); + } +} + +static int +push_mt_sync_events(struct libevdev *dev, + const struct slot_change_state changes[dev->num_slots], + int last_reported_slot) { + struct input_absinfo abs_info; + int rc; + + for (int slot = 0; slot < dev->num_slots; slot++) { + bool have_slot_event = false; + + if (!bit_is_set(changes[slot].axes, ABS_MT_SLOT)) + continue; + + for (int axis = ABS_MT_MIN; axis <= ABS_MT_MAX; axis++) { + if (axis == ABS_MT_SLOT || + !libevdev_has_event_code(dev, EV_ABS, axis)) + continue; + + if (bit_is_set(changes[slot].axes, axis)) { + /* We already sent the tracking id -1 in + * terminate_slots so don't do that again. There + * may be other axes like ABS_MT_TOOL_TYPE that + * need to be synced despite no touch being active */ + if (axis == ABS_MT_TRACKING_ID && + *slot_value(dev, slot, axis) == -1) + continue; + + if (!have_slot_event) { + queue_push_event(dev, EV_ABS, ABS_MT_SLOT, slot); + last_reported_slot = slot; + have_slot_event = true; + } + + queue_push_event(dev, EV_ABS, axis, + *slot_value(dev, slot, axis)); + } + } + } + + /* add one last slot event to make sure the client is on the same + slot as the kernel */ + + rc = ioctl(dev->fd, EVIOCGABS(ABS_MT_SLOT), &abs_info); + if (rc < 0) + goto out; + + dev->current_slot = abs_info.value; + + if (dev->current_slot != last_reported_slot) + queue_push_event(dev, EV_ABS, ABS_MT_SLOT, dev->current_slot); + + rc = 0; + out: + return rc ? -errno : 0; +} + +static int +read_more_events(struct libevdev *dev) { + int free_elem; + int len; + struct input_event *next; + + free_elem = queue_num_free_elements(dev); + if (free_elem <= 0) + return 0; + + next = queue_next_element(dev); + len = read(dev->fd, next, free_elem * sizeof(struct input_event)); + if (len < 0) + return -errno; + + if (len > 0 && len % sizeof(struct input_event) != 0) + return -EINVAL; + + if (len > 0) { + int nev = len / sizeof(struct input_event); + queue_set_num_elements(dev, queue_num_elements(dev) + nev); + } + + return 0; +} + +static inline void +drain_events(struct libevdev *dev) { + int rc; + size_t nelem; + int iterations = 0; + const int max_iterations = 8; /* EVDEV_BUF_PACKETS in + kernel/drivers/input/evedev.c */ + + queue_shift_multiple(dev, queue_num_elements(dev), NULL); + + do { + rc = read_more_events(dev); + if (rc == -EAGAIN) + return; + + if (rc < 0) { + log_error(dev, "Failed to drain events before sync.\n"); + return; + } + + nelem = queue_num_elements(dev); + queue_shift_multiple(dev, nelem, NULL); + } while (iterations++ < max_iterations && nelem >= queue_size(dev)); + + /* Our buffer should be roughly the same or bigger than the kernel + buffer in most cases, so we usually don't expect to recurse. If + we do, make sure we stop after max_iterations and proceed with + what we have. This could happen if events queue up faster than + we can drain them. + */ + if (iterations >= max_iterations) + log_info(dev, "Unable to drain events, buffer size mismatch.\n"); +} + +static int +sync_state(struct libevdev *dev) { + int rc = 0; + bool want_mt_sync = false; + int last_reported_slot = 0; + struct slot_change_state changes[dev->num_slots > 0 ? dev->num_slots : 1]; + + memset(changes, 0, sizeof(changes)); + + /* see section "Discarding events before synchronizing" in + * libevdev/libevdev.h */ + drain_events(dev); + + /* We generate one or two event frames during sync. + * The first one (if it exists) terminates all slots that have + * either terminated during SYN_DROPPED or changed their tracking + * ID. + * + * The second frame syncs everything up to the current state of the + * device - including re-starting those slots that have a changed + * tracking id. + */ + if (dev->num_slots > -1 && + libevdev_has_event_code(dev, EV_ABS, ABS_MT_SLOT)) { + want_mt_sync = true; + rc = sync_mt_state(dev, changes); + if (rc == 0) + terminate_slots(dev, changes, &last_reported_slot); + else + want_mt_sync = false; + } + + if (libevdev_has_event_type(dev, EV_KEY)) + rc = sync_key_state(dev); + if (libevdev_has_event_type(dev, EV_LED)) + rc = sync_led_state(dev); + if (libevdev_has_event_type(dev, EV_SW)) + rc = sync_sw_state(dev); + if (rc == 0 && libevdev_has_event_type(dev, EV_ABS)) + rc = sync_abs_state(dev); + if (rc == 0 && want_mt_sync) + push_mt_sync_events(dev, changes, last_reported_slot); + + dev->queue_nsync = queue_num_elements(dev); + + if (dev->queue_nsync > 0) { + queue_push_event(dev, EV_SYN, SYN_REPORT, 0); + dev->queue_nsync++; + } + + return rc; +} + +static int +update_key_state(struct libevdev *dev, const struct input_event *e) { + if (!libevdev_has_event_type(dev, EV_KEY)) + return 1; + + if (e->code > KEY_MAX) + return 1; + + set_bit_state(dev->key_values, e->code, e->value != 0); + + return 0; +} + +static int +update_mt_state(struct libevdev *dev, const struct input_event *e) { + if (e->code == ABS_MT_SLOT && dev->num_slots > -1) { + int i; + dev->current_slot = e->value; + /* sync abs_info with the current slot values */ + for (i = ABS_MT_SLOT + 1; i <= ABS_MT_MAX; i++) { + if (libevdev_has_event_code(dev, EV_ABS, i)) + dev->abs_info[i].value = *slot_value(dev, dev->current_slot, i); + } + + return 0; + } + + if (dev->current_slot == -1) + return 1; + + *slot_value(dev, dev->current_slot, e->code) = e->value; + + return 0; +} + +static int +update_abs_state(struct libevdev *dev, const struct input_event *e) { + if (!libevdev_has_event_type(dev, EV_ABS)) + return 1; + + if (e->code > ABS_MAX) + return 1; + + if (e->code >= ABS_MT_MIN && e->code <= ABS_MT_MAX) + update_mt_state(dev, e); + + dev->abs_info[e->code].value = e->value; + + return 0; +} + +static int +update_led_state(struct libevdev *dev, const struct input_event *e) { + if (!libevdev_has_event_type(dev, EV_LED)) + return 1; + + if (e->code > LED_MAX) + return 1; + + set_bit_state(dev->led_values, e->code, e->value != 0); + + return 0; +} + +static int +update_sw_state(struct libevdev *dev, const struct input_event *e) { + if (!libevdev_has_event_type(dev, EV_SW)) + return 1; + + if (e->code > SW_MAX) + return 1; + + set_bit_state(dev->sw_values, e->code, e->value != 0); + + return 0; +} + +static int +update_state(struct libevdev *dev, const struct input_event *e) { + int rc = 0; + + switch (e->type) { + case EV_SYN: + case EV_REL: + break; + case EV_KEY: + rc = update_key_state(dev, e); + break; + case EV_ABS: + rc = update_abs_state(dev, e); + break; + case EV_LED: + rc = update_led_state(dev, e); + break; + case EV_SW: + rc = update_sw_state(dev, e); + break; + } + + dev->last_event_time.tv_sec = e->input_event_sec; + dev->last_event_time.tv_usec = e->input_event_usec; + + return rc; +} + +/** + * Sanitize/modify events where needed. + */ +static inline enum event_filter_status +sanitize_event(const struct libevdev *dev, + struct input_event *ev, + enum SyncState sync_state) { + if (!libevdev_has_event_code(dev, ev->type, ev->code)) + return EVENT_FILTER_DISCARD; + + if (unlikely(dev->num_slots > -1 && + libevdev_event_is_code(ev, EV_ABS, ABS_MT_SLOT) && + (ev->value < 0 || ev->value >= dev->num_slots))) { + log_bug(dev, "Device \"%s\" received an invalid slot index %d." + "Capping to announced max slot number %d.\n", + dev->name, ev->value, dev->num_slots - 1); + ev->value = dev->num_slots - 1; + return EVENT_FILTER_MODIFIED; + + /* Drop any invalid tracking IDs, they are only supposed to go from + N to -1 or from -1 to N. Never from -1 to -1, or N to M. Very + unlikely to ever happen from a real device. + */ + } + + if (unlikely(sync_state == SYNC_NONE && + dev->num_slots > -1 && + libevdev_event_is_code(ev, EV_ABS, ABS_MT_TRACKING_ID) && + ((ev->value == -1 && + *slot_value(dev, dev->current_slot, ABS_MT_TRACKING_ID) == -1) || + (ev->value != -1 && + *slot_value(dev, dev->current_slot, ABS_MT_TRACKING_ID) != -1)))) { + log_bug(dev, "Device \"%s\" received a double tracking ID %d in slot %d.\n", + dev->name, ev->value, dev->current_slot); + return EVENT_FILTER_DISCARD; + } + + return EVENT_FILTER_NONE; +} + +LIBEVDEV_EXPORT int +libevdev_next_event(struct libevdev *dev, unsigned int flags, struct input_event *ev) { + int rc = LIBEVDEV_READ_STATUS_SUCCESS; + enum event_filter_status filter_status; + const unsigned int valid_flags = LIBEVDEV_READ_FLAG_NORMAL | + LIBEVDEV_READ_FLAG_SYNC | + LIBEVDEV_READ_FLAG_FORCE_SYNC | + LIBEVDEV_READ_FLAG_BLOCKING; + + if (!dev->initialized) { + log_bug(dev, "device not initialized. call libevdev_set_fd() first\n"); + return -EBADF; + } + + if (dev->fd < 0) + return -EBADF; + + if ((flags & valid_flags) == 0) { + log_bug(dev, "invalid flags %#x.\n", flags); + return -EINVAL; + } + + if (flags & LIBEVDEV_READ_FLAG_SYNC) { + if (dev->sync_state == SYNC_NEEDED) { + rc = sync_state(dev); + if (rc != 0) + return rc; + dev->sync_state = SYNC_IN_PROGRESS; + } + + if (dev->queue_nsync == 0) { + dev->sync_state = SYNC_NONE; + return -EAGAIN; + } + + } else if (dev->sync_state != SYNC_NONE) { + struct input_event e; + + /* call update_state for all events here, otherwise the library has the wrong view + of the device too */ + while (queue_shift(dev, &e) == 0) { + dev->queue_nsync--; + if (sanitize_event(dev, &e, dev->sync_state) != EVENT_FILTER_DISCARD) + update_state(dev, &e); + } + + dev->sync_state = SYNC_NONE; + } + + /* Always read in some more events. Best case this smoothes over a potential SYN_DROPPED, + worst case we don't read fast enough and end up with SYN_DROPPED anyway. + + Except if the fd is in blocking mode and we still have events from the last read, don't + read in any more. + */ + do { + if (queue_num_elements(dev) == 0) { + rc = read_more_events(dev); + if (rc < 0 && rc != -EAGAIN) + goto out; + } + + if (flags & LIBEVDEV_READ_FLAG_FORCE_SYNC) { + dev->sync_state = SYNC_NEEDED; + rc = LIBEVDEV_READ_STATUS_SYNC; + goto out; + } + + if (queue_shift(dev, ev) != 0) + return -EAGAIN; + + filter_status = sanitize_event(dev, ev, dev->sync_state); + if (filter_status != EVENT_FILTER_DISCARD) + update_state(dev, ev); + + /* if we disabled a code, get the next event instead */ + } while (filter_status == EVENT_FILTER_DISCARD || + !libevdev_has_event_code(dev, ev->type, ev->code)); + + rc = LIBEVDEV_READ_STATUS_SUCCESS; + if (ev->type == EV_SYN && ev->code == SYN_DROPPED) { + dev->sync_state = SYNC_NEEDED; + rc = LIBEVDEV_READ_STATUS_SYNC; + } + + if (flags & LIBEVDEV_READ_FLAG_SYNC && dev->queue_nsync > 0) { + dev->queue_nsync--; + rc = LIBEVDEV_READ_STATUS_SYNC; + if (dev->queue_nsync == 0) + dev->sync_state = SYNC_NONE; + } + + out: + return rc; +} + +LIBEVDEV_EXPORT int +libevdev_has_event_pending(struct libevdev *dev) { + struct pollfd fds = {dev->fd, POLLIN, 0}; + int rc; + + if (!dev->initialized) { + log_bug(dev, "device not initialized. call libevdev_set_fd() first\n"); + return -EBADF; + } + + if (dev->fd < 0) + return -EBADF; + + if (queue_num_elements(dev) != 0) + return 1; + + rc = poll(&fds, 1, 0); + return (rc >= 0) ? rc : -errno; +} + +LIBEVDEV_EXPORT const char * +libevdev_get_name(const struct libevdev *dev) { + return dev->name ? dev->name : ""; +} + +LIBEVDEV_EXPORT const char * +libevdev_get_phys(const struct libevdev *dev) { + return dev->phys; +} + +LIBEVDEV_EXPORT const char * +libevdev_get_uniq(const struct libevdev *dev) { + return dev->uniq; +} + +#define STRING_SETTER(field) \ +LIBEVDEV_EXPORT void libevdev_set_##field(struct libevdev *dev, const char *field) \ +{ \ + if (field == NULL) \ + return; \ + free(dev->field); \ + dev->field = strdup(field); \ +} + +STRING_SETTER(name) + +STRING_SETTER(phys) + +STRING_SETTER(uniq) + +#define PRODUCT_GETTER(name) \ +LIBEVDEV_EXPORT int libevdev_get_id_##name(const struct libevdev *dev) \ +{ \ + return dev->ids.name; \ +} + +PRODUCT_GETTER(product) + +PRODUCT_GETTER(vendor) + +PRODUCT_GETTER(bustype) + +PRODUCT_GETTER(version) + +#define PRODUCT_SETTER(field) \ +LIBEVDEV_EXPORT void libevdev_set_id_##field(struct libevdev *dev, int field) \ +{ \ + dev->ids.field = field;\ +} + +PRODUCT_SETTER(product) + +PRODUCT_SETTER(vendor) + +PRODUCT_SETTER(bustype) + +PRODUCT_SETTER(version) + +LIBEVDEV_EXPORT int +libevdev_get_driver_version(const struct libevdev *dev) { + return dev->driver_version; +} + +LIBEVDEV_EXPORT int +libevdev_has_property(const struct libevdev *dev, unsigned int prop) { + return (prop <= INPUT_PROP_MAX) && bit_is_set(dev->props, prop); +} + +LIBEVDEV_EXPORT int +libevdev_enable_property(struct libevdev *dev, unsigned int prop) { + if (prop > INPUT_PROP_MAX) + return -1; + + set_bit(dev->props, prop); + return 0; +} + +LIBEVDEV_EXPORT int +libevdev_disable_property(struct libevdev *dev, unsigned int prop) { + if (prop > INPUT_PROP_MAX) + return -1; + + clear_bit(dev->props, prop); + return 0; +} + +LIBEVDEV_EXPORT int +libevdev_has_event_type(const struct libevdev *dev, unsigned int type) { + return type == EV_SYN || (type <= EV_MAX && bit_is_set(dev->bits, type)); +} + +LIBEVDEV_EXPORT int +libevdev_has_event_code(const struct libevdev *dev, unsigned int type, unsigned int code) { + const unsigned long *mask = NULL; + int max; + + if (!libevdev_has_event_type(dev, type)) + return 0; + + if (type == EV_SYN) + return 1; + + max = type_to_mask_const(dev, type, &mask); + + if (max == -1 || code > (unsigned int) max) + return 0; + + return bit_is_set(mask, code); +} + +LIBEVDEV_EXPORT int +libevdev_get_event_value(const struct libevdev *dev, unsigned int type, unsigned int code) { + int value = 0; + + if (!libevdev_has_event_type(dev, type) || !libevdev_has_event_code(dev, type, code)) + return 0; + + switch (type) { + case EV_ABS: + value = dev->abs_info[code].value; + break; + case EV_KEY: + value = bit_is_set(dev->key_values, code); + break; + case EV_LED: + value = bit_is_set(dev->led_values, code); + break; + case EV_SW: + value = bit_is_set(dev->sw_values, code); + break; + case EV_REP: + switch (code) { + case REP_DELAY: + libevdev_get_repeat(dev, &value, NULL); + break; + case REP_PERIOD: + libevdev_get_repeat(dev, NULL, &value); + break; + default: + value = 0; + break; + } + break; + default: + value = 0; + break; + } + + return value; +} + +LIBEVDEV_EXPORT int +libevdev_set_event_value(struct libevdev *dev, unsigned int type, unsigned int code, int value) { + int rc = 0; + struct input_event e; + + if (!libevdev_has_event_type(dev, type) || !libevdev_has_event_code(dev, type, code)) + return -1; + + e.type = type; + e.code = code; + e.value = value; + + if (sanitize_event(dev, &e, SYNC_NONE) != EVENT_FILTER_NONE) + return -1; + + switch (type) { + case EV_ABS: + rc = update_abs_state(dev, &e); + break; + case EV_KEY: + rc = update_key_state(dev, &e); + break; + case EV_LED: + rc = update_led_state(dev, &e); + break; + case EV_SW: + rc = update_sw_state(dev, &e); + break; + default: + rc = -1; + break; + } + + return rc; +} + +LIBEVDEV_EXPORT int +libevdev_fetch_event_value(const struct libevdev *dev, unsigned int type, unsigned int code, + int *value) { + if (libevdev_has_event_type(dev, type) && + libevdev_has_event_code(dev, type, code)) { + *value = libevdev_get_event_value(dev, type, code); + return 1; + } + + return 0; +} + +LIBEVDEV_EXPORT int +libevdev_get_slot_value(const struct libevdev *dev, unsigned int slot, unsigned int code) { + if (!libevdev_has_event_type(dev, EV_ABS) || !libevdev_has_event_code(dev, EV_ABS, code)) + return 0; + + if (dev->num_slots < 0 || slot >= (unsigned int) dev->num_slots) + return 0; + + if (code > ABS_MT_MAX || code < ABS_MT_MIN) + return 0; + + return *slot_value(dev, slot, code); +} + +LIBEVDEV_EXPORT int +libevdev_set_slot_value(struct libevdev *dev, unsigned int slot, unsigned int code, int value) { + if (!libevdev_has_event_type(dev, EV_ABS) || !libevdev_has_event_code(dev, EV_ABS, code)) + return -1; + + if (dev->num_slots == -1 || slot >= (unsigned int) dev->num_slots) + return -1; + + if (code > ABS_MT_MAX || code < ABS_MT_MIN) + return -1; + + if (code == ABS_MT_SLOT) { + if (value < 0 || value >= libevdev_get_num_slots(dev)) + return -1; + dev->current_slot = value; + } + + *slot_value(dev, slot, code) = value; + + return 0; +} + +LIBEVDEV_EXPORT int +libevdev_fetch_slot_value(const struct libevdev *dev, unsigned int slot, unsigned int code, + int *value) { + if (libevdev_has_event_type(dev, EV_ABS) && + libevdev_has_event_code(dev, EV_ABS, code) && + dev->num_slots >= 0 && + slot < (unsigned int) dev->num_slots) { + *value = libevdev_get_slot_value(dev, slot, code); + return 1; + } + + return 0; +} + +LIBEVDEV_EXPORT int +libevdev_get_num_slots(const struct libevdev *dev) { + return dev->num_slots; +} + +LIBEVDEV_EXPORT int +libevdev_get_current_slot(const struct libevdev *dev) { + return dev->current_slot; +} + +LIBEVDEV_EXPORT const struct input_absinfo * +libevdev_get_abs_info(const struct libevdev *dev, unsigned int code) { + if (!libevdev_has_event_type(dev, EV_ABS) || + !libevdev_has_event_code(dev, EV_ABS, code)) + return NULL; + + return &dev->abs_info[code]; +} + +#define ABS_GETTER(name) \ +LIBEVDEV_EXPORT int libevdev_get_abs_##name(const struct libevdev *dev, unsigned int code) \ +{ \ + const struct input_absinfo *absinfo = libevdev_get_abs_info(dev, code); \ + return absinfo ? absinfo->name : 0; \ +} + +ABS_GETTER(maximum) + +ABS_GETTER(minimum) + +ABS_GETTER(fuzz) + +ABS_GETTER(flat) + +ABS_GETTER(resolution) + +#define ABS_SETTER(field) \ +LIBEVDEV_EXPORT void libevdev_set_abs_##field(struct libevdev *dev, unsigned int code, int val) \ +{ \ + if (!libevdev_has_event_code(dev, EV_ABS, code)) \ + return; \ + dev->abs_info[code].field = val; \ +} + +ABS_SETTER(maximum) + +ABS_SETTER(minimum) + +ABS_SETTER(fuzz) + +ABS_SETTER(flat) + +ABS_SETTER(resolution) + +LIBEVDEV_EXPORT void +libevdev_set_abs_info(struct libevdev *dev, unsigned int code, const struct input_absinfo *abs) { + if (!libevdev_has_event_code(dev, EV_ABS, code)) + return; + + dev->abs_info[code] = *abs; +} + +LIBEVDEV_EXPORT int +libevdev_enable_event_type(struct libevdev *dev, unsigned int type) { + int max; + + if (type > EV_MAX) + return -1; + + if (libevdev_has_event_type(dev, type)) + return 0; + + max = libevdev_event_type_get_max(type); + if (max == -1) + return -1; + + set_bit(dev->bits, type); + + if (type == EV_REP) { + int delay = 0, period = 0; + libevdev_enable_event_code(dev, EV_REP, REP_DELAY, &delay); + libevdev_enable_event_code(dev, EV_REP, REP_PERIOD, &period); + } + return 0; +} + +LIBEVDEV_EXPORT int +libevdev_disable_event_type(struct libevdev *dev, unsigned int type) { + int max; + + if (type > EV_MAX || type == EV_SYN) + return -1; + + max = libevdev_event_type_get_max(type); + if (max == -1) + return -1; + + clear_bit(dev->bits, type); + + return 0; +} + +LIBEVDEV_EXPORT int +libevdev_enable_event_code(struct libevdev *dev, unsigned int type, + unsigned int code, const void *data) { + unsigned int max; + unsigned long *mask = NULL; + + if (libevdev_enable_event_type(dev, type)) + return -1; + + switch (type) { + case EV_SYN: + return 0; + case EV_ABS: + case EV_REP: + if (data == NULL) + return -1; + break; + default: + if (data != NULL) + return -1; + break; + } + + max = type_to_mask(dev, type, &mask); + + if (code > max || (int) max == -1) + return -1; + + set_bit(mask, code); + + if (type == EV_ABS) { + const struct input_absinfo *abs = data; + dev->abs_info[code] = *abs; + if (code == ABS_MT_SLOT) { + if (init_slots(dev) != 0) + return -1; + } else if (code == ABS_MT_TRACKING_ID) { + reset_tracking_ids(dev); + } + } else if (type == EV_REP) { + const int *value = data; + dev->rep_values[code] = *value; + } + + return 0; +} + +LIBEVDEV_EXPORT int +libevdev_disable_event_code(struct libevdev *dev, unsigned int type, unsigned int code) { + unsigned int max; + unsigned long *mask = NULL; + + if (type > EV_MAX || type == EV_SYN) + return -1; + + max = type_to_mask(dev, type, &mask); + + if (code > max || (int) max == -1) + return -1; + + clear_bit(mask, code); + + if (type == EV_ABS) { + if (code == ABS_MT_SLOT) { + if (init_slots(dev) != 0) + return -1; + } else if (code == ABS_MT_TRACKING_ID) { + reset_tracking_ids(dev); + } + } + + return 0; +} + +LIBEVDEV_EXPORT int +libevdev_kernel_set_abs_info(struct libevdev *dev, unsigned int code, + const struct input_absinfo *abs) { + int rc; + + if (!dev->initialized) { + log_bug(dev, "device not initialized. call libevdev_set_fd() first\n"); + return -EBADF; + } + + if (dev->fd < 0) + return -EBADF; + + if (code > ABS_MAX) + return -EINVAL; + + rc = ioctl(dev->fd, EVIOCSABS(code), abs); + if (rc < 0) + rc = -errno; + else + rc = libevdev_enable_event_code(dev, EV_ABS, code, abs); + + return rc; +} + +LIBEVDEV_EXPORT int +libevdev_grab(struct libevdev *dev, enum libevdev_grab_mode grab) { + int rc = 0; + + if (!dev->initialized) { + log_bug(dev, "device not initialized. call libevdev_set_fd() first\n"); + return -EBADF; + } + + if (dev->fd < 0) + return -EBADF; + + if (grab != LIBEVDEV_GRAB && grab != LIBEVDEV_UNGRAB) { + log_bug(dev, "invalid grab parameter %#x\n", grab); + return -EINVAL; + } + + if (grab == dev->grabbed) + return 0; + + if (grab == LIBEVDEV_GRAB) + rc = ioctl(dev->fd, EVIOCGRAB, (void *) 1); + else if (grab == LIBEVDEV_UNGRAB) + rc = ioctl(dev->fd, EVIOCGRAB, (void *) 0); + + if (rc == 0) + dev->grabbed = grab; + + return rc < 0 ? -errno : 0; +} + +LIBEVDEV_EXPORT int +libevdev_event_is_type(const struct input_event *ev, unsigned int type) { + return type < EV_CNT && ev->type == type; +} + +LIBEVDEV_EXPORT int +libevdev_event_is_code(const struct input_event *ev, unsigned int type, unsigned int code) { + int max; + + if (!libevdev_event_is_type(ev, type)) + return 0; + + max = libevdev_event_type_get_max(type); + return (max > -1 && code <= (unsigned int) max && ev->code == code); +} + +LIBEVDEV_EXPORT const char * +libevdev_event_type_get_name(unsigned int type) { + if (type > EV_MAX) + return NULL; + + return ev_map[type]; +} + +LIBEVDEV_EXPORT const char * +libevdev_event_code_get_name(unsigned int type, unsigned int code) { + int max = libevdev_event_type_get_max(type); + + if (max == -1 || code > (unsigned int) max) + return NULL; + + return event_type_map[type][code]; +} + +LIBEVDEV_EXPORT const char * +libevdev_event_value_get_name(unsigned int type, + unsigned int code, + int value) { + /* This is a simplified version because nothing else + is an enum like ABS_MT_TOOL_TYPE so we don't need + a generic lookup */ + if (type != EV_ABS || code != ABS_MT_TOOL_TYPE) + return NULL; + + if (value < 0 || value > MT_TOOL_MAX) + return NULL; + + return mt_tool_map[value]; +} + +LIBEVDEV_EXPORT const char * +libevdev_property_get_name(unsigned int prop) { + if (prop > INPUT_PROP_MAX) + return NULL; + + return input_prop_map[prop]; +} + +LIBEVDEV_EXPORT int +libevdev_event_type_get_max(unsigned int type) { + if (type > EV_MAX) + return -1; + + return ev_max[type]; +} + +LIBEVDEV_EXPORT int +libevdev_get_repeat(const struct libevdev *dev, int *delay, int *period) { + if (!libevdev_has_event_type(dev, EV_REP)) + return -1; + + if (delay != NULL) + *delay = dev->rep_values[REP_DELAY]; + if (period != NULL) + *period = dev->rep_values[REP_PERIOD]; + + return 0; +} + +LIBEVDEV_EXPORT int +libevdev_kernel_set_led_value(struct libevdev *dev, unsigned int code, + enum libevdev_led_value value) { + return libevdev_kernel_set_led_values(dev, code, value, -1); +} + +LIBEVDEV_EXPORT int +libevdev_kernel_set_led_values(struct libevdev *dev, ...) { + struct input_event ev[LED_MAX + 1]; + enum libevdev_led_value val; + va_list args; + int code; + int rc = 0; + size_t nleds = 0; + + if (!dev->initialized) { + log_bug(dev, "device not initialized. call libevdev_set_fd() first\n"); + return -EBADF; + } + + if (dev->fd < 0) + return -EBADF; + + memset(ev, 0, sizeof(ev)); + + va_start(args, dev); + code = va_arg(args, unsigned int); + while (code != -1) { + if (code > LED_MAX) { + rc = -EINVAL; + break; + } + val = va_arg(args, enum libevdev_led_value); + if (val != LIBEVDEV_LED_ON && val != LIBEVDEV_LED_OFF) { + rc = -EINVAL; + break; + } + + if (libevdev_has_event_code(dev, EV_LED, code)) { + struct input_event *e = ev; + + while (e->type > 0 && e->code != code) + e++; + + if (e->type == 0) + nleds++; + e->type = EV_LED; + e->code = code; + e->value = (val == LIBEVDEV_LED_ON); + } + code = va_arg(args, unsigned int); + } + va_end(args); + + if (rc == 0 && nleds > 0) { + ev[nleds].type = EV_SYN; + ev[nleds++].code = SYN_REPORT; + + rc = write(libevdev_get_fd(dev), ev, nleds * sizeof(ev[0])); + if (rc > 0) { + nleds--; /* last is EV_SYN */ + while (nleds--) + update_led_state(dev, &ev[nleds]); + } + rc = (rc != -1) ? 0 : -errno; + } + + return rc; +} + +LIBEVDEV_EXPORT int +libevdev_set_clock_id(struct libevdev *dev, int clockid) { + if (!dev->initialized) { + log_bug(dev, "device not initialized. call libevdev_set_fd() first\n"); + return -EBADF; + } + + if (dev->fd < 0) + return -EBADF; + + return ioctl(dev->fd, EVIOCSCLOCKID, &clockid) ? -errno : 0; +} diff --git a/sysbridge/src/main/cpp/libevdev/libevdev.h b/sysbridge/src/main/cpp/libevdev/libevdev.h new file mode 100644 index 0000000000..b4b34ead63 --- /dev/null +++ b/sysbridge/src/main/cpp/libevdev/libevdev.h @@ -0,0 +1,2387 @@ +/* SPDX-License-Identifier: MIT */ +/* + * Copyright © 2013 Red Hat, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. + * + */ + +#ifndef LIBEVDEV_H +#define LIBEVDEV_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +#define LIBEVDEV_ATTRIBUTE_PRINTF(_format, _args) __attribute__ ((format (printf, _format, _args))) + +/** + * @mainpage + * + * **libevdev** is a library for handling evdev kernel devices. It abstracts + * the \ref ioctls through type-safe interfaces and provides functions to change + * the appearance of the device. + * + * Development + * =========== + * The git repository is available here: + * + * - https://gitlab.freedesktop.org/libevdev/libevdev + * + * Development of libevdev is discussed on + * [input-tools@lists.freedesktop.org](http://lists.freedesktop.org/mailman/listinfo/input-tools). + * Please submit patches, questions or general comments there. + * + * Handling events and SYN_DROPPED + * =============================== + * + * libevdev provides an interface for handling events, including most notably + * `SYN_DROPPED` events. `SYN_DROPPED` events are sent by the kernel when the + * process does not read events fast enough and the kernel is forced to drop + * some events. This causes the device to get out of sync with the process' + * view of it. libevdev handles this by telling the caller that a * `SYN_DROPPED` + * has been received and that the state of the device is different to what is + * to be expected. It then provides the delta between the previous state and + * the actual state of the device as a set of events. See + * libevdev_next_event() and @ref syn_dropped for more information on how + * `SYN_DROPPED` is handled. + * + * Signal safety + * ============= + * + * libevdev is signal-safe for the majority of its operations, i.e. many of + * its functions are safe to be called from within a signal handler. + * Check the API documentation to make sure, unless explicitly stated a call + * is not signal safe. + * + * Device handling + * =============== + * + * A libevdev context is valid for a given file descriptor and its + * duration. Closing the file descriptor will not destroy the libevdev device + * but libevdev will not be able to read further events. + * + * libevdev does not attempt duplicate detection. Initializing two libevdev + * devices for the same fd is valid and behaves the same as for two different + * devices. + * + * libevdev does not handle the file descriptors directly, it merely uses + * them. The caller is responsible for opening the file descriptors, setting + * them to `O_NONBLOCK` and handling permissions. A caller should drain any + * events pending on the file descriptor before passing it to libevdev. + * + * Where does libevdev sit? + * ======================== + * + * libevdev is essentially a `read(2)` on steroids for `/dev/input/eventX` + * devices. It sits below the process that handles input events, in between + * the kernel and that process. In the simplest case, e.g. an evtest-like tool + * the stack would look like this: + * + * kernel → libevdev → evtest + * + * For X.Org input modules, the stack would look like this: + * + * kernel → libevdev → xf86-input-evdev → X server → X client + * + * For anything using libinput (e.g. most Wayland compositors), the stack + * the stack would look like this: + * + * kernel → libevdev → libinput → Compositor → Wayland client + * + * libevdev does **not** have knowledge of X clients or Wayland clients, it is + * too low in the stack. + * + * Example + * ======= + * Below is a simple example that shows how libevdev could be used. This example + * opens a device, checks for relative axes and a left mouse button and if it + * finds them monitors the device to print the event. + * + * @code + * struct libevdev *dev = NULL; + * int fd; + * int rc = 1; + * + * fd = open("/dev/input/event0", O_RDONLY|O_NONBLOCK); + * rc = libevdev_new_from_fd(fd, &dev); + * if (rc < 0) { + * fprintf(stderr, "Failed to init libevdev (%s)\n", strerror(-rc)); + * exit(1); + * } + * printf("Input device name: \"%s\"\n", libevdev_get_name(dev)); + * printf("Input device ID: bus %#x vendor %#x product %#x\n", + * libevdev_get_id_bustype(dev), + * libevdev_get_id_vendor(dev), + * libevdev_get_id_product(dev)); + * if (!libevdev_has_event_type(dev, EV_REL) || + * !libevdev_has_event_code(dev, EV_KEY, BTN_LEFT)) { + * printf("This device does not look like a mouse\n"); + * exit(1); + * } + * + * do { + * struct input_event ev; + * rc = libevdev_next_event(dev, LIBEVDEV_READ_FLAG_NORMAL, &ev); + * if (rc == 0) + * printf("Event: %s %s %d\n", + * libevdev_event_type_get_name(ev.type), + * libevdev_event_code_get_name(ev.type, ev.code), + * ev.value); + * } while (rc == 1 || rc == 0 || rc == -EAGAIN); + * @endcode + * + * A more complete example is available with the libevdev-events tool here: + * https://gitlab.freedesktop.org/libevdev/libevdev/blob/master/tools/libevdev-events.c + * + * Backwards compatibility with older kernel + * ========================================= + * libevdev attempts to build and run correctly on a number of kernel versions. + * If features required are not available, libevdev attempts to work around them + * in the most reasonable way. For more details see \ref backwardscompatibility. + * + * License information + * =================== + * libevdev is licensed under the + * [MIT license](http://cgit.freedesktop.org/libevdev/tree/COPYING). + * + * Bindings + * =================== + * - Python: https://gitlab.freedesktop.org/libevdev/python-libevdev + * - Haskell: http://hackage.haskell.org/package/evdev + * - Rust: https://crates.io/crates/evdev-rs + * + * Reporting bugs + * ============== + * Please report bugs in the freedesktop.org GitLab instance: + * https://gitlab.freedesktop.org/libevdev/libevdev/issues/ + */ + +/** + * @page syn_dropped SYN_DROPPED handling + * + * This page describes how libevdev handles `SYN_DROPPED` events. + * + * Receiving `SYN_DROPPED` events + * ============================== + * + * The kernel sends evdev events separated by an event of type `EV_SYN` and + * code `SYN_REPORT`. Such an event marks the end of a frame of hardware + * events. The number of events between `SYN_REPORT` events is arbitrary and + * depends on the hardware. An example event sequence may look like this: + * @code + * EV_ABS ABS_X 9 + * EV_ABS ABS_Y 8 + * EV_SYN SYN_REPORT 0 + * ------------------------ + * EV_ABS ABS_X 10 + * EV_ABS ABS_Y 10 + * EV_KEY BTN_TOUCH 1 + * EV_SYN SYN_REPORT 0 + * ------------------------ + * EV_ABS ABS_X 11 + * EV_SYN SYN_REPORT 0 + * @endcode + * + * Events are handed to the client buffer as they appear, the kernel adjusts + * the buffer size to handle at least one full event. In the normal case, + * the client reads the event and the kernel can place the next event in the + * buffer. If the client is not fast enough, the kernel places an event of + * type `EV_SYN` and code `SYN_DROPPED` into the buffer, effectively notifying + * the client that some events were lost. The above example event sequence + * may look like this (note the missing/repeated events): + * @code + * EV_ABS ABS_X 9 + * EV_ABS ABS_Y 8 + * EV_SYN SYN_REPORT 0 + * ------------------------ + * EV_ABS ABS_X 10 + * EV_ABS ABS_Y 10 + * EV_SYN SYN_DROPPED 0 + * EV_ABS ABS_Y 15 + * EV_SYN SYN_REPORT 0 + * ------------------------ + * EV_ABS ABS_X 11 + * EV_KEY BTN_TOUCH 0 + * EV_SYN SYN_REPORT 0 + * @endcode + * + * A `SYN_DROPPED` event may be recieved at any time in the event sequence. + * When a `SYN_DROPPED` event is received, the client must: + * * discard all events since the last `SYN_REPORT` + * * discard all events until including the next `SYN_REPORT` + * These event are part of incomplete event frames. + * + * Synchronizing the state of the device + * ===================================== + * + * The handling of the device after a `SYN_DROPPED` depends on the available + * event codes. For all event codes of type `EV_REL`, no handling is + * necessary, there is no state attached. For all event codes of type + * `EV_KEY`, `EV_SW`, `EV_LED` and `EV_SND`, the matching @ref ioctls retrieve the + * current state. The caller must then compare the last-known state to the + * retrieved state and handle the deltas accordingly. + * libevdev simplifies this approach: if the state of the device has + * changed, libevdev generates an event for each code with the new value and + * passes it to the caller during libevdev_next_event() if + * @ref LIBEVDEV_READ_FLAG_SYNC is set. + * + * For events of type `EV_ABS` and an event code less than `ABS_MT_SLOT`, the + * handling of state changes is as described above. For events between + * `ABS_MT_SLOT` and `ABS_MAX`, the event handling differs. + * Slots are the vehicles to transport information for multiple simultaneous + * touchpoints on a device. Slots are re-used once a touchpoint has ended. + * The kernel sends an `ABS_MT_SLOT` event whenever the current slot + * changes; any event in the above axis range applies only to the currently + * active slot. + * Thus, an event sequence from a slot-capable device may look like this: + * @code + * EV_ABS ABS_MT_POSITION_Y 10 + * EV_ABS ABS_MT_SLOT 1 + * EV_ABS ABS_MT_POSITION_X 100 + * EV_ABS ABS_MT_POSITION_Y 80 + * EV_SYN SYN_REPORT 0 + * @endcode + * Note the lack of `ABS_MT_SLOT`: the first `ABS_MT_POSITION_Y` applies to + * a slot opened previously, and is the only axis that changed for that + * slot. The touchpoint in slot 1 now has position `100/80`. + * The kernel does not provide events if a value does not change, and does + * not send `ABS_MT_SLOT` events if the slot does not change, or none of the + * values within a slot changes. A client must thus keep the state for each + * slot. + * + * If a `SYN_DROPPED` is received, the client must sync all slots + * individually and update its internal state. libevdev simplifies this by + * generating multiple events: + * * for each slot on the device, libevdev generates an + * `ABS_MT_SLOT` event with the value set to the slot number + * * for each event code between `ABS_MT_SLOT + 1` and `ABS_MAX` that changed + * state for this slot, libevdev generates an event for the new state + * * libevdev sends a final `ABS_MT_SLOT` event for the current slot as + * seen by the kernel + * * libevdev terminates this sequence with an `EV_SYN SYN_REPORT` event + * + * An example event sequence for such a sync may look like this: + * @code + * EV_ABS ABS_MT_SLOT 0 + * EV_ABS ABS_MT_POSITION_Y 10 + * EV_ABS ABS_MT_SLOT 1 + * EV_ABS ABS_MT_POSITION_X 100 + * EV_ABS ABS_MT_POSITION_Y 80 + * EV_ABS ABS_MT_SLOT 2 + * EV_ABS ABS_MT_POSITION_Y 8 + * EV_ABS ABS_MT_PRESSURE 12 + * EV_ABS ABS_MT_SLOT 1 + * EV_SYN SYN_REPORT 0 + * @endcode + * Note the terminating `ABS_MT_SLOT` event, this indicates that the kernel + * currently has slot 1 active. + * + * Synchronizing ABS_MT_TRACKING_ID + * ================================ + * + * The event code `ABS_MT_TRACKING_ID` is used to denote the start and end of + * a touch point within a slot. An `ABS_MT_TRACKING_ID` of zero or greater + * denotes the start of a touchpoint, an `ABS_MT_TRACKING_ID` of -1 denotes + * the end of a touchpoint within this slot. During `SYN_DROPPED`, a touch + * point may have ended and re-started within a slot - a client must check + * the `ABS_MT_TRACKING_ID`. libevdev simplifies this by emulating extra + * events if the `ABS_MT_TRACKING_ID` has changed: + * * if the `ABS_MT_TRACKING_ID` was valid and is -1, libevdev enqueues an + * `ABS_MT_TRACKING_ID` event with value -1. + * * if the `ABS_MT_TRACKING_ID` was -1 and is now a valid ID, libevdev + * enqueues an `ABS_MT_TRACKING_ID` event with the current value. + * * if the `ABS_MT_TRACKING_ID` was a valid ID and is now a different valid + * ID, libevev enqueues an `ABS_MT_TRACKING_ID` event with value -1 and + * another `ABS_MT_TRACKING_ID` event with the new value. + * + * An example event sequence for such a sync may look like this: + * @code + * EV_ABS ABS_MT_SLOT 0 + * EV_ABS ABS_MT_TRACKING_ID -1 + * EV_ABS ABS_MT_SLOT 2 + * EV_ABS ABS_MT_TRACKING_ID -1 + * EV_SYN SYN_REPORT 0 + * ------------------------ + * EV_ABS ABS_MT_SLOT 1 + * EV_ABS ABS_MT_POSITION_X 100 + * EV_ABS ABS_MT_POSITION_Y 80 + * EV_ABS ABS_MT_SLOT 2 + * EV_ABS ABS_MT_TRACKING_ID 45 + * EV_ABS ABS_MT_POSITION_Y 8 + * EV_ABS ABS_MT_PRESSURE 12 + * EV_ABS ABS_MT_SLOT 1 + * EV_SYN SYN_REPORT 0 + * @endcode + * Note how the touchpoint in slot 0 was terminated, the touchpoint in slot + * 2 was terminated and then started with a new `ABS_MT_TRACKING_ID`. The touchpoint + * in slot 1 maintained the same `ABS_MT_TRACKING_ID` and only updated the + * coordinates. Slot 1 is the currently active slot. + * + * In the case of a `SYN_DROPPED` event, a touch point may be invisible to a + * client if it started after `SYN_DROPPED` and finished before the client + * handles events again. The below example shows an example event sequence + * and what libevdev sees in the case of a `SYN_DROPPED` event: + * @code + * + * kernel | userspace + * | + * EV_ABS ABS_MT_SLOT 0 | EV_ABS ABS_MT_SLOT 0 + * EV_ABS ABS_MT_TRACKING_ID -1 | EV_ABS ABS_MT_TRACKING_ID -1 + * EV_SYN SYN_REPORT 0 | EV_SYN SYN_REPORT 0 + * ------------------------ | ------------------------ + * EV_ABS ABS_MT_TRACKING_ID 30 | + * EV_ABS ABS_MT_POSITION_X 100 | + * EV_ABS ABS_MT_POSITION_Y 80 | + * EV_SYN SYN_REPORT 0 | SYN_DROPPED + * ------------------------ | + * EV_ABS ABS_MT_TRACKING_ID -1 | + * EV_SYN SYN_REPORT 0 | + * ------------------------ | ------------------------ + * EV_ABS ABS_MT_SLOT 1 | EV_ABS ABS_MT_SLOT 1 + * EV_ABS ABS_MT_POSITION_X 90 | EV_ABS ABS_MT_POSITION_X 90 + * EV_ABS ABS_MT_POSITION_Y 10 | EV_ABS ABS_MT_POSITION_Y 10 + * EV_SYN SYN_REPORT 0 | EV_SYN SYN_REPORT 0 + * @endcode + * If such an event sequence occurs, libevdev will send all updated axes + * during the sync process. Axis events may thus be generated for devices + * without a currently valid `ABS_MT_TRACKING_ID`. Specifically for the above + * example, the client would receive the following event sequence: + * @code + * EV_ABS ABS_MT_SLOT 0 ← LIBEVDEV_READ_FLAG_NORMAL + * EV_ABS ABS_MT_TRACKING_ID -1 + * EV_SYN SYN_REPORT 0 + * ------------------------ + * EV_SYN SYN_DROPPED 0 → LIBEVDEV_READ_STATUS_SYNC + * ------------------------ + * EV_ABS ABS_MT_POSITION_X 100 ← LIBEVDEV_READ_FLAG_SYNC + * EV_ABS ABS_MT_POSITION_Y 80 + * EV_SYN SYN_REPORT 0 + * ----------------------------- → -EGAIN + * EV_ABS ABS_MT_SLOT 1 ← LIBEVDEV_READ_FLAG_NORMAL + * EV_ABS ABS_MT_POSITION_X 90 + * EV_ABS ABS_MT_POSITION_Y 10 + * EV_SYN SYN_REPORT 0 + * ------------------- + * @endcode + * The axis events do not reflect the position of a current touch point, a + * client must take care not to generate a new touch point based on those + * updates. + * + * Discarding events before synchronizing + * ===================================== + * + * The kernel implements the client buffer as a ring buffer. `SYN_DROPPED` + * events are handled when the buffer is full and a new event is received + * from a device. All existing events are discarded, a `SYN_DROPPED` is added + * to the buffer followed by the actual device event. Further events will be + * appended to the buffer until it is either read by the client, or filled + * again, at which point the sequence repeats. + * + * When the client reads the buffer, the buffer will thus always consist of + * exactly one `SYN_DROPPED` event followed by an unspecified number of real + * events. The data the ioctls return is the current state of the device, + * i.e. the state after all these events have been processed. For example, + * assume the buffer contains the following sequence: + * + * @code + * EV_SYN SYN_DROPPED + * EV_ABS ABS_X 1 + * EV_SYN SYN_REPORT 0 + * EV_ABS ABS_X 2 + * EV_SYN SYN_REPORT 0 + * EV_ABS ABS_X 3 + * EV_SYN SYN_REPORT 0 + * EV_ABS ABS_X 4 + * EV_SYN SYN_REPORT 0 + * EV_ABS ABS_X 5 + * EV_SYN SYN_REPORT 0 + * EV_ABS ABS_X 6 + * EV_SYN SYN_REPORT 0 + * @endcode + * An ioctl at any time in this sequence will return a value of 6 for ABS_X. + * + * libevdev discards all events after a `SYN_DROPPED` to ensure the events + * during @ref LIBEVDEV_READ_FLAG_SYNC represent the last known state of the + * device. This loses some granularity of the events especially as the time + * between the `SYN_DROPPED` and the sync process increases. It does however + * avoid spurious cursor movements. In the above example, the event sequence + * by libevdev is: + * @code + * EV_SYN SYN_DROPPED + * EV_ABS ABS_X 6 + * EV_SYN SYN_REPORT 0 + * @endcode + */ + +/** + * @page backwardscompatibility Compatibility and Behavior across kernel versions + * + * This page describes libevdev's behavior when the build-time kernel and the + * run-time kernel differ in their feature set. + * + * With the exception of event names, libevdev defines features that may be + * missing on older kernels and building on such kernels will not disable + * features. Running libevdev on a kernel that is missing some feature will + * change libevdev's behavior. In most cases, the new behavior should be + * obvious, but it is spelled out below in detail. + * + * Minimum requirements + * ==================== + * libevdev requires a 2.6.36 kernel as minimum. Specifically, it requires + * kernel-support for `ABS_MT_SLOT`. + * + * Event and input property names + * ============================== + * Event names and input property names are defined at build-time by the + * linux/input.h shipped with libevdev. + * The list of event names is compiled at build-time, any events not defined + * at build time will not resolve. Specifically, + * libevdev_event_code_get_name() for an undefined type or code will + * always return `NULL`. Likewise, libevdev_property_get_name() will return NULL + * for properties undefined at build-time. + * + * Input properties + * ================ + * If the kernel does not support input properties, specifically the + * `EVIOCGPROPS` ioctl, libevdev does not expose input properties to the caller. + * Specifically, libevdev_has_property() will always return 0 unless the + * property has been manually set with libevdev_enable_property(). + * + * This also applies to the libevdev-uinput code. If uinput does not honor + * `UI_SET_PROPBIT`, libevdev will continue without setting the properties on + * the device. + * + * MT slot behavior + * ================= + * If the kernel does not support the `EVIOCGMTSLOTS` ioctl, libevdev + * assumes all values in all slots are 0 and continues without an error. + * + * SYN_DROPPED behavior + * ==================== + * A kernel without `SYN_DROPPED` won't send such an event. libevdev_next_event() + * will never require the switch to sync mode. + */ + +/** + * @page ioctls evdev ioctls + * + * This page lists the status of the evdev-specific ioctls in libevdev. + * + *

+ *
EVIOCGVERSION:
+ *
supported, see libevdev_get_driver_version()
+ *
EVIOCGID:
+ *
supported, see libevdev_get_id_product(), libevdev_get_id_vendor(), + * libevdev_get_id_bustype(), libevdev_get_id_version()
+ *
EVIOCGREP:
+ *
supported, see libevdev_get_event_value())
+ *
EVIOCSREP:
+ *
supported, see libevdev_enable_event_code()
+ *
EVIOCGKEYCODE:
+ *
currently not supported
+ *
EVIOCSKEYCODE:
+ *
currently not supported
+ *
EVIOCGKEYCODE_V2:
+ *
currently not supported
+ *
EVIOCSKEYCODE_V2:
+ *
currently not supported
+ *
EVIOCGNAME:
+ *
supported, see libevdev_get_name()
+ *
EVIOCGPHYS:
+ *
supported, see libevdev_get_phys()
+ *
EVIOCGUNIQ:
+ *
supported, see libevdev_get_uniq()
+ *
EVIOCGPROP:
+ *
supported, see libevdev_has_property()
+ *
EVIOCGMTSLOTS:
+ *
supported, see libevdev_get_num_slots(), libevdev_get_slot_value()
+ *
EVIOCGKEY:
+ *
supported, see libevdev_has_event_code(), libevdev_get_event_value()
+ *
EVIOCGLED:
+ *
supported, see libevdev_has_event_code(), libevdev_get_event_value()
+ *
EVIOCGSND:
+ *
currently not supported
+ *
EVIOCGSW:
+ *
supported, see libevdev_has_event_code(), libevdev_get_event_value()
+ *
EVIOCGBIT:
+ *
supported, see libevdev_has_event_code(), libevdev_get_event_value()
+ *
EVIOCGABS:
+ *
supported, see libevdev_has_event_code(), libevdev_get_event_value(), + * libevdev_get_abs_info()
+ *
EVIOCSABS:
+ *
supported, see libevdev_kernel_set_abs_info()
+ *
EVIOCSFF:
+ *
currently not supported
+ *
EVIOCRMFF:
+ *
currently not supported
+ *
EVIOCGEFFECTS:
+ *
currently not supported
+ *
EVIOCGRAB:
+ *
supported, see libevdev_grab()
+ *
EVIOCSCLOCKID:
+ *
supported, see libevdev_set_clock_id()
+ *
EVIOCREVOKE:
+ *
currently not supported, see + * http://lists.freedesktop.org/archives/input-tools/2014-January/000688.html
+ *
EVIOCGMASK:
+ *
currently not supported
+ *
EVIOCSMASK:
+ *
currently not supported
+ *
+ * + */ + +/** + * @page kernel_header Kernel header + * + * libevdev provides its own copy of the Linux kernel header file and + * compiles against the definitions define here. Event type and event code + * names, etc. are taken from the file below: + * @include linux/input.h + */ + +/** + * @page static_linking Statically linking libevdev + * + * Statically linking libevdev.a is not recommended. Symbol visibility is + * difficult to control in a static library, so extra care must be taken to + * only use symbols that are explicitly exported. libevdev's API stability + * guarantee only applies to those symbols. + * + * If you do link libevdev statically, note that in addition to the exported + * symbols, libevdev reserves the _libevdev_* namespace. Do not use + * or create symbols with that prefix, they are subject to change at any + * time. + */ + +/** + * @page testing libevdev-internal test suite + * + * libevdev's internal test suite uses the + * [Check unit testing framework](http://check.sourceforge.net/). Tests are + * divided into test suites and test cases. Most tests create a uinput device, + * so you'll need to run as root, and your kernel must have + * `CONFIG_INPUT_UINPUT` enabled. + * + * To run a specific suite only: + * + * export CK_RUN_SUITE="suite name" + * + * To run a specific test case only: + * + * export CK_RUN_TEST="test case name" + * + * To get a list of all suites or tests: + * + * git grep "suite_create" + * git grep "tcase_create" + * + * By default, Check forks, making debugging harder. The test suite tries to detect + * if it is running inside gdb and disable forking. If that doesn't work for + * some reason, run gdb as below to avoid forking. + * + * sudo CK_FORK=no CK_RUN_TEST="test case name" gdb ./test/test-libevdev + * + * A special target `make gcov-report.txt` exists that runs gcov and leaves a + * `libevdev.c.gcov` file. Check that for test coverage. + * + * `make check` is hooked up to run the test and gcov (again, needs root). + * + * The test suite creates a lot of devices, very quickly. Add the following + * xorg.conf.d snippet to avoid the devices being added as X devices (at the + * time of writing, mutter can't handle these devices and exits after getting + * a BadDevice error). + * + * $ cat /etc/X11/xorg.conf.d/99-ignore-libevdev-devices.conf + * Section "InputClass" + * Identifier "Ignore libevdev test devices" + * MatchProduct "libevdev test device" + * Option "Ignore" "on" + * EndSection + * + */ + +/** + * @defgroup init Initialization and setup + * + * Initialization, initial setup and file descriptor handling. + * These functions are the main entry points for users of libevdev, usually a + * caller will use this series of calls: + * + * @code + * struct libevdev *dev; + * int err; + * + * dev = libevdev_new(); + * if (!dev) + * return ENOMEM; + * + * err = libevdev_set_fd(dev, fd); + * if (err < 0) + * printf("Failed (errno %d): %s\n", -err, strerror(-err)); + * + * libevdev_free(dev); + * @endcode + * + * libevdev_set_fd() is the central call and initializes the internal structs + * for the device at the given fd. libevdev functions will fail if called + * before libevdev_set_fd() unless documented otherwise. + */ + +/** + * @defgroup logging Library logging facilities + * + * libevdev provides two methods of logging library-internal messages. The + * old method is to provide a global log handler in + * libevdev_set_log_function(). The new method is to provide a per-context + * log handler in libevdev_set_device_log_function(). Developers are encouraged + * to use the per-context logging facilities over the global log handler as + * it provides access to the libevdev instance that caused a message, and is + * more flexible when libevdev is used from within a shared library. + * + * If a caller sets both the global log handler and a per-context log + * handler, each device with a per-context log handler will only invoke that + * log handler. + * + * @note To set a context-specific log handler, a context is needed. + * Thus developers are discouraged from using libevdev_new_from_fd() as + * important messages from the device initialization process may get lost. + * + * @note A context-specific handler cannot be used for libevdev's uinput + * devices. @ref uinput must use the global log handler. + */ + +/** + * @defgroup bits Querying device capabilities + * + * Abstraction functions to handle device capabilities, specifically + * device properties such as the name of the device and the bits + * representing the events supported by this device. + * + * The logical state returned may lag behind the physical state of the device. + * libevdev queries the device state on libevdev_set_fd() and then relies on + * the caller to parse events through libevdev_next_event(). If a caller does not + * use libevdev_next_event(), libevdev will not update the internal state of the + * device and thus returns outdated values. + */ + +/** + * @defgroup mt Multi-touch related functions + * Functions for querying multi-touch-related capabilities. MT devices + * following the kernel protocol B (using `ABS_MT_SLOT`) provide multiple touch + * points through so-called slots on the same axis. The slots are enumerated, + * a client reading from the device will first get an ABS_MT_SLOT event, then + * the values of axes changed in this slot. Multiple slots may be provided in + * before an `EV_SYN` event. + * + * As with @ref bits, the logical state of the device as seen by the library + * depends on the caller using libevdev_next_event(). + * + * The Linux kernel requires all axes on a device to have a semantic + * meaning, matching the axis names in linux/input.h. Some devices merely + * export a number of axes beyond the available axis list. For those + * devices, the multitouch information is invalid. Specifically, if a device + * provides the `ABS_MT_SLOT` axis AND also the `ABS_RESERVED` axis, the + * device is not treated as multitouch device. No slot information is + * available and the `ABS_MT` axis range for these devices is treated as all + * other `EV_ABS` axes. + * + * Note that because of limitations in the kernel API, such fake multitouch + * devices can not be reliably synced after a `SYN_DROPPED` event. libevdev + * ignores all `ABS_MT` axis values during the sync process and instead + * relies on the device to send the current axis value with the first event + * after `SYN_DROPPED`. + */ + +/** + * @defgroup kernel Modifying the appearance or capabilities of the device + * + * Modifying the set of events reported by this device. By default, the + * libevdev device mirrors the kernel device, enabling only those bits + * exported by the kernel. This set of functions enable or disable bits as + * seen from the caller. + * + * Enabling an event type or code does not affect event reporting - a + * software-enabled event will not be generated by the physical hardware. + * Disabling an event will prevent libevdev from routing such events to the + * caller. Enabling and disabling event types and codes is at the library + * level and thus only affects the caller. + * + * If an event type or code is enabled at kernel-level, future users of this + * device will see this event enabled. Currently there is no option of + * disabling an event type or code at kernel-level. + */ + +/** + * @defgroup misc Miscellaneous helper functions + * + * Functions for printing or querying event ranges. The list of names is + * compiled into libevdev and is independent of the run-time kernel. + * Likewise, the max for each event type is compiled in and does not check + * the kernel at run-time. + */ + +/** + * @defgroup events Event handling + * + * Functions to handle events and fetch the current state of the event. + * libevdev updates its internal state as the event is processed and forwarded + * to the caller. Thus, the libevdev state of the device should always be identical + * to the caller's state. It may however lag behind the actual state of the device. + */ + +/** + * @ingroup init + * + * Opaque struct representing an evdev device. + */ +struct libevdev; + +/** + * @ingroup events + */ +enum libevdev_read_flag { + LIBEVDEV_READ_FLAG_SYNC = 1, /**< Process data in sync mode */ + LIBEVDEV_READ_FLAG_NORMAL = 2, /**< Process data in normal mode */ + LIBEVDEV_READ_FLAG_FORCE_SYNC = 4, /**< Pretend the next event is a SYN_DROPPED and + require the caller to sync */ + LIBEVDEV_READ_FLAG_BLOCKING = 8 /**< The fd is not in O_NONBLOCK and a read may block */ +}; + +/** + * @ingroup init + * + * Initialize a new libevdev device. This function only allocates the + * required memory and initializes the struct to sane default values. + * To actually hook up the device to a kernel device, use + * libevdev_set_fd(). + * + * Memory allocated through libevdev_new() must be released by the + * caller with libevdev_free(). + * + * @see libevdev_set_fd + * @see libevdev_free + */ +struct libevdev *libevdev_new(void); + +/** + * @ingroup init + * + * Initialize a new libevdev device from the given fd. + * + * This is a shortcut for + * + * @code + * int err; + * struct libevdev *dev = libevdev_new(); + * err = libevdev_set_fd(dev, fd); + * @endcode + * + * @param fd A file descriptor to the device in O_RDWR or O_RDONLY mode. + * @param[out] dev The newly initialized evdev device. + * + * @return On success, 0 is returned and dev is set to the newly + * allocated struct. On failure, a negative errno is returned and the value + * of dev is undefined. + * + * @see libevdev_free + */ +int libevdev_new_from_fd(int fd, struct libevdev **dev); + +/** + * @ingroup init + * + * Clean up and free the libevdev struct. After completion, the struct + * libevdev is invalid and must not be used. + * + * Note that calling libevdev_free() does not close the file descriptor + * currently associated with this instance. + * + * @param dev The evdev device + * + * @note This function may be called before libevdev_set_fd(). + */ +void libevdev_free(struct libevdev *dev); + +/** + * @ingroup logging + */ +enum libevdev_log_priority { + LIBEVDEV_LOG_ERROR = 10, /**< critical errors and application bugs */ + LIBEVDEV_LOG_INFO = 20, /**< informational messages */ + LIBEVDEV_LOG_DEBUG = 30 /**< debug information */ +}; + +/** + * @ingroup logging + * + * Logging function called by library-internal logging. + * This function is expected to treat its input like printf would. + * + * @param priority Log priority of this message + * @param data User-supplied data pointer (see libevdev_set_log_function()) + * @param file libevdev source code file generating this message + * @param line libevdev source code line generating this message + * @param func libevdev source code function generating this message + * @param format printf-style format string + * @param args List of arguments + * + * @see libevdev_set_log_function + */ +typedef void (*libevdev_log_func_t)(enum libevdev_log_priority priority, + void *data, + const char *file, int line, + const char *func, + const char *format, va_list args) + LIBEVDEV_ATTRIBUTE_PRINTF(6, 0); + +/** + * @ingroup logging + * + * Set a printf-style logging handler for library-internal logging. The default + * logging function is to stdout. + * + * @note The global log handler is only called if no context-specific log + * handler has been set with libevdev_set_device_log_function(). + * + * @param logfunc The logging function for this device. If NULL, the current + * logging function is unset and no logging is performed. + * @param data User-specific data passed to the log handler. + * + * @note This function may be called before libevdev_set_fd(). + * + * @deprecated Use per-context logging instead, see + * libevdev_set_device_log_function(). + */ +void libevdev_set_log_function(libevdev_log_func_t logfunc, void *data); + +/** + * @ingroup logging + * + * Define the minimum level to be printed to the log handler. + * Messages higher than this level are printed, others are discarded. This + * is a global setting and applies to any future logging messages. + * + * @param priority Minimum priority to be printed to the log. + * + * @deprecated Use per-context logging instead, see + * libevdev_set_device_log_function(). + */ +void libevdev_set_log_priority(enum libevdev_log_priority priority); + +/** + * @ingroup logging + * + * Return the current log priority level. Messages higher than this level + * are printed, others are discarded. This is a global setting. + * + * @return the current log level + * + * @deprecated Use per-context logging instead, see + * libevdev_set_device_log_function(). + */ +enum libevdev_log_priority libevdev_get_log_priority(void); + +/** + * @ingroup logging + * + * Logging function called by library-internal logging for a specific + * libevdev context. This function is expected to treat its input like + * printf would. + * + * @param dev The evdev device + * @param priority Log priority of this message + * @param data User-supplied data pointer (see libevdev_set_log_function()) + * @param file libevdev source code file generating this message + * @param line libevdev source code line generating this message + * @param func libevdev source code function generating this message + * @param format printf-style format string + * @param args List of arguments + * + * @see libevdev_set_log_function + * @since 1.3 + */ +typedef void (*libevdev_device_log_func_t)(const struct libevdev *dev, + enum libevdev_log_priority priority, + void *data, + const char *file, int line, + const char *func, + const char *format, va_list args) + LIBEVDEV_ATTRIBUTE_PRINTF(7, 0); + +/** + * @ingroup logging + * + * Set a printf-style logging handler for library-internal logging for this + * device context. The default logging function is NULL, i.e. the global log + * handler is invoked. If a context-specific log handler is set, the global + * log handler is not invoked for this device. + * + * @note This log function applies for this device context only, even if + * another context exists for the same fd. + * + * @param dev The evdev device + * @param logfunc The logging function for this device. If NULL, the current + * logging function is unset and logging falls back to the global log + * handler, if any. + * @param priority Minimum priority to be printed to the log. + * @param data User-specific data passed to the log handler. + * + * @note This function may be called before libevdev_set_fd(). + * @since 1.3 + */ +void libevdev_set_device_log_function(struct libevdev *dev, + libevdev_device_log_func_t logfunc, + enum libevdev_log_priority priority, + void *data); + +/** + * @ingroup init + */ +enum libevdev_grab_mode { + LIBEVDEV_GRAB = 3, /**< Grab the device if not currently grabbed */ + LIBEVDEV_UNGRAB = 4 /**< Ungrab the device if currently grabbed */ +}; + +/** + * @ingroup init + * + * Grab or ungrab the device through a kernel EVIOCGRAB. This prevents other + * clients (including kernel-internal ones such as rfkill) from receiving + * events from this device. + * + * This is generally a bad idea. Don't do this. + * + * Grabbing an already grabbed device, or ungrabbing an ungrabbed device is + * a noop and always succeeds. + * + * A grab is an operation tied to a file descriptor, not a device. If a + * client changes the file descriptor with libevdev_change_fd(), it must + * also re-issue a grab with libevdev_grab(). + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param grab If true, grab the device. Otherwise ungrab the device. + * + * @return 0 if the device was successfully grabbed or ungrabbed, or a + * negative errno in case of failure. + */ +int libevdev_grab(struct libevdev *dev, enum libevdev_grab_mode grab); + +/** + * @ingroup init + * + * Set the fd for this struct and initialize internal data. + * The fd must be in O_RDONLY or O_RDWR mode. + * + * This function may only be called once per device. If the device changed and + * you need to re-read a device, use libevdev_free() and libevdev_new(). If + * you need to change the fd after closing and re-opening the same device, use + * libevdev_change_fd(). + * + * A caller should ensure that any events currently pending on the fd are + * drained before the file descriptor is passed to libevdev for + * initialization. Due to how the kernel's ioctl handling works, the initial + * device state will reflect the current device state *after* applying all + * events currently pending on the fd. Thus, if the fd is not drained, the + * state visible to the caller will be inconsistent with the events + * immediately available on the device. This does not affect state-less + * events like EV_REL. + * + * Unless otherwise specified, libevdev function behavior is undefined until + * a successful call to libevdev_set_fd(). + * + * @param dev The evdev device + * @param fd The file descriptor for the device + * + * @return 0 on success, or a negative errno on failure + * + * @see libevdev_change_fd + * @see libevdev_new + * @see libevdev_free + */ +int libevdev_set_fd(struct libevdev *dev, int fd); + +/** + * @ingroup init + * + * Change the fd for this device, without re-reading the actual device. If the fd + * changes after initializing the device, for example after a VT-switch in the + * X.org X server, this function updates the internal fd to the newly opened. + * No check is made that new fd points to the same device. If the device has + * changed, libevdev's behavior is undefined. + * + * libevdev does not sync itself after changing the fd and keeps the current + * device state. Use libevdev_next_event with the + * @ref LIBEVDEV_READ_FLAG_FORCE_SYNC flag to force a re-sync. + * + * The example code below illustrates how to force a re-sync of the + * library-internal state. Note that this code doesn't handle the events in + * the caller, it merely forces an update of the internal library state. + * @code + * struct input_event ev; + * libevdev_change_fd(dev, new_fd); + * libevdev_next_event(dev, LIBEVDEV_READ_FLAG_FORCE_SYNC, &ev); + * while (libevdev_next_event(dev, LIBEVDEV_READ_FLAG_SYNC, &ev) == LIBEVDEV_READ_STATUS_SYNC) + * ; // noop + * @endcode + * + * The fd may be open in O_RDONLY or O_RDWR. + * + * After changing the fd, the device is assumed ungrabbed and a caller must + * call libevdev_grab() again. + * + * It is an error to call this function before calling libevdev_set_fd(). + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param fd The new fd + * + * @return 0 on success, or -1 on failure. + * + * @see libevdev_set_fd + */ +int libevdev_change_fd(struct libevdev *dev, int fd); + +/** + * @ingroup init + * + * @param dev The evdev device + * + * @return The previously set fd, or -1 if none had been set previously. + * @note This function may be called before libevdev_set_fd(). + */ +int libevdev_get_fd(const struct libevdev *dev); + +/** + * @ingroup events + */ +enum libevdev_read_status { + /** + * libevdev_next_event() has finished without an error + * and an event is available for processing. + * + * @see libevdev_next_event + */ + LIBEVDEV_READ_STATUS_SUCCESS = 0, + /** + * Depending on the libevdev_next_event() read flag: + * * libevdev received a SYN_DROPPED from the device, and the caller should + * now resync the device, or, + * * an event has been read in sync mode. + * + * @see libevdev_next_event + */ + LIBEVDEV_READ_STATUS_SYNC = 1 +}; + +/** + * @ingroup events + * + * Get the next event from the device. This function operates in two different + * modes: normal mode or sync mode. + * + * In normal mode (when flags has @ref LIBEVDEV_READ_FLAG_NORMAL set), this + * function returns @ref LIBEVDEV_READ_STATUS_SUCCESS and returns the event + * in the argument @p ev. If no events are available at this + * time, it returns -EAGAIN and ev is undefined. + * + * If the current event is an EV_SYN SYN_DROPPED event, this function returns + * @ref LIBEVDEV_READ_STATUS_SYNC and ev is set to the EV_SYN event. + * The caller should now call this function with the + * @ref LIBEVDEV_READ_FLAG_SYNC flag set, to get the set of events that make up the + * device state delta. This function returns @ref LIBEVDEV_READ_STATUS_SYNC for + * each event part of that delta, until it returns -EAGAIN once all events + * have been synced. For more details on what libevdev does to sync after a + * SYN_DROPPED event, see @ref syn_dropped. + * + * If a device needs to be synced by the caller but the caller does not call + * with the @ref LIBEVDEV_READ_FLAG_SYNC flag set, all events from the diff are + * dropped after libevdev updates its internal state and event processing + * continues as normal. Note that the current slot and the state of touch + * points may have updated during the SYN_DROPPED event, it is strongly + * recommended that a caller ignoring all sync events calls + * libevdev_get_current_slot() and checks the ABS_MT_TRACKING_ID values for + * all slots. + * + * If a device has changed state without events being enqueued in libevdev, + * e.g. after changing the file descriptor, use the @ref + * LIBEVDEV_READ_FLAG_FORCE_SYNC flag. This triggers an internal sync of the + * device and libevdev_next_event() returns @ref LIBEVDEV_READ_STATUS_SYNC. + * Any state changes are available as events as described above. If + * @ref LIBEVDEV_READ_FLAG_FORCE_SYNC is set, the value of ev is undefined. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param flags Set of flags to determine behaviour. If @ref LIBEVDEV_READ_FLAG_NORMAL + * is set, the next event is read in normal mode. If @ref LIBEVDEV_READ_FLAG_SYNC is + * set, the next event is read in sync mode. + * @param ev On success, set to the current event. + * @return On failure, a negative errno is returned. + * @retval LIBEVDEV_READ_STATUS_SUCCESS One or more events were read of the + * device and ev points to the next event in the queue + * @retval -EAGAIN No events are currently available on the device + * @retval LIBEVDEV_READ_STATUS_SYNC A SYN_DROPPED event was received, or a + * synced event was returned and ev points to the SYN_DROPPED event + * + * @note This function is signal-safe. + */ +int libevdev_next_event(struct libevdev *dev, unsigned int flags, struct input_event *ev); + +/** + * @ingroup events + * + * Check if there are events waiting for us. This function does not read an + * event off the fd and may not access the fd at all. If there are events + * queued internally this function will return non-zero. If the internal + * queue is empty, this function will poll the file descriptor for data. + * + * This is a convenience function for simple processes, most complex programs + * are expected to use select(2) or poll(2) on the file descriptor. The kernel + * guarantees that if data is available, it is a multiple of sizeof(struct + * input_event), and thus calling libevdev_next_event() when select(2) or + * poll(2) return is safe. You do not need libevdev_has_event_pending() if + * you're using select(2) or poll(2). + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @return On failure, a negative errno is returned. + * @retval 0 No event is currently available + * @retval 1 One or more events are available on the fd + * + * @note This function is signal-safe. + */ +int libevdev_has_event_pending(struct libevdev *dev); + +/** + * @ingroup bits + * + * Retrieve the device's name, either as set by the caller or as read from + * the kernel. The string returned is valid until libevdev_free() or until + * libevdev_set_name(), whichever comes earlier. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * + * @return The device name as read off the kernel device. The name is never + * NULL but it may be the empty string. + * + * @note This function is signal-safe. + */ +const char *libevdev_get_name(const struct libevdev *dev); + +/** + * @ingroup kernel + * + * Change the device's name as returned by libevdev_get_name(). This + * function destroys the string previously returned by libevdev_get_name(), + * a caller must take care that no references are kept. + * + * @param dev The evdev device + * @param name The new, non-NULL, name to assign to this device. + * + * @note This function may be called before libevdev_set_fd(). A call to + * libevdev_set_fd() will overwrite any previously set value. + */ +void libevdev_set_name(struct libevdev *dev, const char *name); + +/** + * @ingroup bits + * + * Retrieve the device's physical location, either as set by the caller or + * as read from the kernel. The string returned is valid until + * libevdev_free() or until libevdev_set_phys(), whichever comes earlier. + * + * Virtual devices such as uinput devices have no phys location. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * + * @return The physical location of this device, or NULL if there is none + * + * @note This function is signal safe. + */ +const char *libevdev_get_phys(const struct libevdev *dev); + +/** + * @ingroup kernel + * + * Change the device's physical location as returned by libevdev_get_phys(). + * This function destroys the string previously returned by + * libevdev_get_phys(), a caller must take care that no references are kept. + * + * @param dev The evdev device + * @param phys The new phys to assign to this device. + * + * @note This function may be called before libevdev_set_fd(). A call to + * libevdev_set_fd() will overwrite any previously set value. + */ +void libevdev_set_phys(struct libevdev *dev, const char *phys); + +/** + * @ingroup bits + * + * Retrieve the device's unique identifier, either as set by the caller or + * as read from the kernel. The string returned is valid until + * libevdev_free() or until libevdev_set_uniq(), whichever comes earlier. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * + * @return The unique identifier for this device, or NULL if there is none + * + * @note This function is signal safe. + */ +const char *libevdev_get_uniq(const struct libevdev *dev); + +/** + * @ingroup kernel + * + * Change the device's unique identifier as returned by libevdev_get_uniq(). + * This function destroys the string previously returned by + * libevdev_get_uniq(), a caller must take care that no references are kept. + * + * @param dev The evdev device + * @param uniq The new uniq to assign to this device. + * + * @note This function may be called before libevdev_set_fd(). A call to + * libevdev_set_fd() will overwrite any previously set value. + */ +void libevdev_set_uniq(struct libevdev *dev, const char *uniq); + +/** + * @ingroup bits + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * + * @return The device's product ID + * + * @note This function is signal-safe. + */ +int libevdev_get_id_product(const struct libevdev *dev); + +/** + * @ingroup kernel + * + * @param dev The evdev device + * @param product_id The product ID to assign to this device + * + * @note This function may be called before libevdev_set_fd(). A call to + * libevdev_set_fd() will overwrite any previously set value. Even though + * the function accepts an int for product_id the value is truncated at 16 + * bits. + */ +void libevdev_set_id_product(struct libevdev *dev, int product_id); + +/** + * @ingroup bits + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * + * @return The device's vendor ID + * + * @note This function is signal-safe. + */ +int libevdev_get_id_vendor(const struct libevdev *dev); + +/** + * @ingroup kernel + * + * @param dev The evdev device + * @param vendor_id The vendor ID to assign to this device + * + * @note This function may be called before libevdev_set_fd(). A call to + * libevdev_set_fd() will overwrite any previously set value. Even though + * the function accepts an int for vendor_id the value is truncated at 16 + * bits. + */ +void libevdev_set_id_vendor(struct libevdev *dev, int vendor_id); + +/** + * @ingroup bits + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * + * @return The device's bus type + * + * @note This function is signal-safe. + */ +int libevdev_get_id_bustype(const struct libevdev *dev); + +/** + * @ingroup kernel + * + * @param dev The evdev device + * @param bustype The bustype to assign to this device + * + * @note This function may be called before libevdev_set_fd(). A call to + * libevdev_set_fd() will overwrite any previously set value. Even though + * the function accepts an int for bustype the value is truncated at 16 + * bits. + */ +void libevdev_set_id_bustype(struct libevdev *dev, int bustype); + +/** + * @ingroup bits + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * + * @return The device's firmware version + * + * @note This function is signal-safe. + */ +int libevdev_get_id_version(const struct libevdev *dev); + +/** + * @ingroup kernel + * + * @param dev The evdev device + * @param version The version to assign to this device + * + * @note This function may be called before libevdev_set_fd(). A call to + * libevdev_set_fd() will overwrite any previously set value. Even though + * the function accepts an int for version the value is truncated at 16 + * bits. + */ +void libevdev_set_id_version(struct libevdev *dev, int version); + +/** + * @ingroup bits + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * + * @return The driver version for this device + * + * @note This function is signal-safe. + */ +int libevdev_get_driver_version(const struct libevdev *dev); + +/** + * @ingroup bits + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param prop The input property to query for, one of INPUT_PROP_... + * + * @return 1 if the device provides this input property, or 0 otherwise. + * + * @note This function is signal-safe + */ +int libevdev_has_property(const struct libevdev *dev, unsigned int prop); + +/** + * @ingroup kernel + * + * @param dev The evdev device + * @param prop The input property to enable, one of INPUT_PROP_... + * + * @return 0 on success or -1 on failure + * + * @note This function may be called before libevdev_set_fd(). A call to + * libevdev_set_fd() will overwrite any previously set value. + */ +int libevdev_enable_property(struct libevdev *dev, unsigned int prop); + +/** + * @ingroup kernel + * + * @param dev The evdev device + * @param prop The input property to disable, one of INPUT_PROP_... + * + * @return 0 on success or -1 on failure + */ +int libevdev_disable_property(struct libevdev *dev, unsigned int prop); + +/** + * @ingroup bits + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param type The event type to query for, one of EV_SYN, EV_REL, etc. + * + * @return 1 if the device supports this event type, or 0 otherwise. + * + * @note This function is signal-safe. + */ +int libevdev_has_event_type(const struct libevdev *dev, unsigned int type); + +/** + * @ingroup bits + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param type The event type for the code to query (EV_SYN, EV_REL, etc.) + * @param code The event code to query for, one of ABS_X, REL_X, etc. + * + * @return 1 if the device supports this event type and code, or 0 otherwise. + * + * @note This function is signal-safe. + */ +int libevdev_has_event_code(const struct libevdev *dev, unsigned int type, unsigned int code); + +/** + * @ingroup bits + * + * Get the minimum axis value for the given axis, as advertised by the kernel. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code The EV_ABS event code to query for, one of ABS_X, ABS_Y, etc. + * + * @return axis minimum for the given axis or 0 if the axis is invalid + * + * @note This function is signal-safe. + */ +int libevdev_get_abs_minimum(const struct libevdev *dev, unsigned int code); + +/** + * @ingroup bits + * + * Get the maximum axis value for the given axis, as advertised by the kernel. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code The EV_ABS event code to query for, one of ABS_X, ABS_Y, etc. + * + * @return axis maximum for the given axis or 0 if the axis is invalid + * + * @note This function is signal-safe. + */ +int libevdev_get_abs_maximum(const struct libevdev *dev, unsigned int code); + +/** + * @ingroup bits + * + * Get the axis fuzz for the given axis, as advertised by the kernel. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code The EV_ABS event code to query for, one of ABS_X, ABS_Y, etc. + * + * @return axis fuzz for the given axis or 0 if the axis is invalid + * + * @note This function is signal-safe. + */ +int libevdev_get_abs_fuzz(const struct libevdev *dev, unsigned int code); + +/** + * @ingroup bits + * + * Get the axis flat for the given axis, as advertised by the kernel. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code The EV_ABS event code to query for, one of ABS_X, ABS_Y, etc. + * + * @return axis flat for the given axis or 0 if the axis is invalid + * + * @note This function is signal-safe. + */ +int libevdev_get_abs_flat(const struct libevdev *dev, unsigned int code); + +/** + * @ingroup bits + * + * Get the axis resolution for the given axis, as advertised by the kernel. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code The EV_ABS event code to query for, one of ABS_X, ABS_Y, etc. + * + * @return axis resolution for the given axis or 0 if the axis is invalid + * + * @note This function is signal-safe. + */ +int libevdev_get_abs_resolution(const struct libevdev *dev, unsigned int code); + +/** + * @ingroup bits + * + * Get the axis info for the given axis, as advertised by the kernel. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code The EV_ABS event code to query for, one of ABS_X, ABS_Y, etc. + * + * @return The input_absinfo for the given code, or NULL if the device does + * not support this event code. + * + * @note This function is signal-safe. + */ +const struct input_absinfo *libevdev_get_abs_info(const struct libevdev *dev, unsigned int code); + +/** + * @ingroup bits + * + * Behaviour of this function is undefined if the device does not provide + * the event. + * + * If the device supports ABS_MT_SLOT, the value returned for any ABS_MT_* + * event code is undefined. Use libevdev_get_slot_value() instead. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param type The event type for the code to query (EV_SYN, EV_REL, etc.) + * @param code The event code to query for, one of ABS_X, REL_X, etc. + * + * @return The current value of the event. + * + * @note This function is signal-safe. + * @note The value for ABS_MT_ events is undefined, use + * libevdev_get_slot_value() instead + * + * @see libevdev_get_slot_value + */ +int libevdev_get_event_value(const struct libevdev *dev, unsigned int type, unsigned int code); + +/** + * @ingroup kernel + * + * Set the value for a given event type and code. This only makes sense for + * some event types, e.g. setting the value for EV_REL is pointless. + * + * This is a local modification only affecting only this representation of + * this device. A future call to libevdev_get_event_value() will return this + * value, unless the value was overwritten by an event. + * + * If the device supports ABS_MT_SLOT, the value set for any ABS_MT_* + * event code is the value of the currently active slot. You should use + * libevdev_set_slot_value() instead. + * + * If the device supports ABS_MT_SLOT and the type is EV_ABS and the code is + * ABS_MT_SLOT, the value must be a positive number less then the number of + * slots on the device. Otherwise, libevdev_set_event_value() returns -1. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param type The event type for the code to query (EV_SYN, EV_REL, etc.) + * @param code The event code to set the value for, one of ABS_X, LED_NUML, etc. + * @param value The new value to set + * + * @return 0 on success, or -1 on failure. + * @retval -1 + * - the device does not have the event type or + * - code enabled, or the code is outside the, or + * - the code is outside the allowed limits for the given type, or + * - the type cannot be set, or + * - the value is not permitted for the given code. + * + * @see libevdev_set_slot_value + * @see libevdev_get_event_value + */ +int libevdev_set_event_value(struct libevdev *dev, unsigned int type, unsigned int code, int value); + +/** + * @ingroup bits + * + * Fetch the current value of the event type. This is a shortcut for + * + * @code + * if (libevdev_has_event_type(dev, t) && libevdev_has_event_code(dev, t, c)) + * val = libevdev_get_event_value(dev, t, c); + * @endcode + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param type The event type for the code to query (EV_SYN, EV_REL, etc.) + * @param code The event code to query for, one of ABS_X, REL_X, etc. + * @param[out] value The current value of this axis returned. + * + * @return If the device supports this event type and code, the return value is + * non-zero and value is set to the current value of this axis. Otherwise, + * 0 is returned and value is unmodified. + * + * @note This function is signal-safe. + * @note The value for ABS_MT_ events is undefined, use + * libevdev_fetch_slot_value() instead + * + * @see libevdev_fetch_slot_value + */ +int libevdev_fetch_event_value(const struct libevdev *dev, unsigned int type, unsigned int code, + int *value); + +/** + * @ingroup mt + * + * Return the current value of the code for the given slot. + * + * The return value is undefined for a slot exceeding the available slots on + * the device, for a code that is not in the permitted ABS_MT range or for a + * device that does not have slots. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param slot The numerical slot number, must be smaller than the total number + * of slots on this device + * @param code The event code to query for, one of ABS_MT_POSITION_X, etc. + * + * @note This function is signal-safe. + * @note The value for events other than ABS_MT_ is undefined, use + * libevdev_fetch_value() instead + * + * @see libevdev_get_event_value + */ +int libevdev_get_slot_value(const struct libevdev *dev, unsigned int slot, unsigned int code); + +/** + * @ingroup kernel + * + * Set the value for a given code for the given slot. + * + * This is a local modification only affecting only this representation of + * this device. A future call to libevdev_get_slot_value() will return this + * value, unless the value was overwritten by an event. + * + * This function does not set event values for axes outside the ABS_MT range, + * use libevdev_set_event_value() instead. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param slot The numerical slot number, must be smaller than the total number + * of slots on this device + * @param code The event code to set the value for, one of ABS_MT_POSITION_X, etc. + * @param value The new value to set + * + * @return 0 on success, or -1 on failure. + * @retval -1 + * - the device does not have the event code enabled, or + * - the code is outside the allowed limits for multitouch events, or + * - the slot number is outside the limits for this device, or + * - the device does not support multitouch events. + * + * @see libevdev_set_event_value + * @see libevdev_get_slot_value + */ +int libevdev_set_slot_value(struct libevdev *dev, unsigned int slot, unsigned int code, int value); + +/** + * @ingroup mt + * + * Fetch the current value of the code for the given slot. This is a shortcut for + * + * @code + * if (libevdev_has_event_type(dev, EV_ABS) && + * libevdev_has_event_code(dev, EV_ABS, c) && + * slot < device->number_of_slots) + * val = libevdev_get_slot_value(dev, slot, c); + * @endcode + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param slot The numerical slot number, must be smaller than the total number + * of slots on this * device + * @param[out] value The current value of this axis returned. + * + * @param code The event code to query for, one of ABS_MT_POSITION_X, etc. + * @return If the device supports this event code, the return value is + * non-zero and value is set to the current value of this axis. Otherwise, or + * if the event code is not an ABS_MT_* event code, 0 is returned and value + * is unmodified. + * + * @note This function is signal-safe. + */ +int libevdev_fetch_slot_value(const struct libevdev *dev, unsigned int slot, unsigned int code, + int *value); + +/** + * @ingroup mt + * + * Get the number of slots supported by this device. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * + * @return The number of slots supported, or -1 if the device does not provide + * any slots + * + * @note A device may provide ABS_MT_SLOT but a total number of 0 slots. Hence + * the return value of -1 for "device does not provide slots at all" + */ +int libevdev_get_num_slots(const struct libevdev *dev); + +/** + * @ingroup mt + * + * Get the currently active slot. This may differ from the value + * an ioctl may return at this time as events may have been read off the fd + * since changing the slot value but those events are still in the buffer + * waiting to be processed. The returned value is the value a caller would + * see if it were to process events manually one-by-one. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * + * @return the currently active slot (logically) + * + * @note This function is signal-safe. + */ +int libevdev_get_current_slot(const struct libevdev *dev); + +/** + * @ingroup kernel + * + * Change the minimum for the given EV_ABS event code, if the code exists. + * This function has no effect if libevdev_has_event_code() returns false for + * this code. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code One of ABS_X, ABS_Y, ... + * @param val The new minimum for this axis + */ +void libevdev_set_abs_minimum(struct libevdev *dev, unsigned int code, int val); + +/** + * @ingroup kernel + * + * Change the maximum for the given EV_ABS event code, if the code exists. + * This function has no effect if libevdev_has_event_code() returns false for + * this code. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code One of ABS_X, ABS_Y, ... + * @param val The new maxium for this axis + */ +void libevdev_set_abs_maximum(struct libevdev *dev, unsigned int code, int val); + +/** + * @ingroup kernel + * + * Change the fuzz for the given EV_ABS event code, if the code exists. + * This function has no effect if libevdev_has_event_code() returns false for + * this code. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code One of ABS_X, ABS_Y, ... + * @param val The new fuzz for this axis + */ +void libevdev_set_abs_fuzz(struct libevdev *dev, unsigned int code, int val); + +/** + * @ingroup kernel + * + * Change the flat for the given EV_ABS event code, if the code exists. + * This function has no effect if libevdev_has_event_code() returns false for + * this code. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code One of ABS_X, ABS_Y, ... + * @param val The new flat for this axis + */ +void libevdev_set_abs_flat(struct libevdev *dev, unsigned int code, int val); + +/** + * @ingroup kernel + * + * Change the resolution for the given EV_ABS event code, if the code exists. + * This function has no effect if libevdev_has_event_code() returns false for + * this code. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code One of ABS_X, ABS_Y, ... + * @param val The new axis resolution + */ +void libevdev_set_abs_resolution(struct libevdev *dev, unsigned int code, int val); + +/** + * @ingroup kernel + * + * Change the abs info for the given EV_ABS event code, if the code exists. + * This function has no effect if libevdev_has_event_code() returns false for + * this code. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code One of ABS_X, ABS_Y, ... + * @param abs The new absolute axis data (min, max, fuzz, flat, resolution) + */ +void +libevdev_set_abs_info(struct libevdev *dev, unsigned int code, const struct input_absinfo *abs); + +/** + * @ingroup kernel + * + * Forcibly enable an event type on this device, even if the underlying + * device does not support it. While this cannot make the device actually + * report such events, it will now return true for libevdev_has_event_type(). + * + * This is a local modification only affecting only this representation of + * this device. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param type The event type to enable (EV_ABS, EV_KEY, ...) + * + * @return 0 on success or -1 otherwise + * + * @see libevdev_has_event_type + */ +int libevdev_enable_event_type(struct libevdev *dev, unsigned int type); + +/** + * @ingroup kernel + * + * Forcibly disable an event type on this device, even if the underlying + * device provides it. This effectively mutes the respective set of + * events. libevdev will filter any events matching this type and none will + * reach the caller. libevdev_has_event_type() will return false for this + * type. + * + * In most cases, a caller likely only wants to disable a single code, not + * the whole type. Use libevdev_disable_event_code() for that. + * + * Disabling EV_SYN will not work. Don't shoot yourself in the foot. + * It hurts. + * + * This is a local modification only affecting only this representation of + * this device. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param type The event type to disable (EV_ABS, EV_KEY, ...) + * + * @return 0 on success or -1 otherwise + * + * @see libevdev_has_event_type + * @see libevdev_disable_event_type + */ +int libevdev_disable_event_type(struct libevdev *dev, unsigned int type); + +/** + * @ingroup kernel + * + * Forcibly enable an event code on this device, even if the underlying + * device does not support it. While this cannot make the device actually + * report such events, it will now return true for libevdev_has_event_code(). + * + * The last argument depends on the type and code: + * - If type is EV_ABS, data must be a pointer to a struct input_absinfo + * containing the data for this axis. + * - If type is EV_REP, data must be a pointer to a int containing the data + * for this axis + * - For all other types, the argument must be NULL. + * + * This function calls libevdev_enable_event_type() if necessary. + * + * This is a local modification only affecting only this representation of + * this device. + * + * If this function is called with a type of EV_ABS and EV_REP on a device + * that already has the given event code enabled, the values in data + * overwrite the previous values. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param type The event type to enable (EV_ABS, EV_KEY, ...) + * @param code The event code to enable (ABS_X, REL_X, etc.) + * @param data If type is EV_ABS, data points to a struct input_absinfo. If type is EV_REP, data + * points to an integer. Otherwise, data must be NULL. + * + * @return 0 on success or -1 otherwise + * + * @see libevdev_enable_event_type + */ +int libevdev_enable_event_code(struct libevdev *dev, unsigned int type, unsigned int code, + const void *data); + +/** + * @ingroup kernel + * + * Forcibly disable an event code on this device, even if the underlying + * device provides it. This effectively mutes the respective set of + * events. libevdev will filter any events matching this type and code and + * none will reach the caller. libevdev_has_event_code() will return false for + * this code. + * + * Disabling all event codes for a given type will not disable the event + * type. Use libevdev_disable_event_type() for that. + * + * This is a local modification only affecting only this representation of + * this device. + * + * Disabling codes of type EV_SYN will not work. Don't shoot yourself in the + * foot. It hurts. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param type The event type to disable (EV_ABS, EV_KEY, ...) + * @param code The event code to disable (ABS_X, REL_X, etc.) + * + * @return 0 on success or -1 otherwise + * + * @see libevdev_has_event_code + * @see libevdev_disable_event_type + */ +int libevdev_disable_event_code(struct libevdev *dev, unsigned int type, unsigned int code); + +/** + * @ingroup kernel + * + * Set the device's EV_ABS axis to the value defined in the abs + * parameter. This will be written to the kernel. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code The EV_ABS event code to modify, one of ABS_X, ABS_Y, etc. + * @param abs Axis info to set the kernel axis to + * + * @return 0 on success, or a negative errno on failure + * + * @see libevdev_enable_event_code + */ +int libevdev_kernel_set_abs_info(struct libevdev *dev, unsigned int code, + const struct input_absinfo *abs); + +/** + * @ingroup kernel + */ +enum libevdev_led_value { + LIBEVDEV_LED_ON = 3, /**< Turn the LED on */ + LIBEVDEV_LED_OFF = 4 /**< Turn the LED off */ +}; + +/** + * @ingroup kernel + * + * Turn an LED on or off. Convenience function, if you need to modify multiple + * LEDs simultaneously, use libevdev_kernel_set_led_values() instead. + * + * @note enabling an LED requires write permissions on the device's file descriptor. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param code The EV_LED event code to modify, one of LED_NUML, LED_CAPSL, ... + * @param value Specifies whether to turn the LED on or off + * @return 0 on success, or a negative errno on failure + */ +int libevdev_kernel_set_led_value(struct libevdev *dev, unsigned int code, + enum libevdev_led_value value); + +/** + * @ingroup kernel + * + * Turn multiple LEDs on or off simultaneously. This function expects a pair + * of LED codes and values to set them to, terminated by a -1. For example, to + * switch the NumLock LED on but the CapsLock LED off, use: + * + * @code + * libevdev_kernel_set_led_values(dev, LED_NUML, LIBEVDEV_LED_ON, + * LED_CAPSL, LIBEVDEV_LED_OFF, + * -1); + * @endcode + * + * If any LED code or value is invalid, this function returns -EINVAL and no + * LEDs are modified. + * + * @note enabling an LED requires write permissions on the device's file descriptor. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param ... A pair of LED_* event codes and libevdev_led_value_t, followed by + * -1 to terminate the list. + * @return 0 on success, or a negative errno on failure + */ +int libevdev_kernel_set_led_values(struct libevdev *dev, ...); + +/** + * @ingroup kernel + * + * Set the clock ID to be used for timestamps. Further events from this device + * will report an event time based on the given clock. + * + * This is a modification only affecting this representation of + * this device. + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param clockid The clock to use for future events. Permitted values + * are CLOCK_MONOTONIC and CLOCK_REALTIME (the default). + * @return 0 on success, or a negative errno on failure + */ +int libevdev_set_clock_id(struct libevdev *dev, int clockid); + +/** + * @ingroup misc + * + * Helper function to check if an event is of a specific type. This is + * virtually the same as: + * + * ev->type == type + * + * with the exception that some sanity checks are performed to ensure type + * is valid. + * + * @note The ranges for types are compiled into libevdev. If the kernel + * changes the max value, libevdev will not automatically pick these up. + * + * @param ev The input event to check + * @param type Input event type to compare the event against (EV_REL, EV_ABS, + * etc.) + * + * @return 1 if the event type matches the given type, 0 otherwise (or if + * type is invalid) + */ +int libevdev_event_is_type(const struct input_event *ev, unsigned int type); + +/** + * @ingroup misc + * + * Helper function to check if an event is of a specific type and code. This + * is virtually the same as: + * + * ev->type == type && ev->code == code + * + * with the exception that some sanity checks are performed to ensure type and + * code are valid. + * + * @note The ranges for types and codes are compiled into libevdev. If the kernel + * changes the max value, libevdev will not automatically pick these up. + * + * @param ev The input event to check + * @param type Input event type to compare the event against (EV_REL, EV_ABS, + * etc.) + * @param code Input event code to compare the event against (ABS_X, REL_X, + * etc.) + * + * @return 1 if the event type matches the given type and code, 0 otherwise + * (or if type/code are invalid) + */ +int libevdev_event_is_code(const struct input_event *ev, unsigned int type, unsigned int code); + +/** + * @ingroup misc + * + * @param type The event type to return the name for. + * + * @return The name of the given event type (e.g. EV_ABS) or NULL for an + * invalid type + * + * @note The list of names is compiled into libevdev. If the kernel adds new + * defines for new event types, libevdev will not automatically pick these up. + */ +const char *libevdev_event_type_get_name(unsigned int type); + +/** + * @ingroup misc + * + * @param type The event type for the code to query (EV_SYN, EV_REL, etc.) + * @param code The event code to return the name for (e.g. ABS_X) + * + * @return The name of the given event code (e.g. ABS_X) or NULL for an + * invalid type or code + * + * @note The list of names is compiled into libevdev. If the kernel adds new + * defines for new event codes, libevdev will not automatically pick these up. + */ +const char *libevdev_event_code_get_name(unsigned int type, unsigned int code); + +/** + * @ingroup misc + * + * This function resolves the event value for a code. + * + * For almost all event codes this will return NULL as the value is just a + * numerical value. As of kernel 4.17, the only event code that will return + * a non-NULL value is EV_ABS/ABS_MT_TOOL_TYPE. + * + * @param type The event type for the value to query (EV_ABS, etc.) + * @param code The event code for the value to query (e.g. ABS_MT_TOOL_TYPE) + * @param value The event value to return the name for (e.g. MT_TOOL_PALM) + * + * @return The name of the given event value (e.g. MT_TOOL_PALM) or NULL for + * an invalid type or code or NULL for an axis that has numerical values + * only. + * + * @note The list of names is compiled into libevdev. If the kernel adds new + * defines for new event values, libevdev will not automatically pick these up. + */ +const char *libevdev_event_value_get_name(unsigned int type, + unsigned int code, + int value); + +/** + * @ingroup misc + * + * @param prop The input prop to return the name for (e.g. INPUT_PROP_BUTTONPAD) + * + * @return The name of the given input prop (e.g. INPUT_PROP_BUTTONPAD) or NULL for an + * invalid property + * + * @note The list of names is compiled into libevdev. If the kernel adds new + * defines for new properties libevdev will not automatically pick these up. + * @note On older kernels input properties may not be defined and + * libevdev_property_get_name() will always return NULL + */ +const char *libevdev_property_get_name(unsigned int prop); + +/** + * @ingroup misc + * + * @param type The event type to return the maximum for (EV_ABS, EV_REL, etc.). No max is defined for + * EV_SYN. + * + * @return The max value defined for the given event type, e.g. ABS_MAX for a type of EV_ABS, or -1 + * for an invalid type. + * + * @note The max value is compiled into libevdev. If the kernel changes the + * max value, libevdev will not automatically pick these up. + */ +int libevdev_event_type_get_max(unsigned int type); + +/** + * @ingroup misc + * + * Look up an event-type by its name. Event-types start with "EV_" followed by + * the name (eg., "EV_ABS"). The "EV_" prefix must be included in the name. It + * returns the constant assigned to the event-type or -1 if not found. + * + * @param name A non-NULL string describing an input-event type ("EV_KEY", + * "EV_ABS", ...), zero-terminated. + * + * @return The given type constant for the passed name or -1 if not found. + * + * @note EV_MAX is also recognized. + */ +int libevdev_event_type_from_name(const char *name); + +/** + * @ingroup misc + * + * Look up an event-type by its name. Event-types start with "EV_" followed by + * the name (eg., "EV_ABS"). The "EV_" prefix must be included in the name. It + * returns the constant assigned to the event-type or -1 if not found. + * + * @param name A non-NULL string describing an input-event type ("EV_KEY", + * "EV_ABS", ...). + * @param len The length of the passed string excluding any terminating 0 + * character. + * + * @return The given type constant for the passed name or -1 if not found. + * + * @note EV_MAX is also recognized. + */ +int libevdev_event_type_from_name_n(const char *name, size_t len); + +/** + * @ingroup misc + * + * Look up an event code by its type and name. Event codes start with a fixed + * prefix followed by their name (eg., "ABS_X"). The prefix must be included in + * the name. It returns the constant assigned to the event code or -1 if not + * found. + * + * You have to pass the event type where to look for the name. For instance, to + * resolve "ABS_X" you need to pass EV_ABS as type and "ABS_X" as string. + * Supported event codes are codes starting with SYN_, KEY_, BTN_, REL_, ABS_, + * MSC_, SND_, SW_, LED_, REP_, FF_. + * + * @param type The event type (EV_* constant) where to look for the name. + * @param name A non-NULL string describing an input-event code ("KEY_A", + * "ABS_X", "BTN_Y", ...), zero-terminated. + * + * @return The given code constant for the passed name or -1 if not found. + */ +int libevdev_event_code_from_name(unsigned int type, const char *name); + +/** + * @ingroup misc + * + * Look up an event code by its type and name. Event codes start with a fixed + * prefix followed by their name (eg., "ABS_X"). The prefix must be included in + * the name. It returns the constant assigned to the event code or -1 if not + * found. + * + * You have to pass the event type where to look for the name. For instance, to + * resolve "ABS_X" you need to pass EV_ABS as type and "ABS_X" as string. + * Supported event codes are codes starting with SYN_, KEY_, BTN_, REL_, ABS_, + * MSC_, SND_, SW_, LED_, REP_, FF_. + * + * @param type The event type (EV_* constant) where to look for the name. + * @param name A non-NULL string describing an input-event code ("KEY_A", + * "ABS_X", "BTN_Y", ...). + * @param len The length of the string in @p name excluding any terminating 0 + * character. + * + * @return The given code constant for the name or -1 if not found. + */ +int libevdev_event_code_from_name_n(unsigned int type, const char *name, + size_t len); + +/** + * @ingroup misc + * + * Look up an event value by its type, code and name. Event values start + * with a fixed prefix followed by their name (eg., "MT_TOOL_PALM"). The + * prefix must be included in the name. It returns the constant assigned + * to the event code or -1 if not found. + * + * You have to pass the event type and code where to look for the name. For + * instance, to resolve "MT_TOOL_PALM" you need to pass EV_ABS as type, + * ABS_MT_TOOL_TYPE as code and "MT_TOOL_PALM" as string. + * + * As of kernel 4.17, only EV_ABS/ABS_MT_TOOL_TYPE support name resolution. + * + * @param type The event type (EV_* constant) where to look for the name. + * @param code The event code (ABS_* constant) where to look for the name. + * @param name A non-NULL string describing an input-event value + * ("MT_TOOL_TYPE", ...) + * + * @return The given value constant for the name or -1 if not found. + */ +int libevdev_event_value_from_name(unsigned int type, unsigned int code, + const char *name); + +/** + * @ingroup misc + * + * Look up an event type for a event code name. For example, the name + * "ABS_Y" returns EV_ABS. For the lookup to succeed, the name must be + * unique, which is the case for all defines as of kernel 5.0 and likely to + * be the case in the future. + * + * This is equivalent to libevdev_event_type_from_name() but takes the code + * name instead of the type name. + * + * @param name A non-NULL string describing an input-event value + * ("ABS_X", "REL_Y", "KEY_A", ...) + * + * @return The given event code for the name or -1 if not found. + */ +int +libevdev_event_type_from_code_name(const char *name); + +/** + * @ingroup misc + * + * Look up an event type for a event code name. For example, the name + * "ABS_Y" returns EV_ABS. For the lookup to succeed, the name must be + * unique, which is the case for all defines as of kernel 5.0 and likely to + * be the case in the future. + * + * This is equivalent to libevdev_event_type_from_name_n() but takes the code + * name instead of the type name. + * + * @param name A non-NULL string describing an input-event value + * ("ABS_X", "REL_Y", "KEY_A", ...) + * @param len The length of the passed string excluding any terminating 0 + * character. + * + * @return The given event code for the name or -1 if not found. + */ +int +libevdev_event_type_from_code_name_n(const char *name, size_t len); + +/** + * @ingroup misc + * + * Look up an event code by its name. For example, the name "ABS_Y" + * returns 1. For the lookup to succeed, the name must be unique, which is + * the case for all defines as of kernel 5.0 and likely to be the case in + * the future. + * + * This is equivalent to libevdev_event_code_from_name() without the need + * for knowing the event type. + * + * @param name A non-NULL string describing an input-event value + * ("ABS_X", "REL_Y", "KEY_A", ...) + * + * @return The given event code for the name or -1 if not found. + */ +int +libevdev_event_code_from_code_name(const char *name); + +/** + * @ingroup misc + * + * Look up an event code by its name. For example, the name "ABS_Y" + * returns 1. For the lookup to succeed, the name must be unique, which is + * the case for all defines as of kernel 5.0 and likely to be the case in + * the future. + * + * This is equivalent to libevdev_event_code_from_name_n() without the need + * for knowing the event type. + * + * @param name A non-NULL string describing an input-event value + * ("ABS_X", "REL_Y", "KEY_A", ...) + * @param len The length of the passed string excluding any terminating 0 + * character. + * + * @return The given event code for the name or -1 if not found. + */ +int +libevdev_event_code_from_code_name_n(const char *name, size_t len); + +/** + * @ingroup misc + * + * Look up an event value by its type, code and name. Event values start + * with a fixed prefix followed by their name (eg., "MT_TOOL_PALM"). The + * prefix must be included in the name. It returns the constant assigned + * to the event code or -1 if not found. + * + * You have to pass the event type and code where to look for the name. For + * instance, to resolve "MT_TOOL_PALM" you need to pass EV_ABS as type, + * ABS_MT_TOOL_TYPE as code and "MT_TOOL_PALM" as string. + * + * As of kernel 4.17, only EV_ABS/ABS_MT_TOOL_TYPE support name resolution. + * + * @param type The event type (EV_* constant) where to look for the name. + * @param code The event code (ABS_* constant) where to look for the name. + * @param name A non-NULL string describing an input-event value + * ("MT_TOOL_TYPE", ...) + * @param len The length of the string in @p name excluding any terminating 0 + * character. + * + * @return The given value constant for the name or -1 if not found. + */ +int libevdev_event_value_from_name_n(unsigned int type, unsigned int code, + const char *name, size_t len); + +/** + * @ingroup misc + * + * Look up an input property by its name. Properties start with the fixed + * prefix "INPUT_PROP_" followed by their name (eg., "INPUT_PROP_POINTER"). + * The prefix must be included in the name. It returns the constant assigned + * to the property or -1 if not found. + * + * @param name A non-NULL string describing an input property + * + * @return The given code constant for the name or -1 if not found. + */ +int libevdev_property_from_name(const char *name); + +/** + * @ingroup misc + * + * Look up an input property by its name. Properties start with the fixed + * prefix "INPUT_PROP_" followed by their name (eg., "INPUT_PROP_POINTER"). + * The prefix must be included in the name. It returns the constant assigned + * to the property or -1 if not found. + * + * @param name A non-NULL string describing an input property + * @param len The length of the string in @p name excluding any terminating 0 + * character. + * + * @return The given code constant for the name or -1 if not found. + */ +int libevdev_property_from_name_n(const char *name, size_t len); + +/** + * @ingroup bits + * + * Get the repeat delay and repeat period values for this device. This + * function is a convenience function only, EV_REP is supported by + * libevdev_get_event_value(). + * + * @param dev The evdev device, already initialized with libevdev_set_fd() + * @param delay If not null, set to the repeat delay value + * @param period If not null, set to the repeat period value + * + * @return 0 on success, -1 if this device does not have repeat settings. + * + * @note This function is signal-safe + * + * @see libevdev_get_event_value + */ +int libevdev_get_repeat(const struct libevdev *dev, int *delay, int *period); + +/********* DEPRECATED SECTION *********/ +#if defined(__GNUC__) && __GNUC__ >= 4 +#define LIBEVDEV_DEPRECATED __attribute__ ((deprecated)) +#else +#define LIBEVDEV_DEPRECATED +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* LIBEVDEV_H */ diff --git a/sysbridge/src/main/cpp/libevdev/libevdev.sym b/sysbridge/src/main/cpp/libevdev/libevdev.sym new file mode 100644 index 0000000000..4161962dc8 --- /dev/null +++ b/sysbridge/src/main/cpp/libevdev/libevdev.sym @@ -0,0 +1,123 @@ +/* SPDX-License-Identifier: MIT */ +/* + * Copyright (c) 2013 David Herrmann + */ + +LIBEVDEV_1 { +global: + libevdev_change_fd; + libevdev_disable_event_code; + libevdev_disable_event_type; + libevdev_enable_event_code; + libevdev_enable_event_type; + libevdev_enable_property; + libevdev_event_code_from_name; + libevdev_event_code_from_name_n; + libevdev_event_code_get_name; + libevdev_event_is_code; + libevdev_event_is_type; + libevdev_event_type_from_name; + libevdev_event_type_from_name_n; + libevdev_event_type_get_max; + libevdev_event_type_get_name; + libevdev_fetch_event_value; + libevdev_fetch_slot_value; + libevdev_free; + libevdev_get_abs_flat; + libevdev_get_abs_fuzz; + libevdev_get_abs_info; + libevdev_get_abs_maximum; + libevdev_get_abs_minimum; + libevdev_get_abs_resolution; + libevdev_get_current_slot; + libevdev_get_driver_version; + libevdev_get_event_value; + libevdev_get_fd; + libevdev_get_id_bustype; + libevdev_get_id_product; + libevdev_get_id_vendor; + libevdev_get_id_version; + libevdev_get_log_priority; + libevdev_get_name; + libevdev_get_num_slots; + libevdev_get_phys; + libevdev_get_repeat; + libevdev_get_slot_value; + libevdev_get_uniq; + libevdev_grab; + libevdev_has_event_code; + libevdev_has_event_pending; + libevdev_has_event_type; + libevdev_has_property; + libevdev_kernel_set_abs_info; + libevdev_kernel_set_led_value; + libevdev_kernel_set_led_values; + libevdev_new; + libevdev_new_from_fd; + libevdev_next_event; + libevdev_property_get_name; + libevdev_set_abs_flat; + libevdev_set_abs_fuzz; + libevdev_set_abs_info; + libevdev_set_abs_maximum; + libevdev_set_abs_minimum; + libevdev_set_abs_resolution; + libevdev_set_clock_id; + libevdev_set_event_value; + libevdev_set_fd; + libevdev_set_id_bustype; + libevdev_set_id_product; + libevdev_set_id_vendor; + libevdev_set_id_version; + libevdev_set_log_function; + libevdev_set_log_priority; + libevdev_set_name; + libevdev_set_phys; + libevdev_set_slot_value; + libevdev_set_uniq; + libevdev_uinput_create_from_device; + libevdev_uinput_destroy; + libevdev_uinput_get_devnode; + libevdev_uinput_get_fd; + libevdev_uinput_get_syspath; + libevdev_uinput_write_event; + +local: + *; +}; + +LIBEVDEV_1_3 { +global: + libevdev_set_device_log_function; + libevdev_property_from_name; + libevdev_property_from_name_n; + +local: + *; +} LIBEVDEV_1; + +LIBEVDEV_1_6 { +global: + libevdev_event_value_get_name; + libevdev_event_value_from_name; + libevdev_event_value_from_name_n; +local: + *; +} LIBEVDEV_1_3; + +LIBEVDEV_1_7 { +global: + libevdev_event_code_from_code_name; + libevdev_event_code_from_code_name_n; + libevdev_event_type_from_code_name; + libevdev_event_type_from_code_name_n; +local: + *; +} LIBEVDEV_1_6; + +LIBEVDEV_1_10 { +global: + libevdev_disable_property; +local: + *; +} LIBEVDEV_1_7; diff --git a/sysbridge/src/main/cpp/libevdev/make-event-names.py b/sysbridge/src/main/cpp/libevdev/make-event-names.py new file mode 100755 index 0000000000..743b4b58b1 --- /dev/null +++ b/sysbridge/src/main/cpp/libevdev/make-event-names.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +# +# Parses linux/input.h scanning for #define KEY_FOO 134 +# Prints C header files or Python files that can be used as +# mapping and lookup tables. +# + +import re +import sys + + +class Bits(object): + def __init__(self): + self.max_codes = {} + + +prefixes = [ + "EV_", + "REL_", + "ABS_", + "KEY_", + "BTN_", + "LED_", + "SND_", + "MSC_", + "SW_", + "FF_", + "SYN_", + "REP_", + "INPUT_PROP_", + "MT_TOOL_", +] + +duplicates = [ + "EV_VERSION", + "BTN_MISC", + "BTN_MOUSE", + "BTN_JOYSTICK", + "BTN_GAMEPAD", + "BTN_DIGI", + "BTN_WHEEL", + "BTN_TRIGGER_HAPPY", + "SW_MAX", + "REP_MAX", +] + +btn_additional = [ + [0, "BTN_A"], + [0, "BTN_B"], + [0, "BTN_X"], + [0, "BTN_Y"], +] + +code_prefixes = [ + "REL_", + "ABS_", + "KEY_", + "BTN_", + "LED_", + "SND_", + "MSC_", + "SW_", + "FF_", + "SYN_", + "REP_", +] + + +def print_bits(bits, prefix): + if not hasattr(bits, prefix): + return + print("static const char * const %s_map[%s_MAX + 1] = {" % (prefix, prefix.upper())) + for val, name in sorted(list(getattr(bits, prefix).items())): + print(" [%s] = \"%s\"," % (name, name)) + if prefix == "key": + for val, name in sorted(list(getattr(bits, "btn").items())): + print(" [%s] = \"%s\"," % (name, name)) + print("};") + print("") + + +def print_map(bits): + print("static const char * const * const event_type_map[EV_MAX + 1] = {") + + for prefix in prefixes: + if prefix in ["BTN_", "EV_", "INPUT_PROP_", "MT_TOOL_"]: + continue + print(" [EV_%s] = %s_map," % (prefix[:-1], prefix[:-1].lower())) + + print("};") + print("") + + print("#if __clang__") + print("#pragma clang diagnostic push") + print("#pragma clang diagnostic ignored \"-Winitializer-overrides\"") + print("#elif __GNUC__") + print("#pragma GCC diagnostic push") + print("#pragma GCC diagnostic ignored \"-Woverride-init\"") + print("#endif") + print("static const int ev_max[EV_MAX + 1] = {") + for val in range(bits.max_codes["EV_MAX"] + 1): + if val in bits.ev: + prefix = bits.ev[val][3:] + if prefix + "_" in prefixes: + print(" %s_MAX," % prefix) + continue + print(" -1,") + print("};") + print("#if __clang__") + print("#pragma clang diagnostic pop /* \"-Winitializer-overrides\" */") + print("#elif __GNUC__") + print("#pragma GCC diagnostic pop /* \"-Woverride-init\" */") + print("#endif") + print("") + + +def print_lookup(bits, prefix): + if not hasattr(bits, prefix): + return + + names = sorted(list(getattr(bits, prefix).items())) + if prefix == "btn": + names = names + btn_additional + + # We need to manually add the _MAX codes because some are + # duplicates + maxname = "%s_MAX" % (prefix.upper()) + if maxname in duplicates: + names.append((bits.max_codes[maxname], maxname)) + + for val, name in sorted(names, key=lambda e: e[1]): + print(" { .name = \"%s\", .value = %s }," % (name, name)) + + +def print_lookup_table(bits): + print("struct name_entry {") + print(" const char *name;") + print(" unsigned int value;") + print("};") + print("") + print("static const struct name_entry tool_type_names[] = {") + print_lookup(bits, "mt_tool") + print("};") + print("") + print("static const struct name_entry ev_names[] = {") + print_lookup(bits, "ev") + print("};") + print("") + + print("static const struct name_entry code_names[] = {") + for prefix in sorted(code_prefixes, key=lambda e: e): + print_lookup(bits, prefix[:-1].lower()) + print("};") + print("") + print("static const struct name_entry prop_names[] = {") + print_lookup(bits, "input_prop") + print("};") + print("") + + +def print_mapping_table(bits): + print("/* THIS FILE IS GENERATED, DO NOT EDIT */") + print("") + print("#ifndef EVENT_NAMES_H") + print("#define EVENT_NAMES_H") + print("") + + for prefix in prefixes: + if prefix == "BTN_": + continue + print_bits(bits, prefix[:-1].lower()) + + print_map(bits) + print_lookup_table(bits) + + print("#endif /* EVENT_NAMES_H */") + + +def parse_define(bits, line): + m = re.match(r"^#define\s+(\w+)\s+(\w+)", line) + if m is None: + return + + name = m.group(1) + + try: + value = int(m.group(2), 0) + except ValueError: + return + + for prefix in prefixes: + if not name.startswith(prefix): + continue + + if name.endswith("_MAX"): + bits.max_codes[name] = value + + if name in duplicates: + return + + attrname = prefix[:-1].lower() + + if not hasattr(bits, attrname): + setattr(bits, attrname, {}) + b = getattr(bits, attrname) + b[value] = name + + +def parse(lines): + bits = Bits() + for line in lines: + if not line.startswith("#define"): + continue + parse_define(bits, line) + + return bits + + +def usage(prog): + print("Usage: %s ".format(prog)) + + +if __name__ == "__main__": + if len(sys.argv) <= 1: + usage(sys.argv[0]) + sys.exit(2) + + from itertools import chain + lines = chain(*[open(f).readlines() for f in sys.argv[1:]]) + bits = parse(lines) + print_mapping_table(bits) diff --git a/sysbridge/src/main/cpp/libevdev_jni.cpp b/sysbridge/src/main/cpp/libevdev_jni.cpp new file mode 100644 index 0000000000..829d5df81a --- /dev/null +++ b/sysbridge/src/main/cpp/libevdev_jni.cpp @@ -0,0 +1,707 @@ +#include +#include +#include +#include +#include +#include +#include "libevdev/libevdev.h" +#include "libevdev/libevdev-uinput.h" +#include "logging.h" +#include "android/input/KeyLayoutMap.h" +#include "android/libbase/result.h" +#include "android/input/InputDevice.h" +#include "aidl/io/github/sds100/keymapper/sysbridge/IEvdevCallback.h" +#include +#include +#include +#include +#include +#include +#include + +using aidl::io::github::sds100::keymapper::sysbridge::IEvdevCallback; + +enum CommandType { + STOP +}; + +struct Command { + CommandType type; +}; + +struct DeviceContext { + struct libevdev *evdev; + struct libevdev_uinput *uinputDev; + struct android::KeyLayoutMap keyLayoutMap; + char devicePath[256]; + int fd; +}; + +static int epollFd = -1; +static std::mutex epollMutex; + +static int commandEventFd = -1; +static std::queue commandQueue; +static std::mutex commandMutex; + +// This maps the file descriptor of an evdev device to its context. +static std::map *evdevDevices = new std::map(); +static std::mutex evdevDevicesMutex; +static std::map *fdToDevicePath = new std::map(); + +#define DEBUG_PROBE false + +jint JNI_OnLoad(JavaVM *vm, void *reserved) { + evdevDevices = new std::map(); + return JNI_VERSION_1_6; +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_io_github_sds100_keymapper_sysbridge_service_SystemBridge_grabEvdevDeviceNative(JNIEnv *env, + jobject thiz, + jstring jDevicePath) { + + const char *devicePath = env->GetStringUTFChars(jDevicePath, nullptr); + if (devicePath == nullptr) { + return false; + } + + bool result = false; + + { + std::lock_guard epollLock(epollMutex); + if (epollFd == -1) { + LOGE("Epoll is not initialized. Cannot grab evdev device."); + return false; + } + + // Lock to prevent concurrent grab/ungrab operations on the same device + std::lock_guard lock(evdevDevicesMutex); + + // Check if device is already grabbed + if (evdevDevices->contains(devicePath)) { + LOGW("Device %s is already grabbed. Maybe it is a virtual uinput device.", + devicePath); + env->ReleaseStringUTFChars(jDevicePath, devicePath); + return false; + } + + // Perform synchronous grab operation + struct libevdev *dev = nullptr; + + // MUST be NONBLOCK so that the loop reading the evdev events eventually returns + // due to an EAGAIN error. + int fd = open(devicePath, O_RDONLY | O_NONBLOCK); + if (fd == -1) { + LOGE("Failed to open device %s: %s", devicePath, strerror(errno)); + env->ReleaseStringUTFChars(jDevicePath, devicePath); + return false; + } + + int rc = libevdev_new_from_fd(fd, &dev); + if (rc != 0) { + LOGE("Failed to create libevdev device from %s: %s", devicePath, strerror(errno)); + close(fd); + env->ReleaseStringUTFChars(jDevicePath, devicePath); + return false; + } + + rc = libevdev_grab(dev, LIBEVDEV_GRAB); + if (rc < 0) { + LOGE("Failed to grab evdev device %s: %s", + libevdev_get_name(dev), strerror(-rc)); + libevdev_free(dev); + close(fd); + env->ReleaseStringUTFChars(jDevicePath, devicePath); + return false; + } + + // Create a dummy InputDeviceIdentifier for key layout loading + android::InputDeviceIdentifier identifier; + identifier.name = std::string(libevdev_get_name(dev)); + identifier.bus = libevdev_get_id_bustype(dev); + identifier.vendor = libevdev_get_id_vendor(dev); + identifier.product = libevdev_get_id_product(dev); + + std::string klPath = android::getInputDeviceConfigurationFilePathByDeviceIdentifier( + identifier, android::InputDeviceConfigurationFileType::KEY_LAYOUT); + + auto klResult = android::KeyLayoutMap::load(klPath, nullptr); + + if (!klResult.ok()) { + LOGE("key layout map not found for device %s", libevdev_get_name(dev)); + libevdev_grab(dev, LIBEVDEV_UNGRAB); + libevdev_free(dev); + close(fd); + env->ReleaseStringUTFChars(jDevicePath, devicePath); + return false; + } + + struct libevdev_uinput *uinputDev = nullptr; + int uinputFd = open("/dev/uinput", O_RDWR); + if (uinputFd < 0) { + LOGE("Failed to open /dev/uinput to clone the device."); + libevdev_grab(dev, LIBEVDEV_UNGRAB); + libevdev_free(dev); + close(fd); + env->ReleaseStringUTFChars(jDevicePath, devicePath); + return false; + } + + rc = libevdev_uinput_create_from_device(dev, uinputFd, &uinputDev); + + if (rc < 0) { + LOGE("Failed to create uinput device from evdev device %s: %s", + libevdev_get_name(dev), strerror(-rc)); + close(uinputFd); + libevdev_grab(dev, LIBEVDEV_UNGRAB); + libevdev_free(dev); + close(fd); + env->ReleaseStringUTFChars(jDevicePath, devicePath); + return false; + } + + DeviceContext context = { + dev, + uinputDev, + *klResult.value(), + {}, // Initialize devicePath array + fd + }; + + strcpy(context.devicePath, devicePath); + + // Already checked at the start of the method whether epoll was running + struct epoll_event event{}; + event.events = EPOLLIN; + event.data.fd = fd; + + if (epoll_ctl(epollFd, EPOLL_CTL_ADD, fd, &event) == -1) { + LOGE("Failed to add new device to epoll: %s", strerror(errno)); + libevdev_uinput_destroy(uinputDev); + libevdev_grab(dev, LIBEVDEV_UNGRAB); + libevdev_free(dev); + close(fd); + env->ReleaseStringUTFChars(jDevicePath, devicePath); + return false; + } + + evdevDevices->insert_or_assign(devicePath, context); + fdToDevicePath->insert_or_assign(fd, devicePath); + result = true; + + LOGI("Grabbed device %s, %s", libevdev_get_name(dev), context.devicePath); + } + + env->ReleaseStringUTFChars(jDevicePath, devicePath); + return result; +} + +struct timeval powerButtonDownTime = {0, 0}; + +/** + * @return Whether the events were all handled by the callback. If the callback dies then this + * returns false. + */ +bool onEpollEvdevEvent(DeviceContext *deviceContext, IEvdevCallback *callback) { + struct input_event inputEvent{}; + + int rc = libevdev_next_event(deviceContext->evdev, LIBEVDEV_READ_FLAG_NORMAL, &inputEvent); + + if (rc < 0) { + if (rc != -EAGAIN) { + LOGE("libevdev_next_event failed with error %d: %s", rc, strerror(-rc)); + } + return rc == -EAGAIN; + } + + do { + if (rc == LIBEVDEV_READ_STATUS_SUCCESS) { // rc == 0 + int32_t outKeycode = -1; + uint32_t outFlags = -1; + + deviceContext->keyLayoutMap.mapKey(inputEvent.code, 0, &outKeycode, &outFlags); + + // Kill the system bridge when power button is held down and released after 10+ seconds. + // 26 = KEYCODE_POWER + if (inputEvent.code == KEY_POWER || outKeycode == 26) { + if (inputEvent.value == 1) { + // Down click + powerButtonDownTime = inputEvent.time; + } else if (inputEvent.value == 0) { + // Up click + + // If held down for 10 seconds or more, kill system bridge. + if (inputEvent.time.tv_sec - powerButtonDownTime.tv_sec >= 10) { + callback->onEmergencyKillSystemBridge(); + exit(0); + } + } + } + + bool returnValue; + ndk::ScopedAStatus callbackResult = callback->onEvdevEvent(deviceContext->devicePath, + inputEvent.time.tv_sec, + inputEvent.time.tv_usec, + inputEvent.type, + inputEvent.code, + inputEvent.value, + outKeycode, + &returnValue); + + if (!callbackResult.isOk()) { + return false; + } + + if (!returnValue) { + libevdev_uinput_write_event(deviceContext->uinputDev, + inputEvent.type, + inputEvent.code, + inputEvent.value); + } + + rc = libevdev_next_event(deviceContext->evdev, LIBEVDEV_READ_FLAG_NORMAL, &inputEvent); + + } else if (rc == LIBEVDEV_READ_STATUS_SYNC) { + rc = libevdev_next_event(deviceContext->evdev, + LIBEVDEV_READ_FLAG_NORMAL | LIBEVDEV_READ_FLAG_SYNC, + &inputEvent); + } + + if (rc < 0 && rc != -EAGAIN) { + LOGE("libevdev_next_event failed with error %d: %s", rc, strerror(-rc)); + return false; + } + } while (rc != -EAGAIN); + + return true; +} + +// Set this to some upper limit. It is unlikely that Key Mapper will be polling +// more than a few evdev devices at once. +static int MAX_EPOLL_EVENTS = 100; + +extern "C" +JNIEXPORT void JNICALL +Java_io_github_sds100_keymapper_sysbridge_service_SystemBridge_startEvdevEventLoop(JNIEnv *env, + jobject thiz, + jobject jCallbackBinder) { + std::unique_lock epollLock(epollMutex); + + if (epollFd != -1 || commandEventFd != -1) { + LOGE("The evdev event loop has already started."); + return; + } + + epollFd = epoll_create1(EPOLL_CLOEXEC); + if (epollFd == -1) { + LOGE("Failed to create epoll fd: %s", strerror(errno)); + return; + } + + commandEventFd = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); + if (commandEventFd == -1) { + LOGE("Failed to create command eventfd: %s", strerror(errno)); + close(epollFd); + return; + } + + struct epoll_event event{}; + event.events = EPOLLIN; + event.data.fd = commandEventFd; + + if (epoll_ctl(epollFd, EPOLL_CTL_ADD, commandEventFd, &event) == -1) { + LOGE("Failed to add command eventfd to epoll: %s", strerror(errno)); + close(epollFd); + close(commandEventFd); + epollLock.unlock(); + return; + } + + epollLock.unlock(); + + AIBinder *callbackAIBinder = AIBinder_fromJavaBinder(env, jCallbackBinder); + const ::ndk::SpAIBinder spBinder(callbackAIBinder); + std::shared_ptr callback = IEvdevCallback::fromBinder(spBinder); + + struct epoll_event events[MAX_EPOLL_EVENTS]; + bool running = true; + + LOGI("Start evdev event loop"); + + ndk::ScopedAStatus callbackResult = callback->onEvdevEventLoopStarted(); + + if (!callbackResult.isOk()) { + LOGE("Callback is dead. Not starting evdev loop."); + return; + } + + while (running) { + int n = epoll_wait(epollFd, events, MAX_EPOLL_EVENTS, -1); + + for (int i = 0; i < n; ++i) { + int fd = events[i].data.fd; + if (fd == commandEventFd) { + uint64_t val; + ssize_t s = read(commandEventFd, &val, sizeof(val)); + + if (s < 0) { + LOGE("Error reading from command event fd: %s", strerror(errno)); + } + + std::vector commandsToProcess; + { + std::lock_guard lock(commandMutex); + while (!commandQueue.empty()) { + commandsToProcess.push_back(commandQueue.front()); + commandQueue.pop(); + } + } + + // Process commands without holding the mutex + for (const auto &cmd: commandsToProcess) { + if (cmd.type == STOP) { + running = false; + break; + } + } + } else { + if ((events[i].events & (EPOLLHUP | EPOLLERR))) { + LOGI("Device disconnected, removing from epoll."); + epoll_ctl(epollFd, EPOLL_CTL_DEL, fd, nullptr); + + std::lock_guard lock(evdevDevicesMutex); + auto it = fdToDevicePath->find(fd); + if (it != fdToDevicePath->end()) { + evdevDevices->erase(it->second); + fdToDevicePath->erase(it); + } + } else { + std::lock_guard lock(evdevDevicesMutex); + auto it = fdToDevicePath->find(fd); + if (it != fdToDevicePath->end()) { + DeviceContext *dc = &evdevDevices->at(it->second); + // If handling the evdev event fails then stop the event loop + // and ungrab all the devices. + bool result = onEpollEvdevEvent(dc, callback.get()); + + if (!result) { + running = false; + break; + } + } + } + } + } + } + + // Cleanup + std::lock_guard lock(evdevDevicesMutex); + + for (auto const &[path, dc]: *evdevDevices) { + libevdev_uinput_destroy(dc.uinputDev); + libevdev_grab(dc.evdev, LIBEVDEV_UNGRAB); + libevdev_free(dc.evdev); + } + + evdevDevices->clear(); + close(commandEventFd); + commandEventFd = -1; + close(epollFd); + epollFd = -1; + + LOGI("Stopped evdev event loop"); +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_io_github_sds100_keymapper_sysbridge_service_SystemBridge_ungrabEvdevDeviceNative(JNIEnv *env, + jobject thiz, + jstring jDevicePath) { + const char *devicePath = env->GetStringUTFChars(jDevicePath, nullptr); + if (devicePath == nullptr) { + return false; + } + + bool result = false; + + { + // Lock to prevent concurrent grab/ungrab operations + std::lock_guard lock(evdevDevicesMutex); + + auto it = evdevDevices->find(devicePath); + if (it != evdevDevices->end()) { + // Remove from epoll first (if event loop is running) + if (epollFd != -1) { + if (epoll_ctl(epollFd, EPOLL_CTL_DEL, it->second.fd, nullptr) == -1) { + LOGW("Failed to remove device from epoll: %s", strerror(errno)); + // Continue with ungrab even if epoll removal fails + } + } + + // Do this before freeing the evdev file descriptor + libevdev_uinput_destroy(it->second.uinputDev); + + // Ungrab the device + libevdev_grab(it->second.evdev, LIBEVDEV_UNGRAB); + + // Free resources + libevdev_free(it->second.evdev); + + // Remove from device map + evdevDevices->erase(it); + fdToDevicePath->erase(it->second.fd); + result = true; + + LOGI("Ungrabbed device %s", devicePath); + } + + if (!result) { + LOGW("Device %s was not found in grabbed devices list", devicePath); + } + } + + env->ReleaseStringUTFChars(jDevicePath, devicePath); + return result; +} + + +extern "C" +JNIEXPORT void JNICALL +Java_io_github_sds100_keymapper_sysbridge_service_SystemBridge_stopEvdevEventLoop(JNIEnv *env, + jobject thiz) { + if (commandEventFd == -1) { + return; + } + + Command cmd = {STOP}; + + std::lock_guard lock(commandMutex); + commandQueue.push(cmd); + + // Notify the event loop + uint64_t val = 1; + ssize_t written = write(commandEventFd, &val, sizeof(val)); + if (written < 0) { + LOGE("Failed to write to commandEventFd: %s", strerror(errno)); + } +} + +extern "C" +JNIEXPORT jboolean JNICALL +Java_io_github_sds100_keymapper_sysbridge_service_SystemBridge_writeEvdevEventNative(JNIEnv *env, + jobject thiz, + jstring jDevicePath, + jint type, + jint code, + jint value) { + const char *devicePath = env->GetStringUTFChars(jDevicePath, nullptr); + if (devicePath == nullptr) { + return false; + } + + bool result = false; + { + auto it = evdevDevices->find(devicePath); + if (it != evdevDevices->end()) { + int rc = libevdev_uinput_write_event(it->second.uinputDev, type, code, value); + if (rc == 0) { + rc = libevdev_uinput_write_event(it->second.uinputDev, EV_SYN, SYN_REPORT, 0); + } + result = (rc == 0); + } + } + + env->ReleaseStringUTFChars(jDevicePath, devicePath); + return result; +} +extern "C" +JNIEXPORT jboolean JNICALL +Java_io_github_sds100_keymapper_sysbridge_service_SystemBridge_ungrabAllEvdevDevicesNative( + JNIEnv *env, + jobject thiz) { + + { + // Lock to prevent concurrent grab/ungrab operations + std::lock_guard lock(evdevDevicesMutex); + std::lock_guard epollLock(epollMutex); + + // Create a copy of the iterator to avoid issues with erasing during iteration + auto devicesCopy = *evdevDevices; + + for (const auto &pair: devicesCopy) { + const DeviceContext &context = pair.second; + + // Remove from epoll first (if event loop is running) + if (epollFd != -1) { + if (epoll_ctl(epollFd, EPOLL_CTL_DEL, context.fd, nullptr) == -1) { + LOGW("Failed to remove device %s from epoll: %s", context.devicePath, + strerror(errno)); + // Continue with ungrab even if epoll removal fails + } + } + + // Do this before freeing the evdev file descriptor + libevdev_uinput_destroy(context.uinputDev); + + // Ungrab the device + libevdev_grab(context.evdev, LIBEVDEV_UNGRAB); + + // Free resources + libevdev_free(context.evdev); + + LOGI("Ungrabbed device %s", context.devicePath); + } + + // Clear all devices from the map + evdevDevices->clear(); + fdToDevicePath->clear(); + } + + return true; +} + +// Helper function to create a Java EvdevDeviceHandle object +jobject +createEvdevDeviceHandle(JNIEnv *env, const char *path, const char *name, int bus, int vendor, + int product) { + // Find the EvdevDeviceHandle class + jclass evdevDeviceHandleClass = env->FindClass( + "io/github/sds100/keymapper/common/models/EvdevDeviceHandle"); + if (evdevDeviceHandleClass == nullptr) { + LOGE("Failed to find EvdevDeviceHandle class"); + return nullptr; + } + + // Get the constructor + jmethodID constructor = env->GetMethodID(evdevDeviceHandleClass, "", + "(Ljava/lang/String;Ljava/lang/String;III)V"); + if (constructor == nullptr) { + LOGE("Failed to find EvdevDeviceHandle constructor"); + return nullptr; + } + + // Create Java strings + jstring jPath = env->NewStringUTF(path); + jstring jName = env->NewStringUTF(name); + + // Create the object + jobject evdevDeviceHandle = env->NewObject(evdevDeviceHandleClass, constructor, jPath, jName, + bus, vendor, product); + + // Clean up local references + env->DeleteLocalRef(jPath); + env->DeleteLocalRef(jName); + env->DeleteLocalRef(evdevDeviceHandleClass); + + return evdevDeviceHandle; +} + +extern "C" +JNIEXPORT jobjectArray JNICALL +Java_io_github_sds100_keymapper_sysbridge_service_SystemBridge_getEvdevDevicesNative(JNIEnv *env, + jobject thiz) { + DIR *dir = opendir("/dev/input"); + + if (dir == nullptr) { + LOGE("Failed to open /dev/input directory"); + return nullptr; + } + + std::vector deviceHandles; + struct dirent *entry; + + std::lock_guard lock(evdevDevicesMutex); + while ((entry = readdir(dir)) != nullptr) { + // Skip . and .. entries + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + + char fullPath[256]; + snprintf(fullPath, sizeof(fullPath), "/dev/input/%s", entry->d_name); + + bool ignoreDevice = false; + + { + // Ignore this device if it is a uinput device we created + for (const auto &pair: *evdevDevices) { + DeviceContext context = pair.second; + const char *uinputDevicePath = libevdev_uinput_get_devnode(context.uinputDev); + + if (strcmp(fullPath, uinputDevicePath) == 0) { + LOGW("Ignoring uinput device %s.", uinputDevicePath); + ignoreDevice = true; + break; + } + } + } + + if (ignoreDevice) { + continue; + } + + int fd = open(fullPath, O_RDONLY); + + if (fd == -1) { + continue; + } + + struct libevdev *dev = nullptr; + int status = libevdev_new_from_fd(fd, &dev); + + if (status != 0) { + LOGE("Failed to open libevdev device from path %s: %s", fullPath, strerror(errno)); + close(fd); + continue; + } + + const char *devName = libevdev_get_name(dev); + int devVendor = libevdev_get_id_vendor(dev); + int devProduct = libevdev_get_id_product(dev); + int devBus = libevdev_get_id_bustype(dev); + + if (DEBUG_PROBE) { + LOGD("Evdev device: %s, bus: %d, vendor: %d, product: %d, path: %s", + devName, devBus, devVendor, devProduct, fullPath); + } + + // Create EvdevDeviceHandle object + jobject deviceHandle = createEvdevDeviceHandle(env, fullPath, devName, devBus, devVendor, + devProduct); + if (deviceHandle != nullptr) { + deviceHandles.push_back(deviceHandle); + } + + libevdev_free(dev); + close(fd); + } + + closedir(dir); + + // Create the Java array + jclass evdevDeviceHandleClass = env->FindClass( + "io/github/sds100/keymapper/common/models/EvdevDeviceHandle"); + if (evdevDeviceHandleClass == nullptr) { + LOGE("Failed to find EvdevDeviceHandle class for array creation"); + return nullptr; + } + + jobjectArray result = env->NewObjectArray(deviceHandles.size(), evdevDeviceHandleClass, + nullptr); + if (result == nullptr) { + LOGE("Failed to create EvdevDeviceHandle array"); + env->DeleteLocalRef(evdevDeviceHandleClass); + return nullptr; + } + + // Fill the array + for (size_t i = 0; i < deviceHandles.size(); i++) { + env->SetObjectArrayElement(result, i, deviceHandles[i]); + env->DeleteLocalRef(deviceHandles[i]); // Clean up local reference + } + + env->DeleteLocalRef(evdevDeviceHandleClass); + return result; +} \ No newline at end of file diff --git a/sysbridge/src/main/cpp/logging.h b/sysbridge/src/main/cpp/logging.h new file mode 100644 index 0000000000..0bc5310dc8 --- /dev/null +++ b/sysbridge/src/main/cpp/logging.h @@ -0,0 +1,30 @@ +#ifndef _LOGGING_H +#define _LOGGING_H + +#include +#include "android/log.h" + +#ifndef LOG_TAG +#define LOG_TAG "KeyMapperSystemBridge" +#endif + +#ifndef NO_LOG +#ifndef NO_DEBUG_LOG +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) +#else +#define LOGD(...) +#endif +#define LOGV(...) __android_log_print(ANDROID_LOG_VERBOSE, LOG_TAG, __VA_ARGS__) +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) +#define PLOGE(fmt, args...) LOGE(fmt " failed with %d: %s", ##args, errno, strerror(errno)) +#else +#define LOGD(...) +#define LOGV(...) +#define LOGI(...) +#define LOGW(...) +#define LOGE(...) +#define PLOGE(fmt, args...) +#endif +#endif // _LOGGING_H diff --git a/sysbridge/src/main/cpp/misc.cpp b/sysbridge/src/main/cpp/misc.cpp new file mode 100644 index 0000000000..e7d2f8ccd7 --- /dev/null +++ b/sysbridge/src/main/cpp/misc.cpp @@ -0,0 +1,195 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "misc.h" + +ssize_t fdgets(char *buf, const size_t size, int fd) { + ssize_t len = 0; + buf[0] = '\0'; + while (len < size - 1) { + ssize_t ret = read(fd, buf + len, 1); + if (ret < 0) + return -1; + if (ret == 0) + break; + if (buf[len] == '\0' || buf[len++] == '\n') { + buf[len] = '\0'; + break; + } + } + buf[len] = '\0'; + buf[size - 1] = '\0'; + return len; +} + +int get_proc_name(int pid, char *name, size_t _size) { + int fd; + ssize_t __size; + + char buf[1024]; + snprintf(buf, sizeof(buf), "/proc/%d/cmdline", pid); + if (access(buf, R_OK) == -1 || (fd = open(buf, O_RDONLY)) == -1) + return 1; + if ((__size = fdgets(buf, sizeof(buf), fd)) == 0) { + snprintf(buf, sizeof(buf), "/proc/%d/comm", pid); + close(fd); + if (access(buf, R_OK) == -1 || (fd = open(buf, O_RDONLY)) == -1) + return 1; + __size = fdgets(buf, sizeof(buf), fd); + } + close(fd); + + if (__size < _size) { + strncpy(name, buf, static_cast(__size)); + name[__size] = '\0'; + } else { + strncpy(name, buf, _size); + name[_size] = '\0'; + } + + return 0; +} + +int is_num(const char *s) { + size_t len = strlen(s); + for (size_t i = 0; i < len; ++i) + if (s[i] < '0' || s[i] > '9') + return 0; + return 1; +} + +int copyfileat(int src_path_fd, const char *src_path, int dst_path_fd, const char *dst_path) { + int src_fd; + int dst_fd; + struct stat stat_buf{}; + int64_t size_remaining; + size_t count; + ssize_t result; + + if ((src_fd = openat(src_path_fd, src_path, O_RDONLY)) == -1) + return -1; + + if (fstat(src_fd, &stat_buf) == -1) + return -1; + + dst_fd = openat(dst_path_fd, dst_path, O_WRONLY | O_CREAT | O_TRUNC, stat_buf.st_mode); + if (dst_fd == -1) { + close(src_fd); + return -1; + } + + size_remaining = stat_buf.st_size; + for (;;) { + if (size_remaining > 0x7ffff000) + count = 0x7ffff000; + else + count = static_cast(size_remaining); + + result = sendfile(dst_fd, src_fd, nullptr, count); + if (result == -1) { + close(src_fd); + close(dst_fd); + unlink(dst_path); + return -1; + } + + size_remaining -= result; + if (size_remaining == 0) { + close(src_fd); + close(dst_fd); + return 0; + } + } +} + +int copyfile(const char *src_path, const char *dst_path) { + return copyfileat(0, src_path, 0, dst_path); +} + +uintptr_t memsearch(const uintptr_t start, const uintptr_t end, const void *value, size_t size) { + uintptr_t _start = start; + while (true) { + if (_start + size >= end) + return 0; + + if (memcmp((const void *) _start, value, size) == 0) + return _start; + + _start += 1; + } +} + +int switch_mnt_ns(int pid) { + char mnt[32]; + snprintf(mnt, sizeof(mnt), "/proc/%d/ns/mnt", pid); + if (access(mnt, R_OK) == -1) return -1; + + int fd = open(mnt, O_RDONLY); + if (fd < 0) return -1; + + int res = setns(fd, 0); + close(fd); + return res; +} + +void foreach_proc(foreach_proc_function *func) { + DIR *dir; + struct dirent *entry; + + if (!(dir = opendir("/proc"))) + return; + + while ((entry = readdir(dir))) { + if (entry->d_type != DT_DIR) continue; + if (!is_num(entry->d_name)) continue; + pid_t pid = atoi(entry->d_name); + func(pid); + } + + closedir(dir); +} + +char *trim(char *str) { + size_t len = 0; + char *frontp = str; + char *endp = nullptr; + + if (str == nullptr) { return nullptr; } + if (str[0] == '\0') { return str; } + + len = strlen(str); + endp = str + len; + + /* Move the front and back pointers to address the first non-whitespace + * characters from each end. + */ + while (isspace((unsigned char) *frontp)) { ++frontp; } + if (endp != frontp) { + while (isspace((unsigned char) *(--endp)) && endp != frontp) {} + } + + if (str + len - 1 != endp) + *(endp + 1) = '\0'; + else if (frontp != str && endp == frontp) + *str = '\0'; + + /* Shift the string so that it starts at str so that if it's dynamically + * allocated, we can still free it on the returned pointer. Note the reuse + * of endp to mean the front of the string buffer now. + */ + endp = str; + if (frontp != str) { + while (*frontp) { *endp++ = *frontp++; } + *endp = '\0'; + } + + return str; +} \ No newline at end of file diff --git a/sysbridge/src/main/cpp/misc.h b/sysbridge/src/main/cpp/misc.h new file mode 100644 index 0000000000..3ab3e87d4a --- /dev/null +++ b/sysbridge/src/main/cpp/misc.h @@ -0,0 +1,14 @@ +#ifndef MISC_H +#define MISC_H + +int copyfile(const char *src_path, const char *dst_path); +uintptr_t memsearch(const uintptr_t start, const uintptr_t end, const void *value, size_t size); +int switch_mnt_ns(int pid); +int get_proc_name(int pid, char *name, size_t _size); + +using foreach_proc_function = void(pid_t); +void foreach_proc(foreach_proc_function *func); + +char *trim(char *str); + +#endif // MISC_H diff --git a/sysbridge/src/main/cpp/selinux.cpp b/sysbridge/src/main/cpp/selinux.cpp new file mode 100644 index 0000000000..9b9ebc63cf --- /dev/null +++ b/sysbridge/src/main/cpp/selinux.cpp @@ -0,0 +1,105 @@ +#include +#include +#include +#include +#include +#include +#include +#include "selinux.h" + +namespace se { + + static int __getcon(char **context) { + int fd = open("/proc/self/attr/current", O_RDONLY | O_CLOEXEC); + if (fd < 0) + return fd; + + char *buf; + size_t size; + int errno_hold; + ssize_t ret; + + size = sysconf(_SC_PAGE_SIZE); + buf = (char *) malloc(size); + if (!buf) { + ret = -1; + goto out; + } + memset(buf, 0, size); + + do { + ret = read(fd, buf, size - 1); + } while (ret < 0 && errno == EINTR); + if (ret < 0) + goto out2; + + if (ret == 0) { + *context = nullptr; + goto out2; + } + + *context = strdup(buf); + if (!(*context)) { + ret = -1; + goto out2; + } + ret = 0; + out2: + free(buf); + out: + errno_hold = errno; + close(fd); + errno = errno_hold; + return 0; + } + + static int __setcon(const char *ctx) { + int fd = open("/proc/self/attr/current", O_WRONLY | O_CLOEXEC); + if (fd < 0) + return fd; + size_t len = strlen(ctx) + 1; + ssize_t rc = write(fd, ctx, len); + close(fd); + return rc != len; + } + + static int __setfilecon(const char *path, const char *ctx) { + int rc = syscall(__NR_setxattr, path, "security.selinux"/*XATTR_NAME_SELINUX*/, ctx, + strlen(ctx) + 1, 0); + if (rc) { + errno = -rc; + return -1; + } + return 0; + } + + static int __selinux_check_access(const char *scon, const char *tcon, + const char *tclass, const char *perm, void *auditdata) { + return 0; + } + + static void __freecon(char *con) { + free(con); + } + + getcon_t *getcon = __getcon; + setcon_t *setcon = __setcon; + setfilecon_t *setfilecon = __setfilecon; + selinux_check_access_t *selinux_check_access = __selinux_check_access; + freecon_t *freecon = __freecon; + + void init() { + if (access("/system/lib/libselinux.so", F_OK) != 0 && access("/system/lib64/libselinux.so", F_OK) != 0) + return; + + void *handle = dlopen("libselinux.so", RTLD_LAZY | RTLD_LOCAL); + if (handle == nullptr) + return; + + getcon = (getcon_t *) dlsym(handle, "getcon"); + setcon = (setcon_t *) dlsym(handle, "setcon"); + setfilecon = (setfilecon_t *) dlsym(handle, "setfilecon"); + selinux_check_access = (selinux_check_access_t *) dlsym(handle, "selinux_check_access"); + freecon = (freecon_t *) (dlsym(handle, "freecon")); + } +} diff --git a/sysbridge/src/main/cpp/selinux.h b/sysbridge/src/main/cpp/selinux.h new file mode 100644 index 0000000000..f5e47eb291 --- /dev/null +++ b/sysbridge/src/main/cpp/selinux.h @@ -0,0 +1,21 @@ +#ifndef SELINUX_H +#define SELINUX_H + +namespace se { + void init(); + + using getcon_t = int(char **); + using setcon_t = int(const char *); + using setfilecon_t = int(const char *, const char *); + using selinux_check_access_t = int(const char *, const char *, const char *, const char *, + void *); + using freecon_t = void(char *); + + extern getcon_t *getcon; + extern setcon_t *setcon; + extern setfilecon_t *setfilecon; + extern selinux_check_access_t *selinux_check_access; + extern freecon_t *freecon; +} + +#endif // SELINUX_H diff --git a/sysbridge/src/main/cpp/starter.cpp b/sysbridge/src/main/cpp/starter.cpp new file mode 100644 index 0000000000..242966261f --- /dev/null +++ b/sysbridge/src/main/cpp/starter.cpp @@ -0,0 +1,304 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "android.h" +#include "misc.h" +#include "selinux.h" +#include "cgroup.h" +#include "logging.h" + +#ifdef DEBUG +#define JAVA_DEBUGGABLE +#endif + +#define perrorf(...) fprintf(stderr, __VA_ARGS__) + +#define EXIT_FATAL_SET_CLASSPATH 3 +#define EXIT_FATAL_FORK 4 +#define EXIT_FATAL_APP_PROCESS 5 +#define EXIT_FATAL_UID 6 +#define EXIT_FATAL_PM_PATH 7 +#define EXIT_FATAL_KILL 9 +#define EXIT_FATAL_BINDER_BLOCKED_BY_SELINUX 10 + +#define SERVER_NAME "keymapper_sysbridge" +#define SERVER_CLASS_PATH "io.github.sds100.keymapper.sysbridge.service.SystemBridge" + +#if defined(__arm__) +#define ABI "armeabi-v7a" +#elif defined(__i386__) +#define ABI "x86" +#elif defined(__x86_64__) +#define ABI "x86_64" +#elif defined(__aarch64__) +#define ABI "arm64-v8a" +#endif + +static void run_server(const char *apk_path, const char *lib_path, const char *main_class, + const char *process_name, const char *package_name, + const char *version_code) { + if (setenv("CLASSPATH", apk_path, true)) { + LOGE("can't set CLASSPATH\n"); + exit(EXIT_FATAL_SET_CLASSPATH); + } + +#define ARG(v) char **v = nullptr; \ + char buf_##v[PATH_MAX]; \ + size_t v_size = 0; \ + uintptr_t v_current = 0; +#define ARG_PUSH(v, arg) v_size += sizeof(char *); \ +if (v == nullptr) { \ + v = (char **) malloc(v_size); \ +} else { \ + v = (char **) realloc(v, v_size);\ +} \ +v_current = (uintptr_t) v + v_size - sizeof(char *); \ +*((char **) v_current) = arg ? strdup(arg) : nullptr; + +#define ARG_END(v) ARG_PUSH(v, nullptr) + +#define ARG_PUSH_FMT(v, fmt, ...) snprintf(buf_##v, PATH_MAX, fmt, __VA_ARGS__); \ + ARG_PUSH(v, buf_##v) + +#ifdef JAVA_DEBUGGABLE +#define ARG_PUSH_DEBUG_ONLY(v, arg) ARG_PUSH(v, arg) +#define ARG_PUSH_DEBUG_VM_PARAMS(v) \ + if (android::GetApiLevel() >= 30) { \ + ARG_PUSH(v, "-Xcompiler-option"); \ + ARG_PUSH(v, "--debuggable"); \ + ARG_PUSH(v, "-XjdwpProvider:adbconnection"); \ + ARG_PUSH(v, "-XjdwpOptions:suspend=n,server=y"); \ + } else if (android::GetApiLevel() >= 28) { \ + ARG_PUSH(v, "-Xcompiler-option"); \ + ARG_PUSH(v, "--debuggable"); \ + ARG_PUSH(v, "-XjdwpProvider:internal"); \ + ARG_PUSH(v, "-XjdwpOptions:transport=dt_android_adb,suspend=n,server=y"); \ + } else { \ + ARG_PUSH(v, "-Xcompiler-option"); \ + ARG_PUSH(v, "--debuggable"); \ + ARG_PUSH(v, "-agentlib:jdwp=transport=dt_android_adb,suspend=n,server=y"); \ + } +#else +#define ARG_PUSH_DEBUG_VM_PARAMS(v) +#define ARG_PUSH_DEBUG_ONLY(v, arg) +#endif + + ARG(argv) + ARG_PUSH(argv, "/system/bin/app_process") + ARG_PUSH_FMT(argv, "-Djava.class.path=%s", apk_path) + ARG_PUSH_FMT(argv, "-Dkeymapper_sysbridge.library.path=%s", lib_path) + ARG_PUSH_FMT(argv, "-Dkeymapper_sysbridge.package=%s", package_name) + ARG_PUSH_FMT(argv, "-Dkeymapper_sysbridge.version=%s", version_code) + ARG_PUSH_DEBUG_VM_PARAMS(argv) + ARG_PUSH(argv, "/system/bin") + ARG_PUSH_FMT(argv, "--nice-name=%s", process_name) + ARG_PUSH(argv, main_class) + ARG_PUSH_DEBUG_ONLY(argv, "--debug") + ARG_END(argv) + + LOGD("exec app_process"); + + if (execvp((const char *) argv[0], argv)) { + exit(EXIT_FATAL_APP_PROCESS); + } +} + +static void start_server(const char *apk_path, const char *lib_path, const char *main_class, + const char *process_name, const char *package_name, + const char *version_code) { + + if (daemon(false, false) == 0) { + LOGD("child"); + run_server(apk_path, lib_path, main_class, process_name, package_name, version_code); + } else { + LOGE("fatal: can't fork"); + exit(EXIT_FATAL_FORK); + } +} + +static int check_selinux(const char *s, const char *t, const char *c, const char *p) { + int res = se::selinux_check_access(s, t, c, p, nullptr); +#ifndef DEBUG + if (res != 0) { +#endif + LOGI("selinux_check_access %s %s %s %s: %d", s, t, c, p, res); +#ifndef DEBUG + } +#endif + return res; +} + +static int switch_cgroup() { + int s_cuid, s_cpid; + int spid = getpid(); + + if (cgroup::get_cgroup(spid, &s_cuid, &s_cpid) != 0) { + LOGW("can't read cgroup"); + return -1; + } + + LOGI("cgroup is /uid_%d/pid_%d", s_cuid, s_cpid); + + if (cgroup::switch_cgroup(spid, -1, -1) != 0) { + LOGW("can't switch cgroup"); + return -1; + } + + if (cgroup::get_cgroup(spid, &s_cuid, &s_cpid) != 0) { + LOGI("switch cgroup succeeded"); + return 0; + } + + LOGW("can't switch self, current cgroup is /uid_%d/pid_%d", s_cuid, s_cpid); + return -1; +} + +char *context = nullptr; + +int starter_main(int argc, char *argv[]) { + char *apk_path = nullptr; + char *lib_path = nullptr; + char *package_name = nullptr; + char *version = nullptr; + + // Get the apk path from the program arguments. This gets the path by setting the + // start of the apk path array to after the "--apk=" by offsetting by 6 characters. + for (int i = 0; i < argc; ++i) { + if (strncmp(argv[i], "--apk=", 6) == 0) { + apk_path = argv[i] + 6; + } else if (strncmp(argv[i], "--lib=", 6) == 0) { + lib_path = argv[i] + 6; + } else if (strncmp(argv[i], "--package=", 10) == 0) { + package_name = argv[i] + 10; + } else if (strncmp(argv[i], "--version=", 10) == 0) { + version = argv[i] + 10; + } + } + + LOGI("apk path = %s", apk_path); + LOGI("lib path = %s", lib_path); + LOGI("package name = %s", package_name); + LOGI("version = %s", version); + + int uid = getuid(); + if (uid != 0 && uid != 2000) { + LOGE("fatal: run system bridge from non root nor adb user (uid=%d).", uid); + exit(EXIT_FATAL_UID); + } + + se::init(); + + if (uid == 0) { + chown("/data/local/tmp/keymapper_sysbridge_starter", 2000, 2000); + se::setfilecon("/data/local/tmp/keymapper_sysbridge_starter", + "u:object_r:shell_data_file:s0"); + switch_cgroup(); + + int sdkLevel = 0; + char buf[PROP_VALUE_MAX + 1]; + if (__system_property_get("ro.build.version.sdk", buf) > 0) + sdkLevel = atoi(buf); + + if (sdkLevel >= 29) { + LOGI("switching mount namespace to init..."); + switch_mnt_ns(1); + } + } + + if (uid == 0) { + if (se::getcon(&context) == 0) { + int res = 0; + + res |= check_selinux("u:r:untrusted_app:s0", context, "binder", "call"); + res |= check_selinux("u:r:untrusted_app:s0", context, "binder", "transfer"); + + if (res != 0) { + LOGE("fatal: the su you are using does not allow app (u:r:untrusted_app:s0) to connect to su (%s) with binder.", + context); + exit(EXIT_FATAL_BINDER_BLOCKED_BY_SELINUX); + } + se::freecon(context); + } + } + + LOGI("starter begin"); + + // kill old server + LOGI("killing old process..."); + + foreach_proc([](pid_t pid) { + if (pid == getpid()) return; + + char name[1024]; + if (get_proc_name(pid, name, 1024) != 0) return; + + if (strcmp(SERVER_NAME, name) != 0) return; + + if (kill(pid, SIGKILL) == 0) + LOGI("killed %d (%s)", pid, name); + else if (errno == EPERM) { + LOGE("fatal: can't kill %d, please try to stop existing sysbridge from app first.", + pid); + exit(EXIT_FATAL_KILL); + } else { + LOGW("failed to kill %d (%s)", pid, name); + } + }); + + if (access(apk_path, R_OK) == 0) { + LOGI("use apk path from argv"); + } + + if (access(lib_path, R_OK) == 0) { + LOGI("use lib path from argv"); + } + + if (!apk_path) { + LOGE("fatal: can't get path of manager"); + exit(EXIT_FATAL_PM_PATH); + } + + if (!lib_path) { + LOGE("fatal: can't get path of native libraries"); + exit(EXIT_FATAL_PM_PATH); + } + + if (access(apk_path, R_OK) != 0) { + LOGE("fatal: can't access manager %s", apk_path); + exit(EXIT_FATAL_PM_PATH); + } + + LOGI("starting server..."); + LOGD("start_server"); + start_server(apk_path, lib_path, SERVER_CLASS_PATH, SERVER_NAME, package_name, version); + exit(EXIT_SUCCESS); +} + +using main_func = int (*)(int, char *[]); + +static main_func applet_main[] = {starter_main, nullptr}; + +int main(int argc, char **argv) { + std::string_view base = basename(argv[0]); + + LOGD("applet %s", base.data()); + + constexpr const char *applet_names[] = {"keymapper_sysbridge_starter", nullptr}; + + for (int i = 0; applet_names[i]; ++i) { + if (base == applet_names[i]) { + return (*applet_main[i])(argc, argv); + } + } + + return 1; +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/SystemBridgeHiltModule.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/SystemBridgeHiltModule.kt new file mode 100644 index 0000000000..6a36d57570 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/SystemBridgeHiltModule.kt @@ -0,0 +1,34 @@ +package io.github.sds100.keymapper.sysbridge + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.github.sds100.keymapper.sysbridge.adb.AdbManager +import io.github.sds100.keymapper.sysbridge.adb.AdbManagerImpl +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManagerImpl +import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupController +import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupControllerImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class SystemBridgeHiltModule { + + @Singleton + @Binds + abstract fun bindSystemBridgeSetupController( + impl: SystemBridgeSetupControllerImpl, + ): SystemBridgeSetupController + + @Singleton + @Binds + abstract fun bindSystemBridgeManager( + impl: SystemBridgeConnectionManagerImpl, + ): SystemBridgeConnectionManager + + @Singleton + @Binds + abstract fun bindAdbManager(impl: AdbManagerImpl): AdbManager +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbClient.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbClient.kt new file mode 100644 index 0000000000..523668d790 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbClient.kt @@ -0,0 +1,220 @@ +package io.github.sds100.keymapper.sysbridge.adb + +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.ADB_AUTH_RSAPUBLICKEY +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.ADB_AUTH_SIGNATURE +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_AUTH +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_CLSE +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_CNXN +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_MAXDATA +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_OKAY +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_OPEN +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_STLS +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_STLS_VERSION +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_VERSION +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_WRTE +import java.io.Closeable +import java.io.DataInputStream +import java.io.DataOutputStream +import java.net.ConnectException +import java.net.Socket +import java.net.SocketException +import java.nio.ByteBuffer +import java.nio.ByteOrder +import javax.net.ssl.SSLProtocolException +import javax.net.ssl.SSLSocket +import kotlinx.coroutines.delay +import timber.log.Timber + +private const val TAG = "AdbClient" + +internal class AdbClient(private val host: String, private val port: Int, private val key: AdbKey) : + Closeable { + + private var socket: Socket? = null + private lateinit var plainInputStream: DataInputStream + private lateinit var plainOutputStream: DataOutputStream + + private var useTls = false + + private lateinit var tlsSocket: SSLSocket + private lateinit var tlsInputStream: DataInputStream + private lateinit var tlsOutputStream: DataOutputStream + + private val inputStream get() = if (useTls) tlsInputStream else plainInputStream + private val outputStream get() = if (useTls) tlsOutputStream else plainOutputStream + + suspend fun connect(): KMResult { + Timber.d("Connecting to ADB at $host:$port") + var connectAttemptCounter = 0 + + // Try to connect to the client multiple times in case the server hasn't started up + // yet + while (socket == null && connectAttemptCounter < 5) { + try { + socket = Socket(host, port) + } catch (_: ConnectException) { + delay(1000) + connectAttemptCounter++ + continue + } + } + + if (socket == null) { + return AdbError.ConnectionError + } + + socket!!.tcpNoDelay = true + plainInputStream = DataInputStream(socket!!.getInputStream()) + plainOutputStream = DataOutputStream(socket!!.getOutputStream()) + + try { + write(A_CNXN, A_VERSION, A_MAXDATA, "host::") + + var message = read() + if (message.command == A_STLS) { + write(A_STLS, A_STLS_VERSION, 0) + + val sslContext = key.sslContext + tlsSocket = + sslContext.socketFactory.createSocket(socket, host, port, true) as SSLSocket + tlsSocket.startHandshake() + Timber.d("Handshake succeeded.") + + tlsInputStream = DataInputStream(tlsSocket.inputStream) + tlsOutputStream = DataOutputStream(tlsSocket.outputStream) + useTls = true + + message = read() + } else if (message.command == A_AUTH) { + write(A_AUTH, ADB_AUTH_SIGNATURE, 0, key.sign(message.data)) + + message = read() + if (message.command != A_CNXN) { + write(A_AUTH, ADB_AUTH_RSAPUBLICKEY, 0, key.adbPublicKey) + message = read() + } + } + + if (message.command != A_CNXN) { + error("not A_CNXN") + } + } catch (e: SSLProtocolException) { + // This can be thrown if the encryption keys mismatch + return AdbError.SslHandshakeError + } catch (e: SocketException) { + return AdbError.ConnectionError + } + + return Success(Unit) + } + + fun shellCommand(command: String, listener: (ByteArray) -> Unit) { + val localId = 1 + write(A_OPEN, localId, 0, "shell:$command") + + var message = read() + when (message.command) { + A_OKAY -> { + while (true) { + message = read() + + val remoteId = message.arg0 + if (message.command == A_WRTE) { + if (message.data_length > 0) { + listener(message.data!!) + } + write(A_OKAY, localId, remoteId) + } else if (message.command == A_CLSE) { + write(A_CLSE, localId, remoteId) + break + } else { + error("not A_WRTE or A_CLSE") + } + } + } + + A_CLSE -> { + val remoteId = message.arg0 + write(A_CLSE, localId, remoteId) + } + + else -> { + error("not A_OKAY or A_CLSE") + } + } + } + + private fun write(command: Int, arg0: Int, arg1: Int, data: ByteArray? = null) = write( + AdbMessage(command, arg0, arg1, data), + ) + + private fun write(command: Int, arg0: Int, arg1: Int, data: String) = write( + AdbMessage( + command, + arg0, + arg1, + data, + ), + ) + + private fun write(message: AdbMessage) { + outputStream.write(message.toByteArray()) + outputStream.flush() + } + + private fun read(): AdbMessage { + val buffer = + ByteBuffer.allocate(AdbMessage.HEADER_LENGTH).order(ByteOrder.LITTLE_ENDIAN) + + inputStream.readFully(buffer.array(), 0, 24) + + val command = buffer.int + val arg0 = buffer.int + val arg1 = buffer.int + val dataLength = buffer.int + val checksum = buffer.int + val magic = buffer.int + val data: ByteArray? + if (dataLength >= 0) { + data = ByteArray(dataLength) + inputStream.readFully(data, 0, dataLength) + } else { + data = null + } + val message = AdbMessage(command, arg0, arg1, dataLength, checksum, magic, data) + message.validateOrThrow() + return message + } + + override fun close() { + try { + plainInputStream.close() + } catch (e: Throwable) { + } + try { + plainOutputStream.close() + } catch (e: Throwable) { + } + try { + socket?.close() + } catch (e: Exception) { + } + + if (useTls) { + try { + tlsInputStream.close() + } catch (e: Throwable) { + } + try { + tlsOutputStream.close() + } catch (e: Throwable) { + } + try { + tlsSocket.close() + } catch (e: Exception) { + } + } + } +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbError.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbError.kt new file mode 100644 index 0000000000..e8fbf882b6 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbError.kt @@ -0,0 +1,12 @@ +package io.github.sds100.keymapper.sysbridge.adb + +import io.github.sds100.keymapper.common.utils.KMError + +sealed class AdbError : KMError() { + data object PairingError : AdbError() + data object ServerNotFound : AdbError() + data object KeyCreationError : AdbError() + data object ConnectionError : AdbError() + data object SslHandshakeError : AdbError() + data class Unknown(val exception: kotlin.Exception) : AdbError() +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbException.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbException.kt new file mode 100644 index 0000000000..486118ad5a --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbException.kt @@ -0,0 +1,16 @@ +package io.github.sds100.keymapper.sysbridge.adb + +@Suppress("NOTHING_TO_INLINE") +internal inline fun adbError(message: Any): Nothing = throw AdbException(message.toString()) + +internal open class AdbException : Exception { + + constructor(message: String, cause: Throwable?) : super(message, cause) + constructor(message: String) : super(message) + constructor(cause: Throwable) : super(cause) + constructor() +} + +internal class AdbInvalidPairingCodeException : AdbException() + +internal class AdbKeyException(cause: Throwable) : AdbException(cause) diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbKey.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbKey.kt new file mode 100644 index 0000000000..9e315389b3 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbKey.kt @@ -0,0 +1,395 @@ +package io.github.sds100.keymapper.sysbridge.adb + +import android.annotation.SuppressLint +import android.content.SharedPreferences +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.annotation.RequiresApi +import androidx.core.content.edit +import java.io.ByteArrayInputStream +import java.math.BigInteger +import java.net.Socket +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.Key +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.Principal +import java.security.PrivateKey +import java.security.SecureRandom +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.RSAKeyGenParameterSpec +import java.security.spec.RSAPublicKeySpec +import java.util.Date +import java.util.Locale +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.spec.GCMParameterSpec +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLEngine +import javax.net.ssl.X509ExtendedKeyManager +import javax.net.ssl.X509ExtendedTrustManager +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import org.bouncycastle.cert.X509v3CertificateBuilder +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import org.conscrypt.Conscrypt +import rikka.core.ktx.unsafeLazy + +private const val TAG = "AdbKey" + +@RequiresApi(Build.VERSION_CODES.M) +internal class AdbKey(private val adbKeyStore: AdbKeyStore, name: String) { + + companion object { + + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val ENCRYPTION_KEY_ALIAS = "_adbkey_encryption_key_" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + + private const val IV_SIZE_IN_BYTES = 12 + private const val TAG_SIZE_IN_BYTES = 16 + + private val PADDING = byteArrayOf( + 0x00, 0x01, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0x00, + 0x30, 0x21, 0x30, 0x09, 0x06, 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x1a, 0x05, 0x00, + 0x04, 0x14, + ) + } + + private val encryptionKey: Key + + private val privateKey: RSAPrivateKey + private val publicKey: RSAPublicKey + private val certificate: X509Certificate + + init { + this.encryptionKey = getOrCreateEncryptionKey() + ?: error("Failed to generate encryption key with AndroidKeyManager.") + + this.privateKey = getOrCreatePrivateKey() + this.publicKey = KeyFactory.getInstance("RSA").generatePublic( + RSAPublicKeySpec( + privateKey.modulus, + RSAKeyGenParameterSpec.F4, + ), + ) as RSAPublicKey + + val signer = JcaContentSignerBuilder("SHA256withRSA").build(privateKey) + val x509Certificate = X509v3CertificateBuilder( + X500Name("CN=00"), + BigInteger.ONE, + Date(0), + Date(2461449600 * 1000), + Locale.ROOT, + X500Name("CN=00"), + SubjectPublicKeyInfo.getInstance(publicKey.encoded), + ).build(signer) + this.certificate = CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(x509Certificate.encoded)) as X509Certificate + +// Log.d(TAG, privateKey.toString()) + } + + val adbPublicKey: ByteArray by unsafeLazy { + publicKey.adbEncoded(name) + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun getOrCreateEncryptionKey(): Key? { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + + return keyStore.getKey(ENCRYPTION_KEY_ALIAS, null) ?: run { + val parameterSpec = KeyGenParameterSpec.Builder( + ENCRYPTION_KEY_ALIAS, + KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_ENCRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + val keyGenerator = + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + keyGenerator.init(parameterSpec) + keyGenerator.generateKey() + } + } + + private fun encrypt(plaintext: ByteArray, aad: ByteArray?): ByteArray? { + if (plaintext.size > Int.MAX_VALUE - IV_SIZE_IN_BYTES - TAG_SIZE_IN_BYTES) { + return null + } + val ciphertext = ByteArray(IV_SIZE_IN_BYTES + plaintext.size + TAG_SIZE_IN_BYTES) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, encryptionKey) + cipher.updateAAD(aad) + cipher.doFinal(plaintext, 0, plaintext.size, ciphertext, IV_SIZE_IN_BYTES) + System.arraycopy(cipher.iv, 0, ciphertext, 0, IV_SIZE_IN_BYTES) + return ciphertext + } + + private fun decrypt(ciphertext: ByteArray, aad: ByteArray?): ByteArray? { + if (ciphertext.size < IV_SIZE_IN_BYTES + TAG_SIZE_IN_BYTES) { + return null + } + val params = GCMParameterSpec(8 * TAG_SIZE_IN_BYTES, ciphertext, 0, IV_SIZE_IN_BYTES) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, encryptionKey, params) + cipher.updateAAD(aad) + return cipher.doFinal(ciphertext, IV_SIZE_IN_BYTES, ciphertext.size - IV_SIZE_IN_BYTES) + } + + private fun getOrCreatePrivateKey(): RSAPrivateKey { + var privateKey: RSAPrivateKey? = null + + val aad = ByteArray(16) + "adbkey".toByteArray().copyInto(aad) + + var ciphertext = adbKeyStore.get() + if (ciphertext != null) { + try { + val plaintext = decrypt(ciphertext, aad) + + val keyFactory = KeyFactory.getInstance("RSA") + privateKey = + keyFactory.generatePrivate(PKCS8EncodedKeySpec(plaintext)) as RSAPrivateKey + } catch (e: Exception) { + } + } + if (privateKey == null) { + val keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA) + keyPairGenerator.initialize(RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4)) + val keyPair = keyPairGenerator.generateKeyPair() + privateKey = keyPair.private as RSAPrivateKey + + ciphertext = encrypt(privateKey.encoded, aad) + if (ciphertext != null) { + adbKeyStore.put(ciphertext) + } + } + return privateKey + } + + fun sign(data: ByteArray?): ByteArray { + val cipher = Cipher.getInstance("RSA/ECB/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, privateKey) + cipher.update(PADDING) + return cipher.doFinal(data) + } + + private val keyManager + get() = object : X509ExtendedKeyManager() { + private val alias = "key" + + override fun chooseClientAlias( + keyTypes: Array, + issuers: Array?, + socket: Socket?, + ): String? { +// Log.d( +// TAG, +// "chooseClientAlias: keyType=${keyTypes.contentToString()}, issuers=${issuers?.contentToString()}", +// ) + for (keyType in keyTypes) { + if (keyType == "RSA") return alias + } + return null + } + + override fun getCertificateChain(alias: String?): Array? { +// Log.d(TAG, "getCertificateChain: alias=$alias") + return if (alias == this.alias) arrayOf(certificate) else null + } + + override fun getPrivateKey(alias: String?): PrivateKey? { +// Log.d(TAG, "getPrivateKey: alias=$alias") + return if (alias == this.alias) privateKey else null + } + + override fun getClientAliases( + keyType: String?, + issuers: Array?, + ): Array? { + return null + } + + override fun getServerAliases( + keyType: String, + issuers: Array?, + ): Array? { + return null + } + + override fun chooseServerAlias( + keyType: String, + issuers: Array?, + socket: Socket?, + ): String? { + return null + } + } + + private val trustManager + get() = + @RequiresApi(Build.VERSION_CODES.R) + object : X509ExtendedTrustManager() { + + @SuppressLint("TrustAllX509TrustManager") + override fun checkClientTrusted( + chain: Array?, + authType: String?, + socket: Socket?, + ) { + } + + @SuppressLint("TrustAllX509TrustManager") + override fun checkClientTrusted( + chain: Array?, + authType: String?, + engine: SSLEngine?, + ) { + } + + @SuppressLint("TrustAllX509TrustManager") + override fun checkClientTrusted( + chain: Array?, + authType: String?, + ) { + } + + @SuppressLint("TrustAllX509TrustManager") + override fun checkServerTrusted( + chain: Array?, + authType: String?, + socket: Socket?, + ) { + } + + @SuppressLint("TrustAllX509TrustManager") + override fun checkServerTrusted( + chain: Array?, + authType: String?, + engine: SSLEngine?, + ) { + } + + @SuppressLint("TrustAllX509TrustManager") + override fun checkServerTrusted( + chain: Array?, + authType: String?, + ) { + } + + override fun getAcceptedIssuers(): Array { + return emptyArray() + } + } + + @delegate:RequiresApi(Build.VERSION_CODES.R) + val sslContext: SSLContext by unsafeLazy { + val sslContext = SSLContext.getInstance("TLSv1.3", Conscrypt.newProvider()) + sslContext.init( + arrayOf(keyManager), + arrayOf(trustManager), + SecureRandom(), + ) + sslContext + } +} + +interface AdbKeyStore { + + fun put(bytes: ByteArray) + + fun get(): ByteArray? +} + +class PreferenceAdbKeyStore(private val preference: SharedPreferences) : AdbKeyStore { + + private val preferenceKey = "adbkey" + + override fun put(bytes: ByteArray) { + preference.edit { putString(preferenceKey, String(Base64.encode(bytes, Base64.NO_WRAP))) } + } + + override fun get(): ByteArray? { + if (!preference.contains(preferenceKey)) return null + return Base64.decode(preference.getString(preferenceKey, null), Base64.NO_WRAP) + } +} + +const val ANDROID_PUBKEY_MODULUS_SIZE = 2048 / 8 +const val ANDROID_PUBKEY_MODULUS_SIZE_WORDS = ANDROID_PUBKEY_MODULUS_SIZE / 4 +const val RSA_PUBLIC_KEY_SIZE = 524 + +private fun BigInteger.toAdbEncoded(): IntArray { + // little-endian integer with padding zeros in the end + + val endcoded = IntArray(ANDROID_PUBKEY_MODULUS_SIZE_WORDS) + val r32 = BigInteger.ZERO.setBit(32) + + var tmp = this.add(BigInteger.ZERO) + for (i in 0 until ANDROID_PUBKEY_MODULUS_SIZE_WORDS) { + val out = tmp.divideAndRemainder(r32) + tmp = out[0] + endcoded[i] = out[1].toInt() + } + return endcoded +} + +private fun RSAPublicKey.adbEncoded(name: String): ByteArray { + // https://cs.android.com/android/platform/superproject/+/android-10.0.0_r30:system/core/libcrypto_utils/android_pubkey.c + + /* + typedef struct RSAPublicKey { + uint32_t modulus_size_words; // ANDROID_PUBKEY_MODULUS_SIZE + uint32_t n0inv; // n0inv = -1 / N[0] mod 2^32 + uint8_t modulus[ANDROID_PUBKEY_MODULUS_SIZE]; + uint8_t rr[ANDROID_PUBKEY_MODULUS_SIZE]; // rr = (2^(rsa_size)) ^ 2 mod N + uint32_t exponent; + } RSAPublicKey; + */ + + val r32 = BigInteger.ZERO.setBit(32) + val n0inv = modulus.remainder(r32).modInverse(r32).negate() + val r = BigInteger.ZERO.setBit(ANDROID_PUBKEY_MODULUS_SIZE * 8) + val rr = r.modPow(BigInteger.valueOf(2), modulus) + + val buffer = ByteBuffer.allocate(RSA_PUBLIC_KEY_SIZE).order(ByteOrder.LITTLE_ENDIAN) + buffer.putInt(ANDROID_PUBKEY_MODULUS_SIZE_WORDS) + buffer.putInt(n0inv.toInt()) + modulus.toAdbEncoded().forEach { buffer.putInt(it) } + rr.toAdbEncoded().forEach { buffer.putInt(it) } + buffer.putInt(publicExponent.toInt()) + + val base64Bytes = Base64.encode(buffer.array(), Base64.NO_WRAP) + val nameBytes = " $name\u0000".toByteArray() + val bytes = ByteArray(base64Bytes.size + nameBytes.size) + base64Bytes.copyInto(bytes) + nameBytes.copyInto(bytes, base64Bytes.size) + return bytes +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbManager.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbManager.kt new file mode 100644 index 0000000000..6a9b938788 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbManager.kt @@ -0,0 +1,131 @@ +package io.github.sds100.keymapper.sysbridge.adb + +import android.content.Context +import android.os.Build +import android.preference.PreferenceManager +import androidx.annotation.RequiresApi +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.success +import io.github.sds100.keymapper.common.utils.then +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber + +@RequiresApi(Build.VERSION_CODES.R) +@Singleton +class AdbManagerImpl @Inject constructor(@ApplicationContext private val ctx: Context) : + AdbManager { + companion object { + private const val LOCALHOST = "127.0.0.1" + } + + private val commandMutex: Mutex = Mutex() + private val pairMutex: Mutex = Mutex() + + private val adbConnectMdns: AdbMdns by lazy { AdbMdns(ctx, AdbServiceType.TLS_CONNECT) } + private val adbPairMdns: AdbMdns by lazy { AdbMdns(ctx, AdbServiceType.TLS_PAIR) } + + override suspend fun executeCommand(command: String): KMResult { + Timber.d("Execute ADB command: $command") + + val result = withContext(Dispatchers.IO) { + return@withContext commandMutex.withLock { + val port = adbConnectMdns.discoverPort() + + if (port == null || port == -1) { + return@withLock AdbError.ServerNotFound + } + + val adbKey = getAdbKey() + + // Recreate a new client every time in case the port changes during the lifetime + // of AdbManager + val client: AdbClient = when (adbKey) { + is KMError -> return@withLock adbKey + is Success -> AdbClient(LOCALHOST, port, adbKey.value) + } + + return@withLock with(client) { + connect().then { + val output = buildString { + try { + client.shellCommand(command) { append(String(it)) } + } catch (e: Exception) { + Timber.e(e) + AdbError.Unknown(e) + } + } + + Success(output) + } + } + } + } + + Timber.i("Execute command result: $result") + + return result + } + + override suspend fun pair(code: String): KMResult { + return pairMutex.withLock { + val port = adbPairMdns.discoverPort() + + if (port == null) { + return@withLock AdbError.ServerNotFound + } + + return@withLock getAdbKey().then { key -> + val pairingClient = AdbPairingClient(LOCALHOST, port, code, key) + + with(pairingClient) { + try { + withContext(Dispatchers.IO) { + start() + } + Timber.i( + "Successfully paired with wireless ADB on port $port with code $code", + ) + Success(Unit) + } catch (e: Exception) { + e.printStackTrace() + Timber.e( + "Failed to pair with wireless ADB on port $port with code $code: $e", + ) + AdbError.PairingError + } + } + } + } + } + + private fun getAdbKey(): KMResult { + try { + return AdbKey( + PreferenceAdbKeyStore(PreferenceManager.getDefaultSharedPreferences(ctx)), + "keymapper", + ).success() + } catch (e: Throwable) { + Timber.e(e) + return AdbError.KeyCreationError + } + } +} + +interface AdbManager { + /** + * Execute an ADB command + */ + @RequiresApi(Build.VERSION_CODES.R) + suspend fun executeCommand(command: String): KMResult + + @RequiresApi(Build.VERSION_CODES.R) + suspend fun pair(code: String): KMResult +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbMdns.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbMdns.kt new file mode 100644 index 0000000000..dde3da9a28 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbMdns.kt @@ -0,0 +1,203 @@ +package io.github.sds100.keymapper.sysbridge.adb + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.os.Build +import android.os.ext.SdkExtensions +import androidx.annotation.RequiresApi +import java.io.IOException +import java.net.InetSocketAddress +import java.net.NetworkInterface +import java.net.ServerSocket +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import timber.log.Timber + +/** + * This uses mDNS to scan for the ADB pairing and connection ports. + */ +@RequiresApi(Build.VERSION_CODES.R) +internal class AdbMdns(ctx: Context, private val serviceType: AdbServiceType) { + + private val nsdManager: NsdManager = ctx.getSystemService(NsdManager::class.java) + + private var serviceDiscoveredChannel: Channel? = null + + /** + * Only one service can be resolved at a time. + * A null value is sent if the service failed to resolve. + */ + private var serviceResolvedChannel: Channel? = null + + private val isDiscovering: MutableStateFlow = MutableStateFlow(false) + private val discoveredPort: MutableStateFlow = MutableStateFlow(null) + private val discoverMutex: Mutex = Mutex() + + private val resolveListener: NsdManager.ResolveListener = object : NsdManager.ResolveListener { + override fun onResolveFailed(nsdServiceInfo: NsdServiceInfo, i: Int) { + serviceResolvedChannel?.trySendBlocking(null) + } + + override fun onServiceResolved(nsdServiceInfo: NsdServiceInfo) { + Timber.d( + "onServiceResolved: ${nsdServiceInfo.serviceName} ${nsdServiceInfo.host} ${nsdServiceInfo.port} ${nsdServiceInfo.serviceType}", + ) + serviceResolvedChannel?.trySendBlocking(nsdServiceInfo) + } + + override fun onResolutionStopped(serviceInfo: NsdServiceInfo) { + super.onResolutionStopped(serviceInfo) + + serviceResolvedChannel?.trySendBlocking(null) + } + + override fun onStopResolutionFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + super.onStopResolutionFailed(serviceInfo, errorCode) + + serviceResolvedChannel?.trySendBlocking(null) + } + } + + private val discoveryListener: NsdManager.DiscoveryListener = + object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String) { + Timber.d("onDiscoveryStarted: $serviceType") + isDiscovering.update { true } + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Timber.d("onStartDiscoveryFailed: $serviceType, $errorCode") + isDiscovering.update { false } + } + + override fun onDiscoveryStopped(serviceType: String) { + Timber.d("onDiscoveryStopped: $serviceType") + isDiscovering.update { false } + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Timber.d("onStopDiscoveryFailed: $serviceType, $errorCode") + isDiscovering.update { false } + } + + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + Timber.d( + "onServiceFound: ${serviceInfo.serviceName} ${serviceInfo.host} ${serviceInfo.port} ${serviceInfo.serviceType}", + ) + + // You can only resolve one service at a time and they can take some time to resolve. + serviceDiscoveredChannel?.trySend(serviceInfo) + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + Timber.d("onServiceLost: ${serviceInfo.serviceName}") + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun discoverPort(): Int? { + discoverMutex.withLock { + val currentPort = discoveredPort.value + + if (currentPort == null || !isPortAvailable(currentPort)) { + val port = withContext(Dispatchers.IO) { + discoverPortInternal() + } + discoveredPort.value = port + return port + } else { + return currentPort + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private suspend fun discoverPortInternal(): Int? { + var port: Int? = null + + cleanup() + + // Wait for it to stop discovering + isDiscovering.first { !it } + + serviceDiscoveredChannel = Channel(capacity = 10) + serviceResolvedChannel = Channel(capacity = 1) + + nsdManager.discoverServices( + serviceType.id, + NsdManager.PROTOCOL_DNS_SD, + discoveryListener, + ) + + try { + withTimeout(10000L) { + while (port == null) { + val service = serviceDiscoveredChannel?.receive() + nsdManager.resolveService(service, resolveListener) + + val resolvedService = serviceResolvedChannel?.receive() + + if (resolvedService == null) { + continue + } + + val isLocalNetwork = NetworkInterface.getNetworkInterfaces() + .asSequence() + .any { networkInterface -> + networkInterface.inetAddresses + .asSequence() + .any { resolvedService.host.hostAddress == it.hostAddress } + } + + if (isLocalNetwork && isPortAvailable(resolvedService.port)) { + Timber.d("Discovered ADB port: ${resolvedService.port}") + port = resolvedService.port + } + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to discover ADB port") + } finally { + cleanup() + } + + return port + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun cleanup() { + runCatching { + if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.TIRAMISU) >= 7) { + nsdManager.stopServiceResolution(resolveListener) + } + } + + runCatching { + nsdManager.stopServiceDiscovery(discoveryListener) + } + + serviceResolvedChannel?.cancel() + serviceDiscoveredChannel?.cancel() + } + + private fun isPortAvailable(port: Int): Boolean { + return try { + ServerSocket().use { + it.bind(InetSocketAddress("127.0.0.1", port), 1) + false + } + } catch (e: IOException) { + true + } + } +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbMessage.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbMessage.kt new file mode 100644 index 0000000000..50faf83108 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbMessage.kt @@ -0,0 +1,137 @@ +package io.github.sds100.keymapper.sysbridge.adb + +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_AUTH +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_CLSE +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_CNXN +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_OKAY +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_OPEN +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_STLS +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_SYNC +import io.github.sds100.keymapper.sysbridge.adb.AdbProtocol.A_WRTE +import java.nio.ByteBuffer +import java.nio.ByteOrder + +internal class AdbMessage( + val command: Int, + val arg0: Int, + val arg1: Int, + val data_length: Int, + val data_crc32: Int, + val magic: Int, + val data: ByteArray?, +) { + + constructor(command: Int, arg0: Int, arg1: Int, data: String) : this( + command, + arg0, + arg1, + "$data\u0000".toByteArray(), + ) + + constructor(command: Int, arg0: Int, arg1: Int, data: ByteArray?) : this( + command, + arg0, + arg1, + data?.size ?: 0, + crc32(data), + (command.toLong() xor 0xFFFFFFFF).toInt(), + data, + ) + + fun validate(): Boolean { + if (command != magic xor -0x1) return false + if (data_length != 0 && crc32(data) != data_crc32) return false + return true + } + + fun validateOrThrow() { + if (!validate()) throw IllegalArgumentException("bad message ${this.toStringShort()}") + } + + fun toByteArray(): ByteArray { + val length = HEADER_LENGTH + (data?.size ?: 0) + return ByteBuffer.allocate(length).apply { + order(ByteOrder.LITTLE_ENDIAN) + putInt(command) + putInt(arg0) + putInt(arg1) + putInt(data_length) + putInt(data_crc32) + putInt(magic) + if (data != null) { + put(data) + } + }.array() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AdbMessage + + if (command != other.command) return false + if (arg0 != other.arg0) return false + if (arg1 != other.arg1) return false + if (data_length != other.data_length) return false + if (data_crc32 != other.data_crc32) return false + if (magic != other.magic) return false + if (data != null) { + if (other.data == null) return false + if (!data.contentEquals(other.data)) return false + } else if (other.data != null) { + return false + } + + return true + } + + override fun hashCode(): Int { + var result = command + result = 31 * result + arg0 + result = 31 * result + arg1 + result = 31 * result + data_length + result = 31 * result + data_crc32 + result = 31 * result + magic + result = 31 * result + (data?.contentHashCode() ?: 0) + return result + } + + override fun toString(): String { + return "AdbMessage(${toStringShort()})" + } + + fun toStringShort(): String { + val commandString = when (command) { + A_SYNC -> "A_SYNC" + A_CNXN -> "A_CNXN" + A_AUTH -> "A_AUTH" + A_OPEN -> "A_OPEN" + A_OKAY -> "A_OKAY" + A_CLSE -> "A_CLSE" + A_WRTE -> "A_WRTE" + A_STLS -> "A_STLS" + else -> command.toString() + } + @Suppress("ktlint:standard:max-line-length") + return "command=$commandString, arg0=$arg0, arg1=$arg1, data_length=$data_length, data_crc32=$data_crc32, magic=$magic, data=${data?.contentToString()}" + } + + companion object { + + const val HEADER_LENGTH = 24 + + private fun crc32(data: ByteArray?): Int { + if (data == null) return 0 + var res = 0 + for (b in data) { + if (b >= 0) { + res += b + } else { + res += b + 256 + } + } + return res + } + } +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbPairingClient.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbPairingClient.kt new file mode 100644 index 0000000000..15c91a43f3 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbPairingClient.kt @@ -0,0 +1,330 @@ +package io.github.sds100.keymapper.sysbridge.adb + +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import java.io.Closeable +import java.io.DataInputStream +import java.io.DataOutputStream +import java.net.Socket +import java.nio.ByteBuffer +import java.nio.ByteOrder +import javax.net.ssl.SSLSocket +import org.conscrypt.Conscrypt + +private const val TAG = "AdbPairClient" + +private const val CURRENT_KEY_HEADER_VERSION = 1.toByte() +private const val MIN_SUPPORTED_KEY_HEADER_VERSION = 1.toByte() +private const val MAX_SUPPORTED_KEY_HEADER_VERSION = 1.toByte() +private const val MAX_PEER_INFO_SIZE = 8192 +private const val MAX_PAYLOAD_SIZE = MAX_PEER_INFO_SIZE * 2 + +private const val EXPORTED_KEY_LABEL = "adb-label\u0000" +private const val EXPORTED_KEY_SIZE = 64 + +private const val PAIRING_PACKET_HEADER_SIZE = 6 + +private class PeerInfo(val type: Byte, data: ByteArray) { + + val data = ByteArray(MAX_PEER_INFO_SIZE - 1) + + init { + data.copyInto(this.data, 0, 0, data.size.coerceAtMost(MAX_PEER_INFO_SIZE - 1)) + } + + enum class Type(val value: Byte) { + ADB_RSA_PUB_KEY(0.toByte()), + ADB_DEVICE_GUID(0.toByte()), + } + + fun writeTo(buffer: ByteBuffer) { + buffer.run { + put(type) + put(data) + } + +// Log.d(TAG, "write PeerInfo ${toStringShort()}") + } + + override fun toString(): String { + return "PeerInfo(${toStringShort()})" + } + + fun toStringShort(): String { + return "type=$type, data=${data.contentToString()}" + } + + companion object { + + fun readFrom(buffer: ByteBuffer): PeerInfo { + val type = buffer.get() + val data = ByteArray(MAX_PEER_INFO_SIZE - 1) + buffer.get(data) + return PeerInfo(type, data) + } + } +} + +private class PairingPacketHeader(val version: Byte, val type: Byte, val payload: Int) { + + enum class Type(val value: Byte) { + SPAKE2_MSG(0.toByte()), + PEER_INFO(1.toByte()), + } + + fun writeTo(buffer: ByteBuffer) { + buffer.run { + put(version) + put(type) + putInt(payload) + } + +// Log.d(TAG, "write PairingPacketHeader ${toStringShort()}") + } + + override fun toString(): String { + return "PairingPacketHeader(${toStringShort()})" + } + + fun toStringShort(): String { + return "version=${version.toInt()}, type=${type.toInt()}, payload=$payload" + } + + companion object { + + fun readFrom(buffer: ByteBuffer): PairingPacketHeader? { + val version = buffer.get() + val type = buffer.get() + val payload = buffer.int + + if (version < MIN_SUPPORTED_KEY_HEADER_VERSION || + version > MAX_SUPPORTED_KEY_HEADER_VERSION + ) { + Log.e( + TAG, + "PairingPacketHeader version mismatch (us=$CURRENT_KEY_HEADER_VERSION them=$version)", + ) + return null + } + if (type != Type.SPAKE2_MSG.value && type != Type.PEER_INFO.value) { + Log.e(TAG, "Unknown PairingPacket type=$type") + return null + } + if (payload <= 0 || payload > MAX_PAYLOAD_SIZE) { + Log.e(TAG, "header payload not within a safe payload size (size=$payload)") + return null + } + + val header = PairingPacketHeader(version, type, payload) + Log.d(TAG, "read PairingPacketHeader ${header.toStringShort()}") + return header + } + } +} + +private class PairingContext private constructor(private val nativePtr: Long) { + + val msg: ByteArray + + init { + msg = nativeMsg(nativePtr) + } + + fun initCipher(theirMsg: ByteArray) = nativeInitCipher(nativePtr, theirMsg) + + fun encrypt(`in`: ByteArray) = nativeEncrypt(nativePtr, `in`) + + fun decrypt(`in`: ByteArray) = nativeDecrypt(nativePtr, `in`) + + fun destroy() = nativeDestroy(nativePtr) + + private external fun nativeMsg(nativePtr: Long): ByteArray + + private external fun nativeInitCipher(nativePtr: Long, theirMsg: ByteArray): Boolean + + private external fun nativeEncrypt(nativePtr: Long, inbuf: ByteArray): ByteArray? + + private external fun nativeDecrypt(nativePtr: Long, inbuf: ByteArray): ByteArray? + + private external fun nativeDestroy(nativePtr: Long) + + companion object { + + fun create(password: ByteArray): PairingContext? { + val nativePtr = nativeConstructor(true, password) + return if (nativePtr != 0L) PairingContext(nativePtr) else null + } + + @JvmStatic + private external fun nativeConstructor(isClient: Boolean, password: ByteArray): Long + } +} + +@RequiresApi(Build.VERSION_CODES.R) +internal class AdbPairingClient( + private val host: String, + private val port: Int, + private val pairCode: String, + private val key: AdbKey, +) : Closeable { + + private enum class State { + Ready, + ExchangingMsgs, + ExchangingPeerInfo, + Stopped, + } + + private lateinit var socket: Socket + private lateinit var inputStream: DataInputStream + private lateinit var outputStream: DataOutputStream + + private val peerInfo: PeerInfo = PeerInfo(PeerInfo.Type.ADB_RSA_PUB_KEY.value, key.adbPublicKey) + private lateinit var pairingContext: PairingContext + private var state: State = State.Ready + + fun start(): Boolean { + setupTlsConnection() + + state = State.ExchangingMsgs + + if (!doExchangeMsgs()) { + state = State.Stopped + return false + } + + state = State.ExchangingPeerInfo + + if (!doExchangePeerInfo()) { + state = State.Stopped + return false + } + + state = State.Stopped + return true + } + + private fun setupTlsConnection() { + socket = Socket(host, port) + socket.tcpNoDelay = true + + val sslContext = key.sslContext + + val sslSocket = sslContext.socketFactory.createSocket(socket, host, port, true) as SSLSocket + sslSocket.startHandshake() +// Log.d(TAG, "Handshake succeeded.") + + inputStream = DataInputStream(sslSocket.inputStream) + outputStream = DataOutputStream(sslSocket.outputStream) + + val pairCodeBytes = pairCode.toByteArray() + val keyMaterial = Conscrypt.exportKeyingMaterial( + sslSocket, + EXPORTED_KEY_LABEL, + null, + EXPORTED_KEY_SIZE, + ) + val passwordBytes = ByteArray(pairCode.length + keyMaterial.size) + pairCodeBytes.copyInto(passwordBytes) + keyMaterial.copyInto(passwordBytes, pairCodeBytes.size) + + val pairingContext = PairingContext.create(passwordBytes) + checkNotNull(pairingContext) { "Unable to create PairingContext." } + this.pairingContext = pairingContext + } + + private fun createHeader( + type: PairingPacketHeader.Type, + payloadSize: Int, + ): PairingPacketHeader { + return PairingPacketHeader(CURRENT_KEY_HEADER_VERSION, type.value, payloadSize) + } + + private fun readHeader(): PairingPacketHeader? { + val bytes = ByteArray(PAIRING_PACKET_HEADER_SIZE) + inputStream.readFully(bytes) + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + return PairingPacketHeader.readFrom(buffer) + } + + private fun writeHeader(header: PairingPacketHeader, payload: ByteArray) { + val buffer = ByteBuffer.allocate(PAIRING_PACKET_HEADER_SIZE).order(ByteOrder.BIG_ENDIAN) + header.writeTo(buffer) + + outputStream.write(buffer.array()) + outputStream.write(payload) +// Log.d(TAG, "write payload, size=${payload.size}") + } + + private fun doExchangeMsgs(): Boolean { + val msg = pairingContext.msg + val size = msg.size + + val ourHeader = createHeader(PairingPacketHeader.Type.SPAKE2_MSG, size) + writeHeader(ourHeader, msg) + + val theirHeader = readHeader() ?: return false + if (theirHeader.type != PairingPacketHeader.Type.SPAKE2_MSG.value) return false + + val theirMessage = ByteArray(theirHeader.payload) + inputStream.readFully(theirMessage) + + return pairingContext.initCipher(theirMessage) + } + + private fun doExchangePeerInfo(): Boolean { + val buf = ByteBuffer.allocate(MAX_PEER_INFO_SIZE).order(ByteOrder.BIG_ENDIAN) + peerInfo.writeTo(buf) + + val outbuf = pairingContext.encrypt(buf.array()) ?: return false + + val ourHeader = createHeader(PairingPacketHeader.Type.PEER_INFO, outbuf.size) + writeHeader(ourHeader, outbuf) + + val theirHeader = readHeader() ?: return false + if (theirHeader.type != PairingPacketHeader.Type.PEER_INFO.value) return false + + val theirMessage = ByteArray(theirHeader.payload) + inputStream.readFully(theirMessage) + + val decrypted = + pairingContext.decrypt(theirMessage) ?: throw AdbInvalidPairingCodeException() + if (decrypted.size != MAX_PEER_INFO_SIZE) { +// Log.e(TAG, "Got size=${decrypted.size} PeerInfo.size=$kMaxPeerInfoSize") + return false + } + PeerInfo.readFrom(ByteBuffer.wrap(decrypted)) +// Log.d(TAG, theirPeerInfo.toString()) + return true + } + + override fun close() { + try { + inputStream.close() + } catch (e: Throwable) { + } + try { + outputStream.close() + } catch (e: Throwable) { + } + try { + socket.close() + } catch (e: Exception) { + } + + if (state != State.Ready) { + pairingContext.destroy() + } + } + + companion object { + + init { + System.loadLibrary("adb") + } + + @JvmStatic + external fun available(): Boolean + } +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbProtocol.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbProtocol.kt new file mode 100644 index 0000000000..d85fafeab8 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbProtocol.kt @@ -0,0 +1,22 @@ +package io.github.sds100.keymapper.sysbridge.adb + +internal object AdbProtocol { + + const val A_SYNC = 0x434e5953 + const val A_CNXN = 0x4e584e43 + const val A_AUTH = 0x48545541 + const val A_OPEN = 0x4e45504f + const val A_OKAY = 0x59414b4f + const val A_CLSE = 0x45534c43 + const val A_WRTE = 0x45545257 + const val A_STLS = 0x534C5453 + + const val A_VERSION = 0x01000000 + const val A_MAXDATA = 4096 + + const val A_STLS_VERSION = 0x01000000 + + const val ADB_AUTH_TOKEN = 1 + const val ADB_AUTH_SIGNATURE = 2 + const val ADB_AUTH_RSAPUBLICKEY = 3 +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbServiceType.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbServiceType.kt new file mode 100644 index 0000000000..991263472b --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbServiceType.kt @@ -0,0 +1,6 @@ +package io.github.sds100.keymapper.sysbridge.adb + +enum class AdbServiceType(val id: String) { + TLS_CONNECT("_adb-tls-connect._tcp"), + TLS_PAIR("_adb-tls-pairing._tcp"), +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/ktx/Log.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/ktx/Log.kt new file mode 100644 index 0000000000..43c2b6d251 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/ktx/Log.kt @@ -0,0 +1,34 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package io.github.sds100.keymapper.sysbridge.ktx + +import android.util.Log + +inline val T.TAG: String + get() = + T::class.java.simpleName.let { + if (it.isBlank()) throw IllegalStateException("tag is empty") + if (it.length > 23) it.substring(0, 23) else it + } + +inline fun T.logv(message: String, throwable: Throwable? = null) = + logv(TAG, message, throwable) +inline fun T.logi(message: String, throwable: Throwable? = null) = + logi(TAG, message, throwable) +inline fun T.logw(message: String, throwable: Throwable? = null) = + logw(TAG, message, throwable) +inline fun T.logd(message: String, throwable: Throwable? = null) = + logd(TAG, message, throwable) +inline fun T.loge(message: String, throwable: Throwable? = null) = + loge(TAG, message, throwable) + +inline fun T.logv(tag: String, message: String, throwable: Throwable? = null) = + Log.v(tag, message, throwable) +inline fun T.logi(tag: String, message: String, throwable: Throwable? = null) = + Log.i(tag, message, throwable) +inline fun T.logw(tag: String, message: String, throwable: Throwable? = null) = + Log.w(tag, message, throwable) +inline fun T.logd(tag: String, message: String, throwable: Throwable? = null) = + Log.d(tag, message, throwable) +inline fun T.loge(tag: String, message: String, throwable: Throwable? = null) = + Log.e(tag, message, throwable) diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt new file mode 100644 index 0000000000..04edccdda9 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt @@ -0,0 +1,256 @@ +package io.github.sds100.keymapper.sysbridge.manager + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.os.Build +import android.os.DeadObjectException +import android.os.IBinder +import android.os.IBinder.DeathRecipient +import android.os.Process +import android.os.RemoteException +import android.os.SystemClock +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.models.isSuccess +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.SettingsUtils +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.onFailure +import io.github.sds100.keymapper.sysbridge.ISystemBridge +import io.github.sds100.keymapper.sysbridge.ktx.TAG +import io.github.sds100.keymapper.sysbridge.starter.SystemBridgeStarter +import io.github.sds100.keymapper.sysbridge.utils.SystemBridgeError +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * This class handles starting, stopping and (dis)connecting to the system bridge. + */ +@Singleton +class SystemBridgeConnectionManagerImpl @Inject constructor( + @ApplicationContext private val ctx: Context, + private val coroutineScope: CoroutineScope, + private val starter: SystemBridgeStarter, + private val buildConfigProvider: BuildConfigProvider, +) : SystemBridgeConnectionManager { + + private val systemBridgeLock: Any = Any() + private var systemBridgeFlow: MutableStateFlow = MutableStateFlow(null) + + override val connectionState: MutableStateFlow = + MutableStateFlow( + SystemBridgeConnectionState.Disconnected( + time = SystemClock.elapsedRealtime(), + isExpected = true, + ), + ) + private var isExpectedDeath: Boolean = false + + private val deathRecipient: DeathRecipient = DeathRecipient { + synchronized(systemBridgeLock) { + Timber.e("System Bridge has died") + + systemBridgeFlow.update { null } + + connectionState.update { + SystemBridgeConnectionState.Disconnected( + time = SystemClock.elapsedRealtime(), + isExpected = isExpectedDeath, + ) + } + + isExpectedDeath = false + } + } + + private var startJob: Job? = null + + fun pingBinder(): Boolean { + synchronized(systemBridgeLock) { + return systemBridgeFlow.value?.asBinder()?.pingBinder() == true + } + } + + /** + * This is called by the SystemBridgeBinderProvider content provider. + */ + @SuppressLint("LogNotTimber") + fun onBinderReceived(binder: IBinder) { + val systemBridge = ISystemBridge.Stub.asInterface(binder) + + synchronized(systemBridgeLock) { + if (systemBridge.versionCode == buildConfigProvider.versionCode) { + // Only link to death if it is the same version code so restarting it + // doesn't send a death message + systemBridge.asBinder().linkToDeath(deathRecipient, 0) + + this.systemBridgeFlow.update { systemBridge } + + // Only turn on the ADB options to prevent killing if it is running under + // the ADB shell user + if (systemBridge.processUid == Process.SHELL_UID) { + preventSystemBridgeKilling(systemBridge) + } + + connectionState.update { + SystemBridgeConnectionState.Connected( + time = SystemClock.elapsedRealtime(), + ) + } + } else { + coroutineScope.launch(Dispatchers.IO) { + // Can not use Timber because the content provider is called before the application's + // onCreate where the Timber Tree is installed. The content provider then + // calls this message. + Log.w( + TAG, + "System Bridge version mismatch! Restarting it. App: ${buildConfigProvider.versionCode}, System Bridge: ${systemBridge.versionCode}", + ) + + restartSystemBridge(systemBridge) + } + } + } + } + + @SuppressLint("LogNotTimber") + private suspend fun restartSystemBridge(systemBridge: ISystemBridge) { + starter.startSystemBridge(executeCommand = { command -> + try { + val result = systemBridge.executeCommand(command, 10000L)!! + if (result.isSuccess()) { + Success(result.stdout) + } else { + KMError.Exception( + Exception( + "Command failed with exit code ${result.exitCode}: ${result.stdout}", + ), + ) + } + } catch (_: DeadObjectException) { + // This exception is expected since it is killing the system bridge + Success("") + } catch (e: Exception) { + KMError.Exception(e) + } + }).onFailure { error -> + Log.e(TAG, "Failed to restart System Bridge: $error") + } + } + + override fun run(block: (ISystemBridge) -> T): KMResult { + try { + val systemBridge = systemBridgeFlow.value ?: return SystemBridgeError.Disconnected + + return Success(block(systemBridge)) + } catch (e: RemoteException) { + Timber.e(e, "RemoteException when running block with System Bridge") + return KMError.Exception(e) + } + } + + override fun stopSystemBridge() { + synchronized(systemBridgeLock) { + isExpectedDeath = true + + try { + systemBridgeFlow.value?.destroy() + } catch (e: RemoteException) { + // This is expected to throw an exception because the destroy() method kills + // the process. + } + } + } + + @RequiresApi(Build.VERSION_CODES.R) + override fun startWithAdb() { + if (startJob?.isActive == true) { + Timber.i("System Bridge is already starting") + return + } + + startJob = coroutineScope.launch { + starter.startWithAdb() + } + } + + private fun preventSystemBridgeKilling(systemBridge: ISystemBridge) { + val deviceId: Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ctx.deviceId + } else { + -1 + } + + systemBridge.grantPermission(Manifest.permission.WRITE_SECURE_SETTINGS, deviceId) + systemBridge.grantPermission(Manifest.permission.READ_LOGS, deviceId) + Timber.i("Granted WRITE_SECURE_SETTINGS and READ_LOGS permission with System Bridge") + + if (ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.WRITE_SECURE_SETTINGS, + ) == PERMISSION_GRANTED + ) { + // Disable automatic revoking of ADB pairings + SettingsUtils.putGlobalSetting( + ctx, + "adb_allowed_connection_time", + 0, + ) + + // Enable USB debugging so the Shell user can keep running in the background + // even when disconnected from the WiFi network + SettingsUtils.putGlobalSetting( + ctx, + "adb_enabled", + 1, + ) + } + } + + override fun startWithRoot() { + if (startJob?.isActive == true) { + Timber.i("System Bridge is already starting") + return + } + + startJob = coroutineScope.launch { + starter.startWithRoot() + } + } + + override fun startWithShizuku() { + starter.startWithShizuku() + } +} + +@SuppressLint("ObsoleteSdkInt") +@RequiresApi(Build.VERSION_CODES.Q) +interface SystemBridgeConnectionManager { + val connectionState: StateFlow + + fun run(block: (ISystemBridge) -> T): KMResult + fun stopSystemBridge() + + fun startWithRoot() + fun startWithShizuku() + fun startWithAdb() +} + +fun SystemBridgeConnectionManager.isConnected(): Boolean { + return connectionState.value is SystemBridgeConnectionState.Connected +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionState.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionState.kt new file mode 100644 index 0000000000..1d4b531783 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionState.kt @@ -0,0 +1,19 @@ +package io.github.sds100.keymapper.sysbridge.manager + +sealed class SystemBridgeConnectionState { + /** + * The time that this connection state was created. This uses SystemClock.elapsedRealtime() + */ + abstract val time: Long + + data class Connected(override val time: Long) : SystemBridgeConnectionState() + + data class Disconnected( + override val time: Long, + /** + * Whether the disconnection was expected. E.g the user stopped it or the app is + * opening for the first time + */ + val isExpected: Boolean, + ) : SystemBridgeConnectionState() +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/provider/BinderContainer.java b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/provider/BinderContainer.java new file mode 100644 index 0000000000..b14a24670e --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/provider/BinderContainer.java @@ -0,0 +1,44 @@ +package io.github.sds100.keymapper.sysbridge.provider; + +import android.os.IBinder; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.RestrictTo; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX; + +@RestrictTo(LIBRARY_GROUP_PREFIX) +public class BinderContainer implements Parcelable { + + public static final Creator CREATOR = new Creator() { + @Override + public BinderContainer createFromParcel(Parcel source) { + return new BinderContainer(source); + } + + @Override + public BinderContainer[] newArray(int size) { + return new BinderContainer[size]; + } + }; + public IBinder binder; + + public BinderContainer(IBinder binder) { + this.binder = binder; + } + + protected BinderContainer(Parcel in) { + this.binder = in.readStrongBinder(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeStrongBinder(this.binder); + } +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/provider/SystemBridgeBinderProvider.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/provider/SystemBridgeBinderProvider.kt new file mode 100644 index 0000000000..8ae10bc73a --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/provider/SystemBridgeBinderProvider.kt @@ -0,0 +1,115 @@ +package io.github.sds100.keymapper.sysbridge.provider + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import androidx.core.os.BundleCompat +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManagerImpl + +/** + * Taken from the ShizukuProvider class. + * + * This provider receives the Binder from the system bridge. When app process starts, + * the system bridge (it runs under adb/root) will send the binder to client apps with this provider. + */ +internal class SystemBridgeBinderProvider : ContentProvider() { + companion object { + // For receive Binder from Shizuku + const val METHOD_SEND_BINDER: String = "sendBinder" + + const val EXTRA_BINDER = "io.github.sds100.keymapper.sysbridge.EXTRA_BINDER" + } + + private val systemBridgeManager: SystemBridgeConnectionManagerImpl by lazy { + val appContext = context?.applicationContext ?: throw IllegalStateException() + val hiltEntryPoint = + EntryPointAccessors.fromApplication( + appContext, + SystemBridgeProviderEntryPoint::class.java, + ) + + hiltEntryPoint.systemBridgeManager() + } + + override fun onCreate(): Boolean { + return true + } + + override fun call(method: String, arg: String?, extras: Bundle?): Bundle? { + if (extras == null) { + return null + } + + extras.classLoader = BinderContainer::class.java.getClassLoader() + + val reply = Bundle() + + when (method) { + METHOD_SEND_BINDER -> { + handleSendBinder(extras) + } + } + + return reply + } + + private fun handleSendBinder(extras: Bundle) { + if (systemBridgeManager.pingBinder()) { + return + } + + val container: BinderContainer? = BundleCompat.getParcelable( + extras, + EXTRA_BINDER, + BinderContainer::class.java, + ) + + if (container != null && container.binder != null) { + systemBridgeManager.onBinderReceived(container.binder) + } + } + + // no other provider methods + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String?, + ): Cursor? { + return null + } + + override fun getType(uri: Uri): String? { + return null + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + return null + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + return 0 + } + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int { + return 0 + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface SystemBridgeProviderEntryPoint { + fun systemBridgeManager(): SystemBridgeConnectionManagerImpl + } +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt new file mode 100644 index 0000000000..fdb9d4cc66 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt @@ -0,0 +1,670 @@ +package io.github.sds100.keymapper.sysbridge.service + +import android.annotation.SuppressLint +import android.app.ActivityTaskManagerApis +import android.app.IActivityManager +import android.app.IActivityTaskManager +import android.bluetooth.IBluetoothManager +import android.content.AttributionSource +import android.content.Context +import android.content.IContentProvider +import android.content.pm.ApplicationInfo +import android.content.pm.IPackageManager +import android.content.pm.PackageManager +import android.hardware.input.IInputManager +import android.media.IAudioService +import android.net.IConnectivityManager +import android.net.wifi.IWifiManager +import android.nfc.INfcAdapter +import android.nfc.NfcAdapterApis +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Process +import android.os.ServiceManager +import android.permission.IPermissionManager +import android.permission.PermissionManagerApis +import android.util.Log +import android.view.InputEvent +import com.android.internal.telephony.ITelephony +import io.github.sds100.keymapper.common.models.EvdevDeviceHandle +import io.github.sds100.keymapper.common.models.ShellResult +import io.github.sds100.keymapper.common.utils.UserHandleUtils +import io.github.sds100.keymapper.sysbridge.IEvdevCallback +import io.github.sds100.keymapper.sysbridge.ISystemBridge +import io.github.sds100.keymapper.sysbridge.provider.BinderContainer +import io.github.sds100.keymapper.sysbridge.provider.SystemBridgeBinderProvider +import io.github.sds100.keymapper.sysbridge.utils.IContentProviderUtils +import java.io.InterruptedIOException +import kotlin.system.exitProcess +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import rikka.hidden.compat.ActivityManagerApis +import rikka.hidden.compat.DeviceIdleControllerApis +import rikka.hidden.compat.PackageManagerApis +import rikka.hidden.compat.UserManagerApis +import rikka.hidden.compat.adapter.ProcessObserverAdapter + +@SuppressLint("LogNotTimber") +internal class SystemBridge : ISystemBridge.Stub() { + + external fun grabEvdevDeviceNative(devicePath: String): Boolean + + external fun ungrabEvdevDeviceNative(devicePath: String): Boolean + external fun ungrabAllEvdevDevicesNative(): Boolean + external fun writeEvdevEventNative( + devicePath: String, + type: Int, + code: Int, + value: Int, + ): Boolean + + external fun getEvdevDevicesNative(): Array + + external fun startEvdevEventLoop(callback: IBinder) + external fun stopEvdevEventLoop() + + companion object { + private const val TAG: String = "KeyMapperSystemBridge" + private val systemBridgePackageName: String? by lazy { + System.getProperty("keymapper_sysbridge.package") + } + + private val systemBridgeVersionCode: Int by lazy { + System.getProperty("keymapper_sysbridge.version")?.toIntOrNull() ?: -1 + } + + private const val KEYMAPPER_CHECK_INTERVAL_MS = 60 * 1000L // 1 minute + private const val DATA_ENABLED_REASON_USER: Int = 0 + + @JvmStatic + fun main(args: Array) { + @Suppress("DEPRECATION") + Looper.prepareMainLooper() + SystemBridge() + Looper.loop() + } + + private fun waitSystemService(name: String?) { + var count = 0 + + while (ServiceManager.getService(name) == null) { + if (count == 5) { + throw IllegalStateException("Failed to get $name system service") + } + + try { + Thread.sleep(1000) + count++ + } catch (e: InterruptedException) { + Log.w(TAG, e.message, e) + } + } + } + } + + private val processObserver = object : ProcessObserverAdapter() { + + // This is used as a proxy for detecting the Key Mapper process has started. + // It is called when ANY foreground activities check so don't execute anything + // long running. + override fun onForegroundActivitiesChanged( + pid: Int, + uid: Int, + foregroundActivities: Boolean, + ) { + if (evdevCallback?.asBinder()?.pingBinder() != true) { + evdevCallbackDeathRecipient.binderDied() + } + + // Do not send the binder if the app is not in the foreground. + if (!foregroundActivities) { + return + } + + if (getKeyMapperPackageInfo() == null) { + Log.i(TAG, "Key Mapper app not installed - exiting") + destroy() + } else { + synchronized(sendBinderLock) { + if (evdevCallback == null) { + Log.i(TAG, "Key Mapper process started, send binder to app") + mainHandler.post { + sendBinderToApp() + } + } + } + } + } + } + + private val sendBinderLock: Any = Any() + + private val coroutineScope: CoroutineScope = MainScope() + private val mainHandler by lazy { Handler(Looper.myLooper()!!) } + + private val keyMapperCheckLock: Any = Any() + private var keyMapperCheckJob: Job? = null + + private val evdevCallbackLock: Any = Any() + private var evdevCallback: IEvdevCallback? = null + private val evdevCallbackDeathRecipient: IBinder.DeathRecipient = IBinder.DeathRecipient { + Log.i(TAG, "EvdevCallback binder died") + evdevCallback = null + + coroutineScope.launch(Dispatchers.Default) { + stopEvdevEventLoop() + } + + // Start periodic check for Key Mapper installation + startKeyMapperPeriodicCheck() + } + + private val inputManager: IInputManager + private val wifiManager: IWifiManager? + private val permissionManager: IPermissionManager + private val telephonyManager: ITelephony? + private val packageManager: IPackageManager + private val bluetoothManager: IBluetoothManager? + private val nfcAdapter: INfcAdapter? + private val connectivityManager: IConnectivityManager? + private val activityManager: IActivityManager + private val activityTaskManager: IActivityTaskManager + private val audioService: IAudioService? + + private val processPackageName: String = when (Process.myUid()) { + Process.ROOT_UID -> "root" + Process.SHELL_UID -> "com.android.shell" + else -> throw IllegalStateException("SystemBridge must run as root or shell user") + } + + init { + if (versionCode == -1) { + Log.e(TAG, "SystemBridge version code not set") + throw IllegalStateException("SystemBridge version code not set") + } + + val libraryPath = System.getProperty("keymapper_sysbridge.library.path") + @SuppressLint("UnsafeDynamicallyLoadedCode") + System.load("$libraryPath/libevdev.so") + + Log.i(TAG, "SystemBridge starting... Version code $versionCode") + + waitSystemService(Context.ACTIVITY_SERVICE) + activityManager = IActivityManager.Stub.asInterface( + ServiceManager.getService(Context.ACTIVITY_SERVICE), + ) + + waitSystemService("activity_task") + activityTaskManager = IActivityTaskManager.Stub.asInterface( + ServiceManager.getService("activity_task"), + ) + + waitSystemService(Context.USER_SERVICE) + waitSystemService(Context.APP_OPS_SERVICE) + + waitSystemService("package") + packageManager = IPackageManager.Stub.asInterface(ServiceManager.getService("package")) + + waitSystemService("permissionmgr") + permissionManager = + IPermissionManager.Stub.asInterface(ServiceManager.getService("permissionmgr")) + + waitSystemService(Context.INPUT_SERVICE) + inputManager = + IInputManager.Stub.asInterface(ServiceManager.getService(Context.INPUT_SERVICE)) + + if (hasSystemFeature(PackageManager.FEATURE_WIFI)) { + waitSystemService(Context.WIFI_SERVICE) + wifiManager = + IWifiManager.Stub.asInterface(ServiceManager.getService(Context.WIFI_SERVICE)) + } else { + wifiManager = null + } + + if (hasSystemFeature(PackageManager.FEATURE_TELEPHONY)) { + waitSystemService(Context.TELEPHONY_SERVICE) + telephonyManager = + ITelephony.Stub.asInterface(ServiceManager.getService(Context.TELEPHONY_SERVICE)) + } else { + telephonyManager = null + } + + if (hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) { + waitSystemService("bluetooth_manager") + bluetoothManager = + IBluetoothManager.Stub.asInterface(ServiceManager.getService("bluetooth_manager")) + } else { + bluetoothManager = null + } + + if (hasSystemFeature(PackageManager.FEATURE_NFC)) { + waitSystemService(Context.NFC_SERVICE) + nfcAdapter = + INfcAdapter.Stub.asInterface(ServiceManager.getService(Context.NFC_SERVICE)) + } else { + nfcAdapter = null + } + + waitSystemService(Context.CONNECTIVITY_SERVICE) + connectivityManager = + IConnectivityManager.Stub.asInterface( + ServiceManager.getService(Context.CONNECTIVITY_SERVICE), + ) + + waitSystemService(Context.AUDIO_SERVICE) + audioService = + IAudioService.Stub.asInterface(ServiceManager.getService(Context.AUDIO_SERVICE)) + + val applicationInfo = getKeyMapperPackageInfo() + + if (applicationInfo == null) { + destroy() + } + + ActivityManagerApis.registerProcessObserver(processObserver) + + // Try sending the binder to the app when its started. + mainHandler.post { + sendBinderToApp() + } + + Log.i(TAG, "SystemBridge started complete. Version code $versionCode") + } + + private fun hasSystemFeature(name: String): Boolean { + return packageManager.hasSystemFeature(name, 0) + } + + private fun getKeyMapperPackageInfo(): ApplicationInfo? = + PackageManagerApis.getApplicationInfoNoThrow(systemBridgePackageName, 0, 0) + + private fun startKeyMapperPeriodicCheck() { + synchronized(keyMapperCheckLock) { + keyMapperCheckJob?.cancel() + + Log.i(TAG, "Starting periodic Key Mapper installation check") + + keyMapperCheckJob = coroutineScope.launch(Dispatchers.Default) { + try { + while (true) { + if (getKeyMapperPackageInfo() == null) { + Log.i(TAG, "Key Mapper not installed - exiting") + destroy() + break + } else { + // While Key Mapper is still installed but not bound, then periodically + // check if it has uninstalled + delay(KEYMAPPER_CHECK_INTERVAL_MS) + } + } + } finally { + } + } + } + } + + private fun stopKeyMapperPeriodicCheck() { + synchronized(keyMapperCheckLock) { + keyMapperCheckJob?.cancel() + keyMapperCheckJob = null + Log.i(TAG, "Stopped periodic Key Mapper installation check") + } + } + + override fun destroy() { + Log.i(TAG, "SystemBridge destroyed") + + // Must be last line in this method because it halts the JVM. + exitProcess(0) + } + + override fun registerEvdevCallback(callback: IEvdevCallback?) { + callback ?: return + + Log.i(TAG, "Register evdev callback") + + // Stop periodic check since Key Mapper has reconnected + stopKeyMapperPeriodicCheck() + + val binder = callback.asBinder() + + if (this.evdevCallback != null) { + unregisterEvdevCallback() + } + + synchronized(evdevCallbackLock) { + this.evdevCallback = callback + binder.linkToDeath(evdevCallbackDeathRecipient, 0) + } + + coroutineScope.launch(Dispatchers.IO) { + mainHandler.post { + startEvdevEventLoop(binder) + } + } + } + + override fun unregisterEvdevCallback() { + synchronized(evdevCallbackLock) { + evdevCallback?.asBinder()?.unlinkToDeath(evdevCallbackDeathRecipient, 0) + evdevCallback = null + stopEvdevEventLoop() + } + } + + override fun grabEvdevDevice(devicePath: String?): Boolean { + devicePath ?: return false + return grabEvdevDeviceNative(devicePath) + } + + override fun grabEvdevDeviceArray(devicePath: Array?): Boolean { + devicePath ?: return false + + for (path in devicePath) { + Log.i(TAG, "Grabbing evdev device $path") + grabEvdevDeviceNative(path) + } + + return true + } + + override fun ungrabEvdevDevice(devicePath: String?): Boolean { + devicePath ?: return false + ungrabEvdevDeviceNative(devicePath) + return true + } + + override fun ungrabAllEvdevDevices(): Boolean { + ungrabAllEvdevDevicesNative() + return true + } + + override fun injectInputEvent(event: InputEvent?, mode: Int): Boolean { + return inputManager.injectInputEvent(event, mode) + } + + override fun getEvdevInputDevices(): Array? { + return getEvdevDevicesNative() + } + + override fun setWifiEnabled(enable: Boolean): Boolean { + if (wifiManager == null) { + throw UnsupportedOperationException("WiFi not supported") + } + + return wifiManager.setWifiEnabled(processPackageName, enable) + } + + override fun writeEvdevEvent(devicePath: String?, type: Int, code: Int, value: Int): Boolean { + devicePath ?: return false + return writeEvdevEventNative(devicePath, type, code, value) + } + + override fun getProcessUid(): Int { + return Process.myUid() + } + + override fun grantPermission(permission: String?, deviceId: Int) { + val userId = UserHandleUtils.getCallingUserId() + + PermissionManagerApis.grantPermission( + permissionManager, + systemBridgePackageName ?: return, + permission ?: return, + deviceId, + userId, + ) + } + + private fun sendBinderToApp(): Boolean { + // Only support Key Mapper running in a single Android user for now so just send + // it to the first user that accepts the binder. + for (userId in UserManagerApis.getUserIdsNoThrow()) { + if (sendBinderToAppInUser(userId)) { + return true + } + } + + return false + } + + /** + * @return Whether it was sent successfully with a reply from the app. + */ + private fun sendBinderToAppInUser(userId: Int): Boolean { + try { + DeviceIdleControllerApis.addPowerSaveTempWhitelistApp( + systemBridgePackageName, + 30 * 1000, + userId, + // PowerExemptionManager#REASON_SHELL + 316, + "shell", + ) + } catch (tr: Throwable) { + Log.e(TAG, tr.toString()) + } + + val providerName = "$systemBridgePackageName.sysbridge" + var provider: IContentProvider? = null + + val token: IBinder? = null + + try { + provider = ActivityManagerApis.getContentProviderExternal( + providerName, + userId, + token, + providerName, + ) + if (provider == null) { + Log.e(TAG, "provider is null $providerName $userId") + return false + } + + if (!provider.asBinder().pingBinder()) { + Log.e(TAG, "provider is dead $providerName $userId") + return false + } + + val extra = Bundle() + extra.putParcelable( + SystemBridgeBinderProvider.EXTRA_BINDER, + BinderContainer(this), + ) + + val reply: Bundle? = IContentProviderUtils.callCompat( + provider, + null, + providerName, + "sendBinder", + null, + extra, + ) + if (reply != null) { + Log.i(TAG, "Send binder to user app $systemBridgePackageName in user $userId") + // Stop periodic check since connection is successful + stopKeyMapperPeriodicCheck() + return true + } else { + Log.w( + TAG, + "Failed to send binder to user app $systemBridgePackageName in user $userId", + ) + } + } catch (tr: Throwable) { + Log.e( + TAG, + "Failed to send binder to user app $systemBridgePackageName in user $userId", + tr, + ) + } finally { + if (provider != null) { + try { + ActivityManagerApis.removeContentProviderExternal(providerName, token) + } catch (tr: Throwable) { + Log.w(TAG, "Failed to remove content provider $providerName", tr) + } + } + } + + return false + } + + override fun executeCommand(command: String?, timeoutMillis: Long): ShellResult { + command ?: throw IllegalArgumentException("Command is null") + + val process = ProcessBuilder() + .command("sh", "-c", command) + // Redirect stderr to stdout + .redirectErrorStream(true) + .start() + + var stdout = "" + + val worker = Thread { + val stdoutReader = process.inputStream.bufferedReader() + + try { + stdout = stdoutReader.readText() + process.waitFor() + } catch (_: InterruptedException) { + } catch (_: InterruptedIOException) { + } finally { + stdoutReader.close() + } + } + + worker.start() + + try { + worker.join(timeoutMillis) + + if (worker.isAlive) { + worker.interrupt() + process.destroy() + // Only some standard exceptions can be thrown across Binder. A TimeoutException + // is not one of them. + throw IllegalStateException("Timeout") + } + } catch (e: InterruptedException) { + worker.interrupt() + Thread.currentThread().interrupt() + } + + val exitCode = process.exitValue() + + return ShellResult(stdout, exitCode) + } + + override fun getVersionCode(): Int { + return systemBridgeVersionCode + } + + override fun setDataEnabled(subId: Int, enable: Boolean) { + if (telephonyManager == null) { + throw UnsupportedOperationException("Telephony not supported") + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + telephonyManager.setDataEnabledForReason( + subId, + DATA_ENABLED_REASON_USER, + enable, + processPackageName, + ) + } else { + telephonyManager.setUserDataEnabled(subId, enable) + } + } + + override fun setBluetoothEnabled(enable: Boolean) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + throw UnsupportedOperationException( + "Bluetooth enable/disable requires Android 12 or higher. Otherwise use the SDK's BluetoothAdapter which allows enable/disable.", + ) + } + + if (bluetoothManager == null) { + throw UnsupportedOperationException("Bluetooth not supported") + } + + val attributionSourceBuilder = AttributionSource.Builder(Process.myUid()) + .setAttributionTag("KeyMapperSystemBridge") + .setPackageName(processPackageName) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + attributionSourceBuilder.setPid(Process.myPid()) + } + + val attributionSource = attributionSourceBuilder.build() + + if (enable) { + bluetoothManager.enable(attributionSource) + } else { + bluetoothManager.disable(attributionSource, true) + } + } + + override fun setNfcEnabled(enable: Boolean) { + if (nfcAdapter == null) { + throw UnsupportedOperationException("NFC not supported") + } + + if (enable) { + NfcAdapterApis.enable(nfcAdapter, processPackageName) + } else { + NfcAdapterApis.disable( + adapter = nfcAdapter, + saveState = true, + packageName = processPackageName, + ) + } + } + + override fun setAirplaneMode(enable: Boolean) { + if (connectivityManager == null) { + throw UnsupportedOperationException("ConnectivityManager not supported") + } + + connectivityManager.setAirplaneMode(enable) + } + + override fun forceStopPackage(packageName: String?) { + val userId = UserHandleUtils.getCallingUserId() + + activityManager.forceStopPackage(packageName, userId) + } + + override fun removeTasks(packageName: String?) { + packageName ?: return + + val tasks = + ActivityTaskManagerApis.getTasks( + activityTaskManager = activityTaskManager, + maxNum = 32, + filterOnlyVisibleRecents = false, + keepIntentExtra = false, + displayId = 0, + ) ?: return + + tasks.filterNotNull() + .filter { it.baseActivity?.packageName == packageName } + .forEach { activityManager.removeTask(it.taskId) } + } + + override fun setRingerMode(ringerMode: Int) { + if (audioService == null) { + throw UnsupportedOperationException("AudioService not supported") + } + + audioService.setRingerModeInternal(ringerMode, processPackageName) + } +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt new file mode 100644 index 0000000000..8d30f0a6c4 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt @@ -0,0 +1,361 @@ +package io.github.sds100.keymapper.sysbridge.service + +import android.Manifest +import android.annotation.SuppressLint +import android.app.ActivityManager +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.provider.Settings +import android.service.quicksettings.TileService +import androidx.annotation.RequiresApi +import androidx.annotation.RequiresPermission +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.common.KeyMapperClassProvider +import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.SettingsUtils +import io.github.sds100.keymapper.common.utils.isSuccess +import io.github.sds100.keymapper.common.utils.onSuccess +import io.github.sds100.keymapper.sysbridge.adb.AdbManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber + +@Singleton +class SystemBridgeSetupControllerImpl @Inject constructor( + @ApplicationContext private val ctx: Context, + private val coroutineScope: CoroutineScope, + private val adbManager: AdbManager, + private val keyMapperClassProvider: KeyMapperClassProvider, + private val connectionManager: SystemBridgeConnectionManager, +) : SystemBridgeSetupController { + + companion object { + private const val DEVELOPER_OPTIONS_SETTING = "development_settings_enabled" + private const val ADB_WIRELESS_SETTING = "adb_wifi_enabled" + } + + private val activityManager: ActivityManager by lazy { ctx.getSystemService()!! } + + override val isDeveloperOptionsEnabled: MutableStateFlow = + MutableStateFlow(getDeveloperOptionsEnabled()) + + override val isWirelessDebuggingEnabled: MutableStateFlow = + MutableStateFlow(getWirelessDebuggingEnabled()) + + // Use a SharedFlow so that the same value can be emitted repeatedly. + override val setupAssistantStep: MutableSharedFlow = MutableSharedFlow() + private val setupAssistantStepState = + setupAssistantStep.stateIn(coroutineScope, SharingStarted.Eagerly, null) + + private val isAdbPairedResult: MutableStateFlow = MutableStateFlow(null) + private var isAdbPairedJob: Job? = null + + private var autoStartJob: Job? = null + + init { + // Automatically go back to the Key Mapper app when turning on wireless debugging + coroutineScope.launch { + val uri = Settings.Global.getUriFor(ADB_WIRELESS_SETTING) + SettingsUtils.settingsCallbackFlow(ctx, uri).collect { + isWirelessDebuggingEnabled.update { getWirelessDebuggingEnabled() } + + // Only go back if the user is currently setting up the wireless debugging step. + // This stops Key Mapper going back if they are turning on wireless debugging + // for another reason. + if (isWirelessDebuggingEnabled.value && + setupAssistantStepState.value == SystemBridgeSetupStep.WIRELESS_DEBUGGING + ) { + getKeyMapperAppTask()?.moveToFront() + } + } + } + + coroutineScope.launch { + val uri = Settings.Global.getUriFor(DEVELOPER_OPTIONS_SETTING) + SettingsUtils.settingsCallbackFlow(ctx, uri).collect { + isDeveloperOptionsEnabled.update { getDeveloperOptionsEnabled() } + + if (isDeveloperOptionsEnabled.value && + setupAssistantStepState.value == SystemBridgeSetupStep.DEVELOPER_OPTIONS + ) { + getKeyMapperAppTask()?.moveToFront() + } + } + } + } + + override fun startWithRoot() { + coroutineScope.launch { + connectionManager.startWithRoot() + } + } + + override fun startWithShizuku() { + connectionManager.startWithShizuku() + } + + /** + * If Key Mapper has WRITE_SECURE_SETTINGS permission then it can turn on wireless debugging + * and ADB and then start the system bridge. + */ + @RequiresApi(Build.VERSION_CODES.R) + override fun autoStartWithAdb() { + autoStartJob?.cancel() + + autoStartJob = coroutineScope.launch { + if (!canWriteGlobalSettings()) { + Timber.w("Cannot auto start with ADB. WRITE_SECURE_SETTINGS permission not granted") + return@launch + } + + if (connectionManager.connectionState.value + !is SystemBridgeConnectionState.Disconnected + ) { + Timber.w("Not auto starting. System Bridge is already connected.") + return@launch + } + + SettingsUtils.putGlobalSetting(ctx, DEVELOPER_OPTIONS_SETTING, 1) + + try { + withTimeout(5000L) { isDeveloperOptionsEnabled.first { it } } + } catch (_: TimeoutCancellationException) { + return@launch + } + + if (isAdbPaired()) { + // This is IMPORTANT. First turn on ADB before enabling wireless debugging because + // turning on developer options just before can cause the Shell to be killed once + // the system bridge is started. + SettingsUtils.putGlobalSetting(ctx, Settings.Global.ADB_ENABLED, 1) + SettingsUtils.putGlobalSetting(ctx, ADB_WIRELESS_SETTING, 1) + + // Wait for wireless debugging to be enabled before starting with ADB + try { + withTimeout(5000L) { isWirelessDebuggingEnabled.first { it } } + } catch (_: TimeoutCancellationException) { + return@launch + } + + startWithAdb() + + // Wait for the service to connect before turning off wireless debugging + withTimeoutOrNull(5000L) { + connectionManager.connectionState + .filterIsInstance() + .first() + } + + // Disable wireless debugging when done + SettingsUtils.putGlobalSetting(ctx, ADB_WIRELESS_SETTING, 0) + } else { + Timber.e("Autostart failed. ADB not paired successfully.") + } + } + } + + @RequiresApi(Build.VERSION_CODES.R) + override fun startWithAdb() { + connectionManager.startWithAdb() + } + + @RequiresApi(Build.VERSION_CODES.R) + override fun launchPairingAssistant() { + launchWirelessDebuggingActivity() + + coroutineScope.launch { + setupAssistantStep.emit(SystemBridgeSetupStep.ADB_PAIRING) + } + } + + @RequiresApi(Build.VERSION_CODES.R) + override suspend fun pairWirelessAdb(code: String): KMResult { + return adbManager.pair(code).onSuccess { + // Clear the step if still at the pairing step. + if (setupAssistantStepState.value == SystemBridgeSetupStep.ADB_PAIRING) { + setupAssistantStep.emit(null) + } + } + } + + override fun enableDeveloperOptions() { + if (canWriteGlobalSettings()) { + SettingsUtils.putGlobalSetting(ctx, DEVELOPER_OPTIONS_SETTING, 1) + } else { + SettingsUtils.launchSettingsScreen( + ctx, + Settings.ACTION_DEVICE_INFO_SETTINGS, + "build_number", + ) + + coroutineScope.launch { + setupAssistantStep.emit(SystemBridgeSetupStep.DEVELOPER_OPTIONS) + } + } + } + + override fun enableWirelessDebugging() { + if (canWriteGlobalSettings()) { + SettingsUtils.putGlobalSetting(ctx, ADB_WIRELESS_SETTING, 1) + } else { + // This is the intent sent by the quick settings tile. Not all devices support this. + launchWirelessDebuggingActivity() + + coroutineScope.launch { + setupAssistantStep.emit(SystemBridgeSetupStep.WIRELESS_DEBUGGING) + } + } + } + + @RequiresApi(Build.VERSION_CODES.R) + override suspend fun isAdbPaired(): Boolean { + // Sometimes multiple calls to this function can happen in a short space of time + // so only run one job to check whether it is paired. + if (isAdbPairedJob == null || isAdbPairedJob?.isCompleted == true) { + isAdbPairedJob?.cancel() + isAdbPairedResult.value = null + + isAdbPairedJob = coroutineScope.launch { + if (!getWirelessDebuggingEnabled()) { + SettingsUtils.putGlobalSetting(ctx, ADB_WIRELESS_SETTING, 1) + } + + // Try running a command to see if the pairing is working correctly. + // This will execute the "exit" command in the shell so it immediately closes + // the connection. + isAdbPairedResult.value = adbManager.executeCommand("exit").isSuccess + } + } + + // Wait for the next result + return isAdbPairedResult.filterNotNull().first() + } + + /** + * @return whether it opened the wireless debugging activity successfully. If it is + * false then developer options was launched. + */ + private fun launchWirelessDebuggingActivity(): Boolean { + val quickSettingsIntent = Intent(TileService.ACTION_QS_TILE_PREFERENCES).apply { + // Set the package name because this action can also resolve to a "Permission Controller" activity. + val packageName = "com.android.settings" + setPackage(packageName) + + putExtra( + Intent.EXTRA_COMPONENT_NAME, + ComponentName( + packageName, + "com.android.settings.development.qstile.DevelopmentTiles\$WirelessDebugging", + ), + ) + + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_NO_HISTORY or + Intent.FLAG_ACTIVITY_CLEAR_TASK or + Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS, + ) + } + + try { + ctx.startActivity(quickSettingsIntent) + return true + } catch (_: ActivityNotFoundException) { + SettingsUtils.launchSettingsScreen( + ctx, + Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS, + "toggle_adb_wireless", + ) + + return false + } + } + + fun invalidateSettings() { + isDeveloperOptionsEnabled.update { getDeveloperOptionsEnabled() } + isWirelessDebuggingEnabled.update { getWirelessDebuggingEnabled() } + } + + private fun getDeveloperOptionsEnabled(): Boolean { + try { + return SettingsUtils.getGlobalSetting(ctx, DEVELOPER_OPTIONS_SETTING) == 1 + } catch (_: Settings.SettingNotFoundException) { + return false + } + } + + private fun getWirelessDebuggingEnabled(): Boolean { + try { + return SettingsUtils.getGlobalSetting(ctx, ADB_WIRELESS_SETTING) == 1 + } catch (_: Settings.SettingNotFoundException) { + return false + } + } + + private fun getKeyMapperAppTask(): ActivityManager.AppTask? { + val task = activityManager.appTasks + .firstOrNull { + it.taskInfo.topActivity?.className == + keyMapperClassProvider.getMainActivity().name + } + return task + } + + private fun canWriteGlobalSettings(): Boolean { + return ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.WRITE_SECURE_SETTINGS, + ) == PackageManager.PERMISSION_GRANTED + } +} + +@SuppressLint("ObsoleteSdkInt") +@RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) +interface SystemBridgeSetupController { + val setupAssistantStep: Flow + + val isDeveloperOptionsEnabled: Flow + fun enableDeveloperOptions() + + val isWirelessDebuggingEnabled: Flow + fun enableWirelessDebugging() + + fun launchPairingAssistant() + + @RequiresApi(Build.VERSION_CODES.R) + suspend fun isAdbPaired(): Boolean + + @RequiresApi(Build.VERSION_CODES.R) + suspend fun pairWirelessAdb(code: String): KMResult + + fun startWithRoot() + fun startWithShizuku() + fun startWithAdb() + + @RequiresPermission(Manifest.permission.WRITE_SECURE_SETTINGS) + fun autoStartWithAdb() +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupStep.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupStep.kt new file mode 100644 index 0000000000..f2b8478545 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupStep.kt @@ -0,0 +1,12 @@ +package io.github.sds100.keymapper.sysbridge.service + +enum class SystemBridgeSetupStep(val stepIndex: Int) { + ACCESSIBILITY_SERVICE(stepIndex = 0), + NOTIFICATION_PERMISSION(stepIndex = 1), + DEVELOPER_OPTIONS(stepIndex = 2), + WIFI_NETWORK(stepIndex = 3), + WIRELESS_DEBUGGING(stepIndex = 4), + ADB_PAIRING(stepIndex = 5), + START_SERVICE(stepIndex = 6), + STARTED(stepIndex = 7), +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/shizuku/ShizukuStarterService.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/shizuku/ShizukuStarterService.kt new file mode 100644 index 0000000000..b7e7d25cd7 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/shizuku/ShizukuStarterService.kt @@ -0,0 +1,42 @@ +package io.github.sds100.keymapper.sysbridge.shizuku + +import android.annotation.SuppressLint +import android.util.Log +import io.github.sds100.keymapper.sysbridge.IShizukuStarterService +import kotlin.system.exitProcess + +@SuppressLint("LogNotTimber") +class ShizukuStarterService : IShizukuStarterService.Stub() { + companion object { + private val TAG = "ShizukuStarterService" + } + + init { + Log.i(TAG, "ShizukuStarterService created") + } + + override fun destroy() { + Log.i(TAG, "ShizukuStarterService destroyed") + + // Must be last line in this method because it halts the JVM. + exitProcess(0) + } + + override fun executeCommand(command: String?): String? { + command ?: return null + + val process = Runtime.getRuntime().exec(command) + + val out = with(process.inputStream.bufferedReader()) { + readText() + } + + val err = with(process.errorStream.bufferedReader()) { + readText() + } + + process.waitFor() + + return "$out\n$err" + } +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt new file mode 100644 index 0000000000..09d32c36a0 --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt @@ -0,0 +1,334 @@ +package io.github.sds100.keymapper.sysbridge.starter + +import android.content.ComponentName +import android.content.Context +import android.content.ServiceConnection +import android.os.Build +import android.os.DeadObjectException +import android.os.IBinder +import android.os.RemoteException +import android.os.UserManager +import android.system.ErrnoException +import android.system.Os +import androidx.annotation.RequiresApi +import com.topjohnwu.superuser.Shell +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.onFailure +import io.github.sds100.keymapper.common.utils.then +import io.github.sds100.keymapper.sysbridge.BuildConfig +import io.github.sds100.keymapper.sysbridge.IShizukuStarterService +import io.github.sds100.keymapper.sysbridge.R +import io.github.sds100.keymapper.sysbridge.adb.AdbManager +import io.github.sds100.keymapper.sysbridge.ktx.loge +import io.github.sds100.keymapper.sysbridge.shizuku.ShizukuStarterService +import java.io.BufferedReader +import java.io.DataInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.FileWriter +import java.io.IOException +import java.io.InputStreamReader +import java.io.PrintWriter +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import rikka.shizuku.Shizuku +import timber.log.Timber + +@Singleton +class SystemBridgeStarter @Inject constructor( + @ApplicationContext private val ctx: Context, + private val adbManager: AdbManager, + private val buildConfigProvider: BuildConfigProvider, +) { + private val userManager by lazy { ctx.getSystemService(UserManager::class.java)!! } + + private val baseApkPath = ctx.applicationInfo.sourceDir + private val splitApkPaths: Array = ctx.applicationInfo.splitSourceDirs ?: emptyArray() + private val libPath = ctx.applicationInfo.nativeLibraryDir + private val packageName = ctx.applicationInfo.packageName + private val startMutex: Mutex = Mutex() + + private val shizukuStarterConnection: ServiceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + Timber.i("Shizuku starter service connected") + + val service = IShizukuStarterService.Stub.asInterface(binder) + + Timber.i("Starting System Bridge with Shizuku starter service") + try { + runBlocking { + startSystemBridge(executeCommand = { command -> + val output = service.executeCommand(command) + + if (output == null) { + KMError.UnknownIOError + } else { + Success(output) + } + }) + } + } catch (e: RemoteException) { + Timber.e("Exception starting with Shizuku starter service: $e") + } finally { + try { + service.destroy() + } catch (_: DeadObjectException) { + // Do nothing. Service is already dead. + } + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + // Do nothing. The service is supposed to immediately kill itself + // after starting the command. + } + } + + fun startWithShizuku() { + if (!Shizuku.pingBinder()) { + Timber.w("Shizuku is not running. Cannot start System Bridge with Shizuku.") + return + } + + // Shizuku will start a service which will then start the System Bridge. Shizuku won't be + // used to start the System Bridge directly because native libraries need to be used + // and we want to limit the dependency on Shizuku as much as possible. Also, the System + // Bridge should still be running even if Shizuku dies. + val serviceComponentName = ComponentName(ctx, ShizukuStarterService::class.java) + val args = Shizuku.UserServiceArgs(serviceComponentName) + .daemon(false) + .processNameSuffix("service") + .debuggable(BuildConfig.DEBUG) + .version(buildConfigProvider.versionCode) + + try { + Shizuku.bindUserService( + args, + shizukuStarterConnection, + ) + } catch (e: Exception) { + Timber.e("Exception when starting System Bridge with Shizuku. $e") + } + } + + @RequiresApi(Build.VERSION_CODES.R) + suspend fun startWithAdb(): KMResult { + if (!userManager.isUserUnlocked) { + return KMError.Exception(IllegalStateException("User is locked")) + } + + return startSystemBridge(executeCommand = adbManager::executeCommand) + .onFailure { error -> + Timber.e("Failed to start system bridge with ADB: $error") + } + } + + suspend fun startWithRoot() { + if (Shell.isAppGrantedRoot() != true) { + Timber.e("Root is not granted. Cannot start System Bridge with Root.") + return + } + + Timber.i("Starting System Bridge with root") + startSystemBridge(executeCommand = { command -> + val output = withContext(Dispatchers.IO) { + Shell.cmd(command).exec() + } + + if (output.isSuccess) { + Success(output.out.plus(output.err).joinToString("\n")) + } else { + KMError.UnknownIOError + } + }) + } + + suspend fun startSystemBridge( + executeCommand: suspend (String) -> KMResult, + ): KMResult { + startMutex.withLock { + val externalFilesParent = try { + ctx.getExternalFilesDir(null)?.parentFile + } catch (e: IOException) { + return KMError.UnknownIOError + } + + Timber.i("Copy starter files to ${externalFilesParent?.absolutePath}") + + val outputStarterBinary = File(externalFilesParent, "starter") + val outputStarterScript = File(externalFilesParent, "start.sh") + + val copyFilesResult = withContext(Dispatchers.IO) { + copyNativeLibrary(outputStarterBinary).then { + // Create the start.sh shell script + writeStarterScript( + outputStarterScript, + outputStarterBinary.absolutePath, + ) + Success(Unit) + } + } + + val startCommand = + "sh ${outputStarterScript.absolutePath} --apk=$baseApkPath --lib=$libPath --package=$packageName --version=${buildConfigProvider.versionCode}" + + return copyFilesResult + .then { executeCommand(startCommand) } + .then { output -> + // Adb on Android 11 has no permission to access Android/data so use /data/user_de. + if (output.contains( + "/Android/data/${ctx.packageName}/start.sh: Permission denied", + ) + ) { + Timber.w( + "ADB has no permission to access Android/data/${ctx.packageName}/start.sh. Trying to use /data/user_de instead...", + ) + + startSystemBridgeFromProtectedStorage(executeCommand) + } else { + Success(output) + } + } + } + } + + private suspend fun startSystemBridgeFromProtectedStorage( + executeCommand: suspend (String) -> KMResult, + ): KMResult { + val protectedStorageDir = + ctx.createDeviceProtectedStorageContext().filesDir.parentFile!! + + Timber.i("Protected storage dir: ${protectedStorageDir.absolutePath}") + + try { + // 0711 + Os.chmod(protectedStorageDir.absolutePath, 457) + } catch (e: ErrnoException) { + e.printStackTrace() + } + + Timber.i("Copy starter files to ${protectedStorageDir.absolutePath}") + + try { + val outputStarterBinary = File(protectedStorageDir, "starter") + val outputStarterScript = File(protectedStorageDir, "start.sh") + + withContext(Dispatchers.IO) { + copyNativeLibrary(outputStarterBinary) + + writeStarterScript( + outputStarterScript, + outputStarterBinary.absolutePath, + ) + } + + val startCommand = + "sh ${outputStarterScript.absolutePath} --apk=$baseApkPath --lib=$libPath --package=$packageName --version=${buildConfigProvider.versionCode}" + + // Make starter binary executable + try { + // 0644 + Os.chmod(outputStarterBinary.absolutePath, 420) + } catch (e: ErrnoException) { + e.printStackTrace() + } + + // Make starter script executable + try { + // 0644 + Os.chmod(outputStarterScript.absolutePath, 420) + } catch (e: ErrnoException) { + e.printStackTrace() + } + + return executeCommand(startCommand) + } catch (e: IOException) { + loge("write files", e) + return KMError.UnknownIOError + } + } + + /** + * This extracts the library file from inside the apk and copies it to [out] File. + */ + private fun copyNativeLibrary(out: File): KMResult { + Timber.i("Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()}") + Timber.i("Attempt to copy native library from: $libPath") + + val libraryName = "libsysbridge.so" + + try { + // copyTo throws an exception if it already exists + out.delete() + + File("$libPath/$libraryName").copyTo(out) + return Success(Unit) + } catch (e: Exception) { + Timber.w("Native library not found. Extracting from APKs. Exception: $e") + + val apkPaths: Array = arrayOf(baseApkPath, *splitApkPaths) + + Timber.i("APK paths: ${apkPaths.joinToString()}") + + for (apk in apkPaths) { + with(ZipFile(apk)) { + for (abi in Build.SUPPORTED_ABIS) { + val expectedLibraryPath = "lib/$abi/$libraryName" + + // Open the apk so the library file can be found + val entry: ZipEntry = getEntry(expectedLibraryPath) ?: continue + + with(DataInputStream(getInputStream(entry))) { + val input = this + with(FileOutputStream(out)) { + val output = this + input.copyTo(output) + } + } + + return Success(Unit) + } + } + } + } + + return KMError.SourceFileNotFound(libraryName) + } + + /** + * Write the start.sh shell script to the specified [out] file. The path to the starter + * binary will be substituted in the script with the [starterPath]. + */ + private fun writeStarterScript(out: File, starterPath: String) { + if (!out.exists()) { + out.createNewFile() + } + + val scriptInputStream = ctx.resources.openRawResource(R.raw.start) + + with(scriptInputStream) { + val reader = BufferedReader(InputStreamReader(this)) + + val outputWriter = PrintWriter(FileWriter(out)) + var line: String? + + while (reader.readLine().also { line = it } != null) { + outputWriter.println(line!!.replace("%%%STARTER_PATH%%%", starterPath)) + } + + outputWriter.flush() + outputWriter.close() + } + } +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/utils/IContentProviderUtils.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/utils/IContentProviderUtils.kt new file mode 100644 index 0000000000..d83d20744d --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/utils/IContentProviderUtils.kt @@ -0,0 +1,41 @@ +package io.github.sds100.keymapper.sysbridge.utils + +import android.content.AttributionSource +import android.content.IContentProvider +import android.os.Build +import android.os.Bundle + +internal object IContentProviderUtils { + + @Throws(android.os.RemoteException::class) + fun callCompat( + provider: IContentProvider, + callingPkg: String?, + authority: String?, + method: String?, + arg: String?, + extras: Bundle?, + ): Bundle? { + val result: Bundle? + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val uid = android.system.Os.getuid() + + result = provider.call( + (AttributionSource.Builder(uid)).setPackageName(callingPkg).build(), + authority, + method, + arg, + extras, + ) + } else if (Build.VERSION.SDK_INT >= 30) { + result = + provider.call(callingPkg, null as String?, authority, method, arg, extras) + } else if (Build.VERSION.SDK_INT >= 29) { + result = provider.call(callingPkg, authority, method, arg, extras) + } else { + result = provider.call(callingPkg, method, arg, extras) + } + + return result + } +} diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/utils/SystemBridgeError.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/utils/SystemBridgeError.kt new file mode 100644 index 0000000000..2b2183fb6d --- /dev/null +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/utils/SystemBridgeError.kt @@ -0,0 +1,7 @@ +package io.github.sds100.keymapper.sysbridge.utils + +import io.github.sds100.keymapper.common.utils.KMError + +sealed class SystemBridgeError : KMError() { + data object Disconnected : SystemBridgeError() +} diff --git a/sysbridge/src/main/res/raw/start.sh b/sysbridge/src/main/res/raw/start.sh new file mode 100644 index 0000000000..f8357e65d5 --- /dev/null +++ b/sysbridge/src/main/res/raw/start.sh @@ -0,0 +1,55 @@ +#!/system/bin/sh + +SOURCE_PATH="%%%STARTER_PATH%%%" +STARTER_PATH="/data/local/tmp/keymapper_sysbridge_starter" + +echo "info: start.sh begin" + +if [ ! -f $SOURCE_PATH ]; then + echo "fatal: source file does not exist: $SOURCE_PATH" + exit 1 +fi + +recreate_tmp() { + echo "info: /data/local/tmp is possible broken, recreating..." + rm -rf /data/local/tmp + mkdir -p /data/local/tmp +} + +broken_tmp() { + echo "fatal: /data/local/tmp is broken, please try reboot the device or manually recreate it..." + exit 1 +} + +echo "info: attempt to copy starter from $SOURCE_PATH to $STARTER_PATH" +rm -f $STARTER_PATH + +cp "$SOURCE_PATH" $STARTER_PATH +res=$? +if [ $res -ne 0 ]; then + recreate_tmp + cp "$SOURCE_PATH" $STARTER_PATH + + res=$? + if [ $res -ne 0 ]; then + broken_tmp + fi +fi + +chmod 700 $STARTER_PATH +chown 2000 $STARTER_PATH +chgrp 2000 $STARTER_PATH + +if [ -f $STARTER_PATH ]; then + echo "info: exec $STARTER_PATH" + # Pass apk path, library path, package name, version code + $STARTER_PATH "$1" "$2" "$3" "$4" + result=$? + if [ ${result} -ne 0 ]; then + echo "info: keymapper_sysbridge_starter exit with non-zero value $result" + else + echo "info: keymapper_sysbridge_starter exit with 0" + fi +else + echo "Starter file not exist, please open Key Mapper and try again." +fi diff --git a/system/build.gradle.kts b/system/build.gradle.kts index 6c133fa974..b70b399c4d 100644 --- a/system/build.gradle.kts +++ b/system/build.gradle.kts @@ -40,11 +40,10 @@ dependencies { implementation(project(":common")) implementation(project(":data")) implementation(project(":systemstubs")) + implementation(project(":sysbridge")) - // kotlin stuff implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization.json) - implementation(libs.androidx.core.ktx) implementation(libs.jakewharton.timber) implementation(libs.dagger.hilt.android) @@ -58,4 +57,5 @@ dependencies { implementation(libs.rikka.shizuku.provider) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.preference.ktx) + implementation(libs.github.topjohnwu.libsu) } diff --git a/system/src/ci/res/values/strings.xml b/system/src/ci/res/values/strings.xml index 2364c4723b..52f4936756 100644 --- a/system/src/ci/res/values/strings.xml +++ b/system/src/ci/res/values/strings.xml @@ -1,4 +1,4 @@ - Key Mapper CI Basic Input Method + Key Mapper CI Input Method \ No newline at end of file diff --git a/system/src/debug/res/values/strings.xml b/system/src/debug/res/values/strings.xml index 634c8afe28..c8d5904dde 100644 --- a/system/src/debug/res/values/strings.xml +++ b/system/src/debug/res/values/strings.xml @@ -1,4 +1,4 @@ - Key Mapper Debug Basic Input Method + Key Mapper Debug Input Method \ No newline at end of file diff --git a/system/src/main/AndroidManifest.xml b/system/src/main/AndroidManifest.xml index f04fd3ce4c..0551dc8037 100644 --- a/system/src/main/AndroidManifest.xml +++ b/system/src/main/AndroidManifest.xml @@ -22,6 +22,13 @@ + + + + +
\ No newline at end of file diff --git a/system/src/main/java/io/github/sds100/keymapper/system/JobSchedulerHelper.kt b/system/src/main/java/io/github/sds100/keymapper/system/JobSchedulerHelper.kt index bbbe2a4d75..c0eb5e1777 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/JobSchedulerHelper.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/JobSchedulerHelper.kt @@ -6,7 +6,6 @@ import android.content.ComponentName import android.content.Context import android.os.Build import android.provider.Settings -import androidx.annotation.RequiresApi import androidx.core.content.getSystemService import io.github.sds100.keymapper.system.accessibility.ObserveEnabledAccessibilityServicesJob import io.github.sds100.keymapper.system.inputmethod.AndroidInputMethodAdapter @@ -19,7 +18,6 @@ object JobSchedulerHelper { private const val ID_OBSERVE_ENABLED_INPUT_METHODS = 2 private const val ID_OBSERVE_NOTIFICATION_LISTENERS = 3 - @RequiresApi(Build.VERSION_CODES.N) fun observeEnabledNotificationListeners(ctx: Context) { val uri = Settings.Secure.getUriFor("enabled_notification_listeners") @@ -42,7 +40,6 @@ object JobSchedulerHelper { ctx.getSystemService()?.schedule(builder.build()) } - @RequiresApi(Build.VERSION_CODES.N) fun observeEnabledAccessibilityServices(ctx: Context) { val uri = Settings.Secure.getUriFor(Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) @@ -65,7 +62,6 @@ object JobSchedulerHelper { ctx.getSystemService()?.schedule(builder.build()) } - @RequiresApi(Build.VERSION_CODES.N) fun observeInputMethods(ctx: Context) { val enabledContentUri = JobInfo.TriggerContentUri( Settings.Secure.getUriFor(Settings.Secure.ENABLED_INPUT_METHODS), diff --git a/system/src/main/java/io/github/sds100/keymapper/system/Shell.kt b/system/src/main/java/io/github/sds100/keymapper/system/Shell.kt deleted file mode 100644 index 1b9d0b7535..0000000000 --- a/system/src/main/java/io/github/sds100/keymapper/system/Shell.kt +++ /dev/null @@ -1,50 +0,0 @@ -package io.github.sds100.keymapper.system - -import io.github.sds100.keymapper.common.utils.KMError -import io.github.sds100.keymapper.common.utils.KMResult -import io.github.sds100.keymapper.common.utils.Success -import io.github.sds100.keymapper.system.shell.ShellAdapter -import java.io.IOException -import java.io.InputStream -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class Shell @Inject constructor() : ShellAdapter { - /** - * @return whether the command was executed successfully - */ - override fun run(vararg command: String, waitFor: Boolean): Boolean = try { - val process = Runtime.getRuntime().exec(command) - - if (waitFor) { - process.waitFor() - } - - true - } catch (e: IOException) { - false - } - - /** - * Remember to close it after using it. - */ - @Throws(IOException::class) - override fun getShellCommandStdOut(vararg command: String): InputStream = Runtime.getRuntime().exec(command).inputStream - - /** - * Remember to close it after using it. - */ - @Throws(IOException::class) - fun getShellCommandStdErr(vararg command: String): InputStream = Runtime.getRuntime().exec(command).errorStream - - override fun execute(command: String): KMResult<*> { - try { - Runtime.getRuntime().exec(command) - - return Success(Unit) - } catch (e: IOException) { - return KMError.Exception(e) - } - } -} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt b/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt index 08d09239a9..65460856f8 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt @@ -22,6 +22,8 @@ import io.github.sds100.keymapper.system.display.AndroidDisplayAdapter import io.github.sds100.keymapper.system.display.DisplayAdapter import io.github.sds100.keymapper.system.files.AndroidFileAdapter import io.github.sds100.keymapper.system.files.FileAdapter +import io.github.sds100.keymapper.system.foldable.AndroidFoldableAdapter +import io.github.sds100.keymapper.system.foldable.FoldableAdapter import io.github.sds100.keymapper.system.inputmethod.AndroidInputMethodAdapter import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.intents.IntentAdapter @@ -52,6 +54,7 @@ import io.github.sds100.keymapper.system.ringtones.RingtoneAdapter import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.root.SuAdapterImpl import io.github.sds100.keymapper.system.shell.ShellAdapter +import io.github.sds100.keymapper.system.shell.StandardShellAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuAdapterImpl import io.github.sds100.keymapper.system.url.AndroidOpenUrlAdapter @@ -105,6 +108,10 @@ abstract class SystemHiltModule { @Binds abstract fun provideFileAdapter(impl: AndroidFileAdapter): FileAdapter + @Singleton + @Binds + abstract fun provideFoldableAdapter(impl: AndroidFoldableAdapter): FoldableAdapter + @Singleton @Binds abstract fun provideInputMethodAdapter(impl: AndroidInputMethodAdapter): InputMethodAdapter @@ -167,7 +174,7 @@ abstract class SystemHiltModule { @Singleton @Binds - abstract fun provideShellAdapter(impl: Shell): ShellAdapter + abstract fun provideShellAdapter(impl: StandardShellAdapter): ShellAdapter @Singleton @Binds diff --git a/system/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceAdapter.kt index b3c4c68464..1902a17749 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceAdapter.kt @@ -35,7 +35,7 @@ interface AccessibilityServiceAdapter { * Send an event to the service asynchronously. This method * will return immediately and you won't be notified of whether it is sent. */ - fun sendAsync(event: AccessibilityServiceEvent) + fun sendAsync(event: AccessibilityServiceEvent): KMResult /** * A flow of events coming from the service. diff --git a/system/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceEvent.kt b/system/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceEvent.kt index 0c6fa47668..b91f810722 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceEvent.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceEvent.kt @@ -1,9 +1,12 @@ package io.github.sds100.keymapper.system.accessibility +import android.os.Build +import android.view.inputmethod.EditorInfo +import androidx.annotation.RequiresApi import kotlinx.serialization.Serializable @Serializable -abstract class AccessibilityServiceEvent { +abstract class AccessibilityServiceEvent { @Serializable data class Ping(val key: String) : AccessibilityServiceEvent() @@ -23,16 +26,20 @@ abstract class AccessibilityServiceEvent { @Serializable data object ShowKeyboard : AccessibilityServiceEvent() + @RequiresApi(Build.VERSION_CODES.R) @Serializable data class ChangeIme(val imeId: String) : AccessibilityServiceEvent() @Serializable data object DisableService : AccessibilityServiceEvent() + @RequiresApi(Build.VERSION_CODES.TIRAMISU) @Serializable - data class OnInputFocusChange(val isFocussed: Boolean) : AccessibilityServiceEvent() + data class EnableInputMethod(val imeId: String) : AccessibilityServiceEvent() @Serializable - data class EnableInputMethod(val imeId: String) : AccessibilityServiceEvent() + data class GlobalAction(val action: Int) : AccessibilityServiceEvent() + data class OnKeyMapperImeStartInput(val attribute: EditorInfo, val restarting: Boolean) : + AccessibilityServiceEvent() } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/airplanemode/AirplaneModeAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/airplanemode/AirplaneModeAdapter.kt index 6c38f9f3a4..bc0bfcfc11 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/airplanemode/AirplaneModeAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/airplanemode/AirplaneModeAdapter.kt @@ -4,6 +4,6 @@ import io.github.sds100.keymapper.common.utils.KMResult interface AirplaneModeAdapter { fun isEnabled(): Boolean - fun enable(): KMResult<*> - fun disable(): KMResult<*> + suspend fun enable(): KMResult<*> + suspend fun disable(): KMResult<*> } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/airplanemode/AndroidAirplaneModeAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/airplanemode/AndroidAirplaneModeAdapter.kt index df7c4a7c5c..d4360093a2 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/airplanemode/AndroidAirplaneModeAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/airplanemode/AndroidAirplaneModeAdapter.kt @@ -1,36 +1,63 @@ package io.github.sds100.keymapper.system.airplanemode import android.content.Context +import android.os.Build import android.provider.Settings +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult -import io.github.sds100.keymapper.common.utils.onSuccess -import io.github.sds100.keymapper.system.SettingsUtils +import io.github.sds100.keymapper.common.utils.SettingsUtils +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.system.root.SuAdapter -import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @Singleton class AndroidAirplaneModeAdapter @Inject constructor( - @ApplicationContext private val context: Context, - val suAdapter: SuAdapter, + @ApplicationContext private val ctx: Context, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val suAdapter: SuAdapter, + private val coroutineScope: CoroutineScope ) : AirplaneModeAdapter { - private val ctx = context.applicationContext - override fun enable(): KMResult<*> = - suAdapter.execute("settings put global airplane_mode_on 1").onSuccess { - broadcastAirplaneModeChanged(false) + override suspend fun enable(): KMResult<*> { + return if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + systemBridgeConnectionManager.run { bridge -> bridge.setAirplaneMode(true) } + } else { + val success = SettingsUtils.putGlobalSetting(ctx, Settings.Global.AIRPLANE_MODE_ON, 1) + broadcastAirplaneModeChanged(true) + if (success) { + Success(Unit) + } else { + KMError.FailedToModifySystemSetting(Settings.Global.AIRPLANE_MODE_ON) + } } + } - override fun disable(): KMResult<*> = - suAdapter.execute("settings put global airplane_mode_on 0").onSuccess { - broadcastAirplaneModeChanged(false) + override suspend fun disable(): KMResult<*> { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + systemBridgeConnectionManager.run { bridge -> bridge.setAirplaneMode(false) } + } else { + val success = SettingsUtils.putGlobalSetting(ctx, Settings.Global.AIRPLANE_MODE_ON, 0) + if (success) { + broadcastAirplaneModeChanged(false) + Success(Unit) + } else { + KMError.FailedToModifySystemSetting(Settings.Global.AIRPLANE_MODE_ON) + } } + } override fun isEnabled(): Boolean = SettingsUtils.getGlobalSetting(ctx, Settings.Global.AIRPLANE_MODE_ON) == 1 private fun broadcastAirplaneModeChanged(enabled: Boolean) { - suAdapter.execute("am broadcast -a android.intent.action.AIRPLANE_MODE --ez state $enabled") + coroutineScope.launch { + suAdapter.execute("am broadcast -a android.intent.action.AIRPLANE_MODE --ez state $enabled") + } } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt index f28b595ea0..e1d2c0546b 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.system.apps import android.annotation.SuppressLint import android.app.ActivityOptions +import android.app.KeyguardManager import android.app.PendingIntent import android.content.ActivityNotFoundException import android.content.BroadcastReceiver @@ -19,6 +20,7 @@ import android.os.RemoteException import android.os.TransactionTooLargeException import android.provider.MediaStore import android.provider.Settings +import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.core.content.pm.PackageInfoCompat import dagger.hilt.android.qualifiers.ApplicationContext @@ -48,6 +50,10 @@ class AndroidPackageManagerAdapter @Inject constructor( private val ctx: Context = context.applicationContext private val packageManager: PackageManager = ctx.packageManager + private val keyguardManager: KeyguardManager by lazy { + ctx.getSystemService(KeyguardManager::class.java) + } + private val fetchPackages: MutableSharedFlow = MutableSharedFlow() override val onPackagesChanged: MutableSharedFlow = MutableSharedFlow() override val installedPackages = MutableStateFlow>>(State.Loading) @@ -62,7 +68,7 @@ class AndroidPackageManagerAdapter @Inject constructor( Intent.ACTION_PACKAGE_ADDED, Intent.ACTION_PACKAGE_REMOVED, Intent.ACTION_PACKAGE_REPLACED, - -> { + -> { coroutineScope.launch { fetchPackages.emit(Unit) onPackagesChanged.emit(Unit) @@ -233,10 +239,10 @@ class AndroidPackageManagerAdapter @Inject constructor( val leanbackIntent = packageManager.getLeanbackLaunchIntentForPackage(packageName) val normalIntent = packageManager.getLaunchIntentForPackage(packageName) - val intent = leanbackIntent ?: normalIntent + val packageIntent = leanbackIntent ?: normalIntent // intent = null if the app doesn't exist - if (intent == null) { + if (packageIntent == null) { try { val appInfo = ctx.packageManager.getApplicationInfo(packageName, 0) @@ -250,12 +256,18 @@ class AndroidPackageManagerAdapter @Inject constructor( return KMError.AppNotFound(packageName) } } else { - val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.getActivity(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) + // Use a trampoline activity that will dismiss the keyguard when it is locked. + val intent = if (keyguardManager.isKeyguardLocked) { + Intent(ctx, TrampolineActivity::class.java).apply { + putExtra(TrampolineActivity.EXTRA_INTENT, packageIntent) + } } else { - PendingIntent.getActivity(ctx, 0, intent, 0) + packageIntent } + val pendingIntent = + PendingIntent.getActivity(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { val bundle = ActivityOptions.makeBasic() .setPendingIntentBackgroundActivityStartMode( @@ -431,4 +443,9 @@ class AndroidPackageManagerAdapter @Inject constructor( return null } } + + @RequiresApi(Build.VERSION_CODES.R) + override fun getInstallSourcePackageName(): String? { + return packageManager.getInstallSourceInfo(ctx.packageName).installingPackageName + } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/apps/PackageManagerAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/apps/PackageManagerAdapter.kt index 6e61c822cf..d06cdcf040 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/apps/PackageManagerAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/apps/PackageManagerAdapter.kt @@ -1,6 +1,8 @@ package io.github.sds100.keymapper.system.apps import android.graphics.drawable.Drawable +import android.os.Build +import androidx.annotation.RequiresApi import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State import kotlinx.coroutines.flow.Flow @@ -31,6 +33,9 @@ interface PackageManagerAdapter { fun launchCameraApp(): KMResult<*> fun launchSettingsApp(): KMResult<*> + + @RequiresApi(Build.VERSION_CODES.R) + fun getInstallSourcePackageName(): String? } fun PackageManagerAdapter.isAppInstalledFlow(packageName: String): Flow = callbackFlow { @@ -41,10 +46,11 @@ fun PackageManagerAdapter.isAppInstalledFlow(packageName: String): Flow } } -fun PackageManagerAdapter.getPackageInfoFlow(packageName: String): Flow = callbackFlow { - send(getPackageInfo(packageName)) - - onPackagesChanged.collect { +fun PackageManagerAdapter.getPackageInfoFlow(packageName: String): Flow = + callbackFlow { send(getPackageInfo(packageName)) + + onPackagesChanged.collect { + send(getPackageInfo(packageName)) + } } -} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/apps/TrampolineActivity.kt b/system/src/main/java/io/github/sds100/keymapper/system/apps/TrampolineActivity.kt new file mode 100644 index 0000000000..89fc8b24fa --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/apps/TrampolineActivity.kt @@ -0,0 +1,54 @@ +package io.github.sds100.keymapper.system.apps + +import android.app.ActivityOptions +import android.app.KeyguardManager +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.core.os.BundleCompat + +/** + * Use an activity trampoline so that the keyguard is dismissed when launching activities from + * the lock screen. + */ +class TrampolineActivity : ComponentActivity() { + companion object { + const val EXTRA_INTENT = "io.github.sds100.keymapper.EXTRA_INTENT" + } + + private val keyguardManager: KeyguardManager by lazy { + getSystemService(KeyguardManager::class.java) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + keyguardManager.requestDismissKeyguard(this, null) + + val intentExtras = intent?.extras ?: return finish() + + val activityIntent: Intent? = + BundleCompat.getParcelable(intentExtras, EXTRA_INTENT, Intent::class.java) + + if (activityIntent != null) { + val pendingIntent = + PendingIntent.getActivity(this, 0, activityIntent, PendingIntent.FLAG_IMMUTABLE) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val bundle = ActivityOptions.makeBasic() + .setPendingIntentBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED, + ) + .toBundle() + + pendingIntent.send(bundle) + } else { + pendingIntent.send() + } + } + + finish() + } +} \ No newline at end of file diff --git a/system/src/main/java/io/github/sds100/keymapper/system/bluetooth/AndroidBluetoothAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/bluetooth/AndroidBluetoothAdapter.kt index 060bc13655..838a455be7 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/bluetooth/AndroidBluetoothAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/bluetooth/AndroidBluetoothAdapter.kt @@ -8,12 +8,14 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager +import android.os.Build import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -26,6 +28,7 @@ import javax.inject.Singleton class AndroidBluetoothAdapter @Inject constructor( @ApplicationContext private val context: Context, private val coroutineScope: CoroutineScope, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager ) : io.github.sds100.keymapper.system.bluetooth.BluetoothAdapter { private val bluetoothManager: BluetoothManager? = context.getSystemService() @@ -45,6 +48,7 @@ class AndroidBluetoothAdapter @Inject constructor( onReceiveIntent(intent) } } + init { IntentFilter().apply { // these broadcasts can't be received from a manifest declared receiver on Android 8.0+ @@ -65,7 +69,6 @@ class AndroidBluetoothAdapter @Inject constructor( fun onReceiveIntent(intent: Intent) { when (intent.action) { BluetoothDevice.ACTION_ACL_CONNECTED -> { - val device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) ?: return @@ -144,9 +147,13 @@ class AndroidBluetoothAdapter @Inject constructor( return KMError.SystemFeatureNotSupported(PackageManager.FEATURE_BLUETOOTH) } - adapter.enable() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) { + return systemBridgeConnectionManager.run { bridge -> bridge.setBluetoothEnabled(true) } + } else { + adapter.enable() + return Success(Unit) + } - return Success(Unit) } override fun disable(): KMResult<*> { @@ -154,8 +161,11 @@ class AndroidBluetoothAdapter @Inject constructor( return KMError.SystemFeatureNotSupported(PackageManager.FEATURE_BLUETOOTH) } - adapter.disable() - - return Success(Unit) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) { + return systemBridgeConnectionManager.run { bridge -> bridge.setBluetoothEnabled(false) } + } else { + adapter.disable() + return Success(Unit) + } } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt index 18de04aadd..ff90c12619 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/camera/AndroidCameraAdapter.kt @@ -5,8 +5,8 @@ import android.hardware.camera2.CameraAccessException import android.hardware.camera2.CameraCharacteristics import android.hardware.camera2.CameraManager import android.os.Build -import androidx.annotation.RequiresApi import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success @@ -15,8 +15,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import timber.log.Timber -import kotlin.collections.set -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @@ -34,36 +32,31 @@ class AndroidCameraAdapter @Inject constructor( ) private val isFlashEnabledMap = MutableStateFlow(initialMap) - private val torchCallback by lazy { - @RequiresApi(Build.VERSION_CODES.M) - object : CameraManager.TorchCallback() { - override fun onTorchModeChanged(cameraId: String, enabled: Boolean) { - super.onTorchModeChanged(cameraId, enabled) - - cameraManager.apply { - try { - val camera = getCameraCharacteristics(cameraId) - val lensFacing = camera.get(CameraCharacteristics.LENS_FACING)!! - - when (lensFacing) { - CameraCharacteristics.LENS_FACING_FRONT -> - updateState(CameraLens.FRONT, enabled) - - CameraCharacteristics.LENS_FACING_BACK -> - updateState(CameraLens.BACK, enabled) - } - } catch (e: Exception) { - Timber.e(e) + private val torchCallback = object : CameraManager.TorchCallback() { + override fun onTorchModeChanged(cameraId: String, enabled: Boolean) { + super.onTorchModeChanged(cameraId, enabled) + + cameraManager.apply { + try { + val camera = getCameraCharacteristics(cameraId) + val lensFacing = camera.get(CameraCharacteristics.LENS_FACING)!! + + when (lensFacing) { + CameraCharacteristics.LENS_FACING_FRONT -> + updateState(CameraLens.FRONT, enabled) + + CameraCharacteristics.LENS_FACING_BACK -> + updateState(CameraLens.BACK, enabled) } + } catch (e: Exception) { + Timber.e(e) } } } } init { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - cameraManager.registerTorchCallback(torchCallback, null) - } + cameraManager.registerTorchCallback(torchCallback, null) } override fun getFlashInfo(lens: CameraLens): CameraFlashInfo? { @@ -138,11 +131,13 @@ class AndroidCameraAdapter @Inject constructor( return null } - override fun enableFlashlight(lens: CameraLens, strengthPercent: Float?): KMResult<*> = setFlashlightMode(true, lens, strengthPercent) + override fun enableFlashlight(lens: CameraLens, strengthPercent: Float?): KMResult<*> = + setFlashlightMode(true, lens, strengthPercent) override fun disableFlashlight(lens: CameraLens): KMResult<*> = setFlashlightMode(false, lens) - override fun toggleFlashlight(lens: CameraLens, strengthPercent: Float?): KMResult<*> = setFlashlightMode(!isFlashEnabledMap.value[lens]!!, lens, strengthPercent) + override fun toggleFlashlight(lens: CameraLens, strengthPercent: Float?): KMResult<*> = + setFlashlightMode(!isFlashEnabledMap.value[lens]!!, lens, strengthPercent) override fun isFlashlightOn(lens: CameraLens): Boolean = isFlashEnabledMap.value[lens] ?: false @@ -172,14 +167,10 @@ class AndroidCameraAdapter @Inject constructor( val currentStrength = cameraManager.getTorchStrengthLevel(cameraId) - val maxStrength = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getCharacteristicForLens( - lens, - CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL, - ) - } else { - null - } + val maxStrength = getCharacteristicForLens( + lens, + CameraCharacteristics.FLASH_INFO_STRENGTH_MAXIMUM_LEVEL, + ) if (maxStrength != null) { val newStrength = @@ -206,9 +197,6 @@ class AndroidCameraAdapter @Inject constructor( lens: CameraLens, strengthPercent: Float? = null, ): KMResult<*> { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) - } try { val cameraId = getFlashlightCameraIdForLens(lens) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/devices/AndroidDevicesAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/devices/AndroidDevicesAdapter.kt index 4de8e4696a..72bd687201 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/devices/AndroidDevicesAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/devices/AndroidDevicesAdapter.kt @@ -8,14 +8,17 @@ import android.os.Handler import android.os.Looper import android.view.InputDevice import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.common.utils.InputDeviceInfo +import io.github.sds100.keymapper.common.utils.InputDeviceUtils import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.ifIsData import io.github.sds100.keymapper.system.bluetooth.BluetoothDeviceInfo import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter -import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.common.utils.ifIsData import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -25,7 +28,6 @@ import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @@ -36,6 +38,7 @@ class AndroidDevicesAdapter @Inject constructor( private val permissionAdapter: PermissionAdapter, private val coroutineScope: CoroutineScope, ) : DevicesAdapter { + private val ctx = context.applicationContext private val inputManager = ctx.getSystemService() @@ -137,6 +140,10 @@ class AndroidDevicesAdapter @Inject constructor( return KMError.DeviceNotFound(descriptor) } + override fun getInputDevice(deviceId: Int): InputDeviceInfo? { + return InputDevice.getDevice(deviceId)?.let { InputDeviceUtils.createInputDeviceInfo(it) } + } + private fun updateInputDevices() { val devices = mutableListOf() diff --git a/system/src/main/java/io/github/sds100/keymapper/system/devices/DevicesAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/devices/DevicesAdapter.kt index b244531a06..f82be90881 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/devices/DevicesAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/devices/DevicesAdapter.kt @@ -1,8 +1,9 @@ package io.github.sds100.keymapper.system.devices +import io.github.sds100.keymapper.common.utils.InputDeviceInfo import io.github.sds100.keymapper.common.utils.KMResult -import io.github.sds100.keymapper.system.bluetooth.BluetoothDeviceInfo import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.system.bluetooth.BluetoothDeviceInfo import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -16,4 +17,5 @@ interface DevicesAdapter { fun deviceHasKey(id: Int, keyCode: Int): Boolean fun getInputDeviceName(descriptor: String): KMResult + fun getInputDevice(deviceId: Int): InputDeviceInfo? } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt index 377ab9be1f..9067b25632 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt @@ -12,12 +12,12 @@ import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.common.utils.KMError -import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Orientation +import io.github.sds100.keymapper.common.utils.SettingsUtils import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.getRealDisplaySize -import io.github.sds100.keymapper.system.SettingsUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow diff --git a/system/src/main/java/io/github/sds100/keymapper/system/files/AndroidFileAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/files/AndroidFileAdapter.kt index 448ce230da..18a9a553e6 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/files/AndroidFileAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/files/AndroidFileAdapter.kt @@ -10,6 +10,8 @@ import androidx.core.content.FileProvider import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.file.toDocumentFile +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success @@ -19,8 +21,6 @@ import net.lingala.zip4j.ZipFile import java.io.File import java.io.InputStream import java.util.UUID -import dagger.hilt.android.qualifiers.ApplicationContext -import io.github.sds100.keymapper.common.BuildConfigProvider import javax.inject.Inject import javax.inject.Singleton @@ -80,7 +80,8 @@ class AndroidFileAdapter @Inject constructor( } } - override fun getPicturesFolder(): String = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).path + override fun getPicturesFolder(): String = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).path override fun createZipFile(destination: IFile, files: Set): KMResult<*> { val zipUid = UUID.randomUUID().toString() @@ -104,6 +105,8 @@ class AndroidFileAdapter @Inject constructor( input.copyTo(output) } } + } catch (e: Exception) { + return KMError.Exception(e) } finally { tempZipFile.delete() } @@ -124,6 +127,8 @@ class AndroidFileAdapter @Inject constructor( } ZipFile(tempZipFileCopy).extractAll(destination.path) + } catch (e: Exception) { + return@withContext KMError.Exception(e) } finally { tempZipFileCopy.delete() } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/foldable/AndroidFoldableAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/foldable/AndroidFoldableAdapter.kt new file mode 100644 index 0000000000..39c87139ed --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/foldable/AndroidFoldableAdapter.kt @@ -0,0 +1,68 @@ +package io.github.sds100.keymapper.system.foldable + +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@RequiresApi(Build.VERSION_CODES.R) +@Singleton +class AndroidFoldableAdapter @Inject constructor( + @ApplicationContext private val context: Context, +) : FoldableAdapter { + + private val _hingeState = MutableStateFlow(HingeState.Unavailable) + override val hingeState: StateFlow = _hingeState.asStateFlow() + + private val sensorManager: SensorManager? = context.getSystemService() + private val hingeSensor: Sensor? = sensorManager?.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE) + + private val sensorEventListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent?) { + event?.let { + if (it.sensor.type == Sensor.TYPE_HINGE_ANGLE && it.values.isNotEmpty()) { + val angle = it.values[0] + _hingeState.value = HingeState.Available(angle) + } + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { + // Not needed for hinge angle sensor + } + } + + init { + startMonitoring() + } + + private fun startMonitoring() { + if (hingeSensor != null) { + try { + sensorManager?.registerListener( + sensorEventListener, + hingeSensor, + SensorManager.SENSOR_DELAY_NORMAL, + ) + Timber.d("Hinge angle sensor monitoring started") + } catch (e: Exception) { + Timber.e(e, "Failed to start hinge angle sensor monitoring") + _hingeState.value = HingeState.Unavailable + } + } else { + Timber.d("Hinge angle sensor not available on this device") + _hingeState.value = HingeState.Unavailable + } + } +} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/foldable/FoldableAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/foldable/FoldableAdapter.kt new file mode 100644 index 0000000000..f4beb0e5f3 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/foldable/FoldableAdapter.kt @@ -0,0 +1,34 @@ +package io.github.sds100.keymapper.system.foldable + +import android.os.Build +import androidx.annotation.RequiresApi +import kotlinx.coroutines.flow.StateFlow + +/** + * Represents the state of a foldable device hinge. + */ +sealed class HingeState { + /** + * Hinge sensor is not available on this device. + */ + data object Unavailable : HingeState() + + /** + * Hinge sensor is available and reporting angle. + * @param angle The angle in degrees of the hinge. + * 0 degrees means the device is closed/flat. + * 180 degrees means the device is fully open. + */ + data class Available(val angle: Float) : HingeState() +} + +fun HingeState.Available.isOpen(): Boolean = angle >= 150 +fun HingeState.Available.isClosed(): Boolean = angle < 30 + +@RequiresApi(Build.VERSION_CODES.R) +interface FoldableAdapter { + /** + * StateFlow that emits the current hinge state. + */ + val hingeState: StateFlow +} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventInjector.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventInjector.kt deleted file mode 100644 index 5a680f9893..0000000000 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventInjector.kt +++ /dev/null @@ -1,27 +0,0 @@ -package io.github.sds100.keymapper.system.inputevents - -import android.view.KeyEvent -import io.github.sds100.keymapper.system.inputmethod.InputKeyModel - -interface InputEventInjector { - suspend fun inputKeyEvent(model: InputKeyModel) - - fun createInjectedKeyEvent( - eventTime: Long, - action: Int, - model: InputKeyModel, - ): KeyEvent { - return KeyEvent( - eventTime, - eventTime, - action, - model.keyCode, - model.repeat, - model.metaState, - model.deviceId, - model.scanCode, - 0, - model.source, - ) - } -} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KMEvdevEvent.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KMEvdevEvent.kt new file mode 100644 index 0000000000..424f869477 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KMEvdevEvent.kt @@ -0,0 +1,36 @@ +package io.github.sds100.keymapper.system.inputevents + +import io.github.sds100.keymapper.common.models.EvdevDeviceHandle + +data class KMEvdevEvent( + val device: EvdevDeviceHandle, + val type: Int, + val code: Int, + val value: Int, + val androidCode: Int, + val timeSec: Long, + val timeUsec: Long +) : KMInputEvent { + + companion object { + const val TYPE_SYN_EVENT = 0 + const val TYPE_KEY_EVENT = 1 + const val TYPE_REL_EVENT = 2 + + const val VALUE_DOWN = 1 + const val VALUE_UP = 0 + } + + // Look at input-event-codes.h for where these are defined. + // EV_SYN + val isSynEvent: Boolean = type == TYPE_SYN_EVENT + + // EV_KEY + val isKeyEvent: Boolean = type == TYPE_KEY_EVENT + + // EV_REL + val isRelEvent: Boolean = type == TYPE_REL_EVENT + + val isDownEvent: Boolean = isKeyEvent && value == VALUE_DOWN + val isUpEvent: Boolean = isKeyEvent && value == VALUE_UP +} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputevents/MyMotionEvent.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KMGamePadEvent.kt similarity index 50% rename from system/src/main/java/io/github/sds100/keymapper/system/inputevents/MyMotionEvent.kt rename to system/src/main/java/io/github/sds100/keymapper/system/inputevents/KMGamePadEvent.kt index 5e77e3b9f9..55fe8c0440 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputevents/MyMotionEvent.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KMGamePadEvent.kt @@ -1,29 +1,34 @@ package io.github.sds100.keymapper.system.inputevents import android.view.MotionEvent -import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import io.github.sds100.keymapper.system.devices.InputDeviceUtils +import io.github.sds100.keymapper.common.utils.InputDeviceInfo +import io.github.sds100.keymapper.common.utils.InputDeviceUtils /** * This is our own abstraction over MotionEvent so that it is easier to write tests and read * values without relying on the Android SDK. */ -data class MyMotionEvent( +data class KMGamePadEvent( + val eventTime: Long, val metaState: Int, - val device: InputDeviceInfo?, + val device: InputDeviceInfo, val axisHatX: Float, val axisHatY: Float, - val isDpad: Boolean, -) { +) : KMInputEvent { + companion object { - fun fromMotionEvent(event: MotionEvent): MyMotionEvent { - return MyMotionEvent( + fun fromMotionEvent(event: MotionEvent): KMGamePadEvent? { + val device = event.device ?: return null + + return KMGamePadEvent( + eventTime = event.eventTime, metaState = event.metaState, - device = event.device?.let { InputDeviceUtils.createInputDeviceInfo(it) }, + device = InputDeviceUtils.createInputDeviceInfo(device), axisHatX = event.getAxisValue(MotionEvent.AXIS_HAT_X), axisHatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y), - isDpad = InputEventUtils.isDpadDevice(event), ) } } + + val deviceId: Int = device.id } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KMInputEvent.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KMInputEvent.kt new file mode 100644 index 0000000000..1e12eaed08 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KMInputEvent.kt @@ -0,0 +1,3 @@ +package io.github.sds100.keymapper.system.inputevents + +sealed interface KMInputEvent \ No newline at end of file diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KMKeyEvent.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KMKeyEvent.kt new file mode 100644 index 0000000000..2a77588af2 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KMKeyEvent.kt @@ -0,0 +1,40 @@ +package io.github.sds100.keymapper.system.inputevents + +import android.view.KeyEvent +import io.github.sds100.keymapper.common.utils.InputDeviceInfo +import io.github.sds100.keymapper.common.utils.InputDeviceUtils + +/** + * This is our own abstraction over KeyEvent so that it is easier to write tests and read + * values without relying on the Android SDK. + */ +data class KMKeyEvent( + val keyCode: Int, + val action: Int, + val metaState: Int, + val scanCode: Int, + val device: InputDeviceInfo, + val repeatCount: Int, + val source: Int, + val eventTime: Long, +) : KMInputEvent { + + companion object { + fun fromAndroidKeyEvent(keyEvent: KeyEvent): KMKeyEvent? { + val device = keyEvent.device ?: return null + + return KMKeyEvent( + keyCode = keyEvent.keyCode, + action = keyEvent.action, + metaState = keyEvent.metaState, + scanCode = keyEvent.scanCode, + device = InputDeviceUtils.createInputDeviceInfo(device), + repeatCount = keyEvent.repeatCount, + source = keyEvent.source, + eventTime = keyEvent.eventTime, + ) + } + } + + val deviceId: Int = device.id +} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KeyEventUtils.kt similarity index 91% rename from system/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt rename to system/src/main/java/io/github/sds100/keymapper/system/inputevents/KeyEventUtils.kt index 401ba8e4c3..1331dcd998 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/KeyEventUtils.kt @@ -1,12 +1,9 @@ package io.github.sds100.keymapper.system.inputevents -import android.view.InputDevice -import android.view.InputEvent import android.view.KeyEvent import io.github.sds100.keymapper.common.utils.withFlag -object InputEventUtils { - +object KeyEventUtils { private val KEYCODES: IntArray = intArrayOf( KeyEvent.KEYCODE_SOFT_LEFT, KeyEvent.KEYCODE_SOFT_RIGHT, @@ -86,7 +83,6 @@ object InputEventUtils { KeyEvent.KEYCODE_APOSTROPHE, KeyEvent.KEYCODE_SLASH, KeyEvent.KEYCODE_AT, - KeyEvent.KEYCODE_ALT_LEFT, KeyEvent.KEYCODE_HEADSETHOOK, KeyEvent.KEYCODE_FOCUS, KeyEvent.KEYCODE_PLUS, @@ -119,7 +115,6 @@ object InputEventUtils { KeyEvent.KEYCODE_BUTTON_SELECT, KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_ESCAPE, - KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_CTRL_LEFT, KeyEvent.KEYCODE_CTRL_RIGHT, KeyEvent.KEYCODE_CAPS_LOCK, @@ -325,33 +320,7 @@ object InputEventUtils { KeyEvent.KEYCODE_MACRO_4, KeyEvent.KEYCODE_EMOJI_PICKER, KeyEvent.KEYCODE_SCREENSHOT, - ) - - /** - * These are key code maps for the getevent command. These names aren't the same as the - * KeyEvent key codes in the Android SDK so these have to be manually whitelisted - * as people need. - */ - val GET_EVENT_LABEL_TO_KEYCODE: List> = listOf( - "KEY_VOLUMEDOWN" to KeyEvent.KEYCODE_VOLUME_DOWN, - "KEY_VOLUMEUP" to KeyEvent.KEYCODE_VOLUME_UP, - "KEY_MEDIA" to KeyEvent.KEYCODE_HEADSETHOOK, - "KEY_HEADSETHOOK" to KeyEvent.KEYCODE_HEADSETHOOK, - "KEY_CAMERA_FOCUS" to KeyEvent.KEYCODE_FOCUS, - "02fe" to KeyEvent.KEYCODE_CAMERA, - "00fa" to KeyEvent.KEYCODE_CAMERA, - - // This kernel key event code seems to be the Bixby button - // but different ROMs have different key maps and so - // it is reported as different Android key codes. - "02bf" to KeyEvent.KEYCODE_MENU, - "02bf" to KeyEvent.KEYCODE_ASSIST, - - "KEY_SEARCH" to KeyEvent.KEYCODE_SEARCH, - ) - - fun canDetectKeyWhenScreenOff(keyCode: Int): Boolean = - GET_EVENT_LABEL_TO_KEYCODE.any { it.second == keyCode } + ).distinct().toIntArray() val MODIFIER_KEYCODES: Set get() = setOf( @@ -368,11 +337,6 @@ object InputEventUtils { KeyEvent.KEYCODE_FUNCTION, ) - /** - * Used for keyCode to scanCode fallback to go past possible keyCode values - */ - val KEYCODE_TO_SCANCODE_OFFSET: Int = 1000 - fun isModifierKey(keyCode: Int): Boolean = keyCode in MODIFIER_KEYCODES fun isGamepadKeyCode(keyCode: Int): Boolean { @@ -453,13 +417,10 @@ object InputEventUtils { code == KeyEvent.KEYCODE_DPAD_UP_LEFT || code == KeyEvent.KEYCODE_DPAD_UP_RIGHT || code == KeyEvent.KEYCODE_DPAD_DOWN_LEFT || - code == KeyEvent.KEYCODE_DPAD_DOWN_RIGHT + code == KeyEvent.KEYCODE_DPAD_DOWN_RIGHT || + code == KeyEvent.KEYCODE_DPAD_CENTER } - fun isDpadDevice(event: InputEvent): Boolean = - // Check that input comes from a device with directional pads. - event.source and InputDevice.SOURCE_DPAD != InputDevice.SOURCE_DPAD - fun isGamepadButton(keyCode: Int): Boolean { return when (keyCode) { KeyEvent.KEYCODE_BUTTON_A, @@ -498,4 +459,17 @@ object InputEventUtils { else -> false } } + + fun isKeyCodeUnknown(keyCode: Int): Boolean { + // The lowest key code is 1 (KEYCODE_SOFT_LEFT) + return keyCode > KeyEvent.getMaxKeyCode() || keyCode < 1 + } + + fun isPowerButtonKey(keyCode: Int, scanCode: Int): Boolean { + return keyCode == KeyEvent.KEYCODE_POWER || + keyCode == KeyEvent.KEYCODE_TV_POWER || + scanCode == Scancode.KEY_POWER || + scanCode == Scancode.KEY_POWER2 + } } + diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputevents/MyKeyEvent.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/MyKeyEvent.kt deleted file mode 100644 index 6517eefa26..0000000000 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputevents/MyKeyEvent.kt +++ /dev/null @@ -1,13 +0,0 @@ -package io.github.sds100.keymapper.system.inputevents - -import io.github.sds100.keymapper.system.devices.InputDeviceInfo - -data class MyKeyEvent( - val keyCode: Int, - val action: Int, - val metaState: Int, - val scanCode: Int, - val device: InputDeviceInfo?, - val repeatCount: Int, - val source: Int, -) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputevents/Scancode.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/Scancode.kt new file mode 100644 index 0000000000..bc2d90fcd3 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputevents/Scancode.kt @@ -0,0 +1,635 @@ +package io.github.sds100.keymapper.system.inputevents + +object Scancode { + const val KEY_1 = 2 + const val KEY_2 = 3 + const val KEY_3 = 4 + const val KEY_4 = 5 + const val KEY_5 = 6 + const val KEY_6 = 7 + const val KEY_7 = 8 + const val KEY_8 = 9 + const val KEY_9 = 10 + const val KEY_0 = 11 + const val KEY_MINUS = 12 + const val KEY_EQUAL = 13 + const val KEY_BACKSPACE = 14 + const val KEY_TAB = 15 + const val KEY_Q = 16 + const val KEY_W = 17 + const val KEY_E = 18 + const val KEY_R = 19 + const val KEY_T = 20 + const val KEY_Y = 21 + const val KEY_U = 22 + const val KEY_I = 23 + const val KEY_O = 24 + const val KEY_P = 25 + const val KEY_LEFTBRACE = 26 + const val KEY_RIGHTBRACE = 27 + const val KEY_ENTER = 28 + const val KEY_LEFTCTRL = 29 + const val KEY_A = 30 + const val KEY_S = 31 + const val KEY_D = 32 + const val KEY_F = 33 + const val KEY_G = 34 + const val KEY_H = 35 + const val KEY_J = 36 + const val KEY_K = 37 + const val KEY_L = 38 + const val KEY_SEMICOLON = 39 + const val KEY_APOSTROPHE = 40 + const val KEY_GRAVE = 41 + const val KEY_LEFTSHIFT = 42 + const val KEY_BACKSLASH = 43 + const val KEY_Z = 44 + const val KEY_X = 45 + const val KEY_C = 46 + const val KEY_V = 47 + const val KEY_B = 48 + const val KEY_N = 49 + const val KEY_M = 50 + const val KEY_COMMA = 51 + const val KEY_DOT = 52 + const val KEY_SLASH = 53 + const val KEY_RIGHTSHIFT = 54 + const val KEY_KPASTERISK = 55 + const val KEY_LEFTALT = 56 + const val KEY_SPACE = 57 + const val KEY_CAPSLOCK = 58 + const val KEY_F1 = 59 + const val KEY_F2 = 60 + const val KEY_F3 = 61 + const val KEY_F4 = 62 + const val KEY_F5 = 63 + const val KEY_F6 = 64 + const val KEY_F7 = 65 + const val KEY_F8 = 66 + const val KEY_F9 = 67 + const val KEY_F10 = 68 + const val KEY_NUMLOCK = 69 + const val KEY_SCROLLLOCK = 70 + const val KEY_KP7 = 71 + const val KEY_KP8 = 72 + const val KEY_KP9 = 73 + const val KEY_KPMINUS = 74 + const val KEY_KP4 = 75 + const val KEY_KP5 = 76 + const val KEY_KP6 = 77 + const val KEY_KPPLUS = 78 + const val KEY_KP1 = 79 + const val KEY_KP2 = 80 + const val KEY_KP3 = 81 + const val KEY_KP0 = 82 + const val KEY_KPDOT = 83 + const val KEY_ZENKAKUHANKAKU = 85 + const val KEY_102ND = 86 + const val KEY_F11 = 87 + const val KEY_F12 = 88 + const val KEY_RO = 89 + const val KEY_KATAKANA = 90 + const val KEY_HIRAGANA = 91 + const val KEY_HENKAN = 92 + const val KEY_KATAKANAHIRAGANA = 93 + const val KEY_MUHENKAN = 94 + const val KEY_KPJPCOMMA = 95 + const val KEY_KPENTER = 96 + const val KEY_RIGHTCTRL = 97 + const val KEY_KPSLASH = 98 + const val KEY_SYSRQ = 99 + const val KEY_RIGHTALT = 100 + const val KEY_LINEFEED = 101 + const val KEY_HOME = 102 + const val KEY_UP = 103 + const val KEY_PAGEUP = 104 + const val KEY_LEFT = 105 + const val KEY_RIGHT = 106 + const val KEY_END = 107 + const val KEY_DOWN = 108 + const val KEY_PAGEDOWN = 109 + const val KEY_INSERT = 110 + const val KEY_DELETE = 111 + const val KEY_MACRO = 112 + const val KEY_MUTE = 113 + const val KEY_VOLUMEDOWN = 114 + const val KEY_VOLUMEUP = 115 + const val KEY_POWER = 116 + const val KEY_KPEQUAL = 117 + const val KEY_KPPLUSMINUS = 118 + const val KEY_PAUSE = 119 + const val KEY_SCALE = 120 + const val KEY_KPCOMMA = 121 + const val KEY_HANGEUL = 122 + const val KEY_HANGUEL = KEY_HANGEUL + const val KEY_HANJA = 123 + const val KEY_YEN = 124 + const val KEY_LEFTMETA = 125 + const val KEY_RIGHTMETA = 126 + const val KEY_COMPOSE = 127 + const val KEY_STOP = 128 + const val KEY_AGAIN = 129 + const val KEY_PROPS = 130 + const val KEY_UNDO = 131 + const val KEY_FRONT = 132 + const val KEY_COPY = 133 + const val KEY_OPEN = 134 + const val KEY_PASTE = 135 + const val KEY_FIND = 136 + const val KEY_CUT = 137 + const val KEY_HELP = 138 + const val KEY_MENU = 139 + const val KEY_CALC = 140 + const val KEY_SETUP = 141 + const val KEY_SLEEP = 142 + const val KEY_WAKEUP = 143 + const val KEY_FILE = 144 + const val KEY_SENDFILE = 145 + const val KEY_DELETEFILE = 146 + const val KEY_XFER = 147 + const val KEY_PROG1 = 148 + const val KEY_PROG2 = 149 + const val KEY_WWW = 150 + const val KEY_MSDOS = 151 + const val KEY_COFFEE = 152 + const val KEY_SCREENLOCK = KEY_COFFEE + const val KEY_ROTATE_DISPLAY = 153 + const val KEY_DIRECTION = KEY_ROTATE_DISPLAY + const val KEY_CYCLEWINDOWS = 154 + const val KEY_MAIL = 155 + const val KEY_BOOKMARKS = 156 + const val KEY_COMPUTER = 157 + const val KEY_BACK = 158 + const val KEY_FORWARD = 159 + const val KEY_CLOSECD = 160 + const val KEY_EJECTCD = 161 + const val KEY_EJECTCLOSECD = 162 + const val KEY_NEXTSONG = 163 + const val KEY_PLAYPAUSE = 164 + const val KEY_PREVIOUSSONG = 165 + const val KEY_STOPCD = 166 + const val KEY_RECORD = 167 + const val KEY_REWIND = 168 + const val KEY_PHONE = 169 + const val KEY_ISO = 170 + const val KEY_CONFIG = 171 + const val KEY_HOMEPAGE = 172 + const val KEY_REFRESH = 173 + const val KEY_EXIT = 174 + const val KEY_MOVE = 175 + const val KEY_EDIT = 176 + const val KEY_SCROLLUP = 177 + const val KEY_SCROLLDOWN = 178 + const val KEY_KPLEFTPAREN = 179 + const val KEY_KPRIGHTPAREN = 180 + const val KEY_NEW = 181 + const val KEY_REDO = 182 + const val KEY_F13 = 183 + const val KEY_F14 = 184 + const val KEY_F15 = 185 + const val KEY_F16 = 186 + const val KEY_F17 = 187 + const val KEY_F18 = 188 + const val KEY_F19 = 189 + const val KEY_F20 = 190 + const val KEY_F21 = 191 + const val KEY_F22 = 192 + const val KEY_F23 = 193 + const val KEY_F24 = 194 + const val KEY_PLAYCD = 200 + const val KEY_PAUSECD = 201 + const val KEY_PROG3 = 202 + const val KEY_PROG4 = 203 + const val KEY_ALL_APPLICATIONS = 204 + const val KEY_DASHBOARD = KEY_ALL_APPLICATIONS + const val KEY_SUSPEND = 205 + const val KEY_CLOSE = 206 + const val KEY_PLAY = 207 + const val KEY_FASTFORWARD = 208 + const val KEY_BASSBOOST = 209 + const val KEY_PRINT = 210 + const val KEY_HP = 211 + const val KEY_CAMERA = 212 + const val KEY_SOUND = 213 + const val KEY_QUESTION = 214 + const val KEY_EMAIL = 215 + const val KEY_CHAT = 216 + const val KEY_SEARCH = 217 + const val KEY_CONNECT = 218 + const val KEY_FINANCE = 219 + const val KEY_SPORT = 220 + const val KEY_SHOP = 221 + const val KEY_ALTERASE = 222 + const val KEY_CANCEL = 223 + const val KEY_BRIGHTNESSDOWN = 224 + const val KEY_BRIGHTNESSUP = 225 + const val KEY_MEDIA = 226 + const val KEY_SWITCHVIDEOMODE = 227 + const val KEY_KBDILLUMTOGGLE = 228 + const val KEY_KBDILLUMDOWN = 229 + const val KEY_KBDILLUMUP = 230 + const val KEY_SEND = 231 + const val KEY_REPLY = 232 + const val KEY_FORWARDMAIL = 233 + const val KEY_SAVE = 234 + const val KEY_DOCUMENTS = 235 + const val KEY_BATTERY = 236 + const val KEY_BLUETOOTH = 237 + const val KEY_WLAN = 238 + const val KEY_UWB = 239 + const val KEY_UNKNOWN = 240 + const val KEY_VIDEO_NEXT = 241 + const val KEY_VIDEO_PREV = 242 + const val KEY_BRIGHTNESS_CYCLE = 243 + const val KEY_BRIGHTNESS_AUTO = 244 + const val KEY_BRIGHTNESS_ZERO = KEY_BRIGHTNESS_AUTO + const val KEY_DISPLAY_OFF = 245 + const val KEY_WWAN = 246 + const val KEY_WIMAX = KEY_WWAN + const val KEY_RFKILL = 247 + const val KEY_MICMUTE = 248 + const val BTN_MISC = 0x100 + const val BTN_0 = 0x100 + const val BTN_1 = 0x101 + const val BTN_2 = 0x102 + const val BTN_3 = 0x103 + const val BTN_4 = 0x104 + const val BTN_5 = 0x105 + const val BTN_6 = 0x106 + const val BTN_7 = 0x107 + const val BTN_8 = 0x108 + const val BTN_9 = 0x109 + const val BTN_MOUSE = 0x110 + const val BTN_LEFT = 0x110 + const val BTN_RIGHT = 0x111 + const val BTN_MIDDLE = 0x112 + const val BTN_SIDE = 0x113 + const val BTN_EXTRA = 0x114 + const val BTN_FORWARD = 0x115 + const val BTN_BACK = 0x116 + const val BTN_TASK = 0x117 + const val BTN_JOYSTICK = 0x120 + const val BTN_TRIGGER = 0x120 + const val BTN_THUMB = 0x121 + const val BTN_THUMB2 = 0x122 + const val BTN_TOP = 0x123 + const val BTN_TOP2 = 0x124 + const val BTN_PINKIE = 0x125 + const val BTN_BASE = 0x126 + const val BTN_BASE2 = 0x127 + const val BTN_BASE3 = 0x128 + const val BTN_BASE4 = 0x129 + const val BTN_BASE5 = 0x12a + const val BTN_BASE6 = 0x12b + const val BTN_DEAD = 0x12f + const val BTN_GAMEPAD = 0x130 + const val BTN_SOUTH = 0x130 + const val BTN_A = BTN_SOUTH + const val BTN_EAST = 0x131 + const val BTN_B = BTN_EAST + const val BTN_C = 0x132 + const val BTN_NORTH = 0x133 + const val BTN_X = BTN_NORTH + const val BTN_WEST = 0x134 + const val BTN_Y = BTN_WEST + const val BTN_Z = 0x135 + const val BTN_TL = 0x136 + const val BTN_TR = 0x137 + const val BTN_TL2 = 0x138 + const val BTN_TR2 = 0x139 + const val BTN_SELECT = 0x13a + const val BTN_START = 0x13b + const val BTN_MODE = 0x13c + const val BTN_THUMBL = 0x13d + const val BTN_THUMBR = 0x13e + const val BTN_DIGI = 0x140 + const val BTN_TOOL_PEN = 0x140 + const val BTN_TOOL_RUBBER = 0x141 + const val BTN_TOOL_BRUSH = 0x142 + const val BTN_TOOL_PENCIL = 0x143 + const val BTN_TOOL_AIRBRUSH = 0x144 + const val BTN_TOOL_FINGER = 0x145 + const val BTN_TOOL_MOUSE = 0x146 + const val BTN_TOOL_LENS = 0x147 + const val BTN_TOOL_QUINTTAP = 0x148 + const val BTN_STYLUS3 = 0x149 + const val BTN_TOUCH = 0x14a + const val BTN_STYLUS = 0x14b + const val BTN_STYLUS2 = 0x14c + const val BTN_TOOL_DOUBLETAP = 0x14d + const val BTN_TOOL_TRIPLETAP = 0x14e + const val BTN_TOOL_QUADTAP = 0x14f + const val BTN_WHEEL = 0x150 + const val BTN_GEAR_DOWN = 0x150 + const val BTN_GEAR_UP = 0x151 + const val KEY_OK = 0x160 + const val KEY_SELECT = 0x161 + const val KEY_GOTO = 0x162 + const val KEY_CLEAR = 0x163 + const val KEY_POWER2 = 0x164 + const val KEY_OPTION = 0x165 + const val KEY_INFO = 0x166 + const val KEY_TIME = 0x167 + const val KEY_VENDOR = 0x168 + const val KEY_ARCHIVE = 0x169 + const val KEY_PROGRAM = 0x16a + const val KEY_CHANNEL = 0x16b + const val KEY_FAVORITES = 0x16c + const val KEY_EPG = 0x16d + const val KEY_PVR = 0x16e + const val KEY_MHP = 0x16f + const val KEY_LANGUAGE = 0x170 + const val KEY_TITLE = 0x171 + const val KEY_SUBTITLE = 0x172 + const val KEY_ANGLE = 0x173 + const val KEY_FULL_SCREEN = 0x174 + const val KEY_ZOOM = KEY_FULL_SCREEN + const val KEY_MODE = 0x175 + const val KEY_KEYBOARD = 0x176 + const val KEY_ASPECT_RATIO = 0x177 + const val KEY_SCREEN = KEY_ASPECT_RATIO + const val KEY_PC = 0x178 + const val KEY_TV = 0x179 + const val KEY_TV2 = 0x17a + const val KEY_VCR = 0x17b + const val KEY_VCR2 = 0x17c + const val KEY_SAT = 0x17d + const val KEY_SAT2 = 0x17e + const val KEY_CD = 0x17f + const val KEY_TAPE = 0x180 + const val KEY_RADIO = 0x181 + const val KEY_TUNER = 0x182 + const val KEY_PLAYER = 0x183 + const val KEY_TEXT = 0x184 + const val KEY_DVD = 0x185 + const val KEY_AUX = 0x186 + const val KEY_MP3 = 0x187 + const val KEY_AUDIO = 0x188 + const val KEY_VIDEO = 0x189 + const val KEY_DIRECTORY = 0x18a + const val KEY_LIST = 0x18b + const val KEY_MEMO = 0x18c + const val KEY_CALENDAR = 0x18d + const val KEY_RED = 0x18e + const val KEY_GREEN = 0x18f + const val KEY_YELLOW = 0x190 + const val KEY_BLUE = 0x191 + const val KEY_CHANNELUP = 0x192 + const val KEY_CHANNELDOWN = 0x193 + const val KEY_FIRST = 0x194 + const val KEY_LAST = 0x195 + const val KEY_AB = 0x196 + const val KEY_NEXT = 0x197 + const val KEY_RESTART = 0x198 + const val KEY_SLOW = 0x199 + const val KEY_SHUFFLE = 0x19a + const val KEY_BREAK = 0x19b + const val KEY_PREVIOUS = 0x19c + const val KEY_DIGITS = 0x19d + const val KEY_TEEN = 0x19e + const val KEY_TWEN = 0x19f + const val KEY_VIDEOPHONE = 0x1a0 + const val KEY_GAMES = 0x1a1 + const val KEY_ZOOMIN = 0x1a2 + const val KEY_ZOOMOUT = 0x1a3 + const val KEY_ZOOMRESET = 0x1a4 + const val KEY_WORDPROCESSOR = 0x1a5 + const val KEY_EDITOR = 0x1a6 + const val KEY_SPREADSHEET = 0x1a7 + const val KEY_GRAPHICSEDITOR = 0x1a8 + const val KEY_PRESENTATION = 0x1a9 + const val KEY_DATABASE = 0x1aa + const val KEY_NEWS = 0x1ab + const val KEY_VOICEMAIL = 0x1ac + const val KEY_ADDRESSBOOK = 0x1ad + const val KEY_MESSENGER = 0x1ae + const val KEY_DISPLAYTOGGLE = 0x1af + const val KEY_BRIGHTNESS_TOGGLE = KEY_DISPLAYTOGGLE + const val KEY_SPELLCHECK = 0x1b0 + const val KEY_LOGOFF = 0x1b1 + const val KEY_DOLLAR = 0x1b2 + const val KEY_EURO = 0x1b3 + const val KEY_FRAMEBACK = 0x1b4 + const val KEY_FRAMEFORWARD = 0x1b5 + const val KEY_CONTEXT_MENU = 0x1b6 + const val KEY_MEDIA_REPEAT = 0x1b7 + const val KEY_10CHANNELSUP = 0x1b8 + const val KEY_10CHANNELSDOWN = 0x1b9 + const val KEY_IMAGES = 0x1ba + const val KEY_NOTIFICATION_CENTER = 0x1bc + const val KEY_PICKUP_PHONE = 0x1bd + const val KEY_HANGUP_PHONE = 0x1be + const val KEY_DEL_EOL = 0x1c0 + const val KEY_DEL_EOS = 0x1c1 + const val KEY_INS_LINE = 0x1c2 + const val KEY_DEL_LINE = 0x1c3 + const val KEY_FN = 0x1d0 + const val KEY_FN_ESC = 0x1d1 + const val KEY_FN_F1 = 0x1d2 + const val KEY_FN_F2 = 0x1d3 + const val KEY_FN_F3 = 0x1d4 + const val KEY_FN_F4 = 0x1d5 + const val KEY_FN_F5 = 0x1d6 + const val KEY_FN_F6 = 0x1d7 + const val KEY_FN_F7 = 0x1d8 + const val KEY_FN_F8 = 0x1d9 + const val KEY_FN_F9 = 0x1da + const val KEY_FN_F10 = 0x1db + const val KEY_FN_F11 = 0x1dc + const val KEY_FN_F12 = 0x1dd + const val KEY_FN_1 = 0x1de + const val KEY_FN_2 = 0x1df + const val KEY_FN_D = 0x1e0 + const val KEY_FN_E = 0x1e1 + const val KEY_FN_F = 0x1e2 + const val KEY_FN_S = 0x1e3 + const val KEY_FN_B = 0x1e4 + const val KEY_FN_RIGHT_SHIFT = 0x1e5 + const val KEY_BRL_DOT1 = 0x1f1 + const val KEY_BRL_DOT2 = 0x1f2 + const val KEY_BRL_DOT3 = 0x1f3 + const val KEY_BRL_DOT4 = 0x1f4 + const val KEY_BRL_DOT5 = 0x1f5 + const val KEY_BRL_DOT6 = 0x1f6 + const val KEY_BRL_DOT7 = 0x1f7 + const val KEY_BRL_DOT8 = 0x1f8 + const val KEY_BRL_DOT9 = 0x1f9 + const val KEY_BRL_DOT10 = 0x1fa + const val KEY_NUMERIC_0 = 0x200 + const val KEY_NUMERIC_1 = 0x201 + const val KEY_NUMERIC_2 = 0x202 + const val KEY_NUMERIC_3 = 0x203 + const val KEY_NUMERIC_4 = 0x204 + const val KEY_NUMERIC_5 = 0x205 + const val KEY_NUMERIC_6 = 0x206 + const val KEY_NUMERIC_7 = 0x207 + const val KEY_NUMERIC_8 = 0x208 + const val KEY_NUMERIC_9 = 0x209 + const val KEY_NUMERIC_STAR = 0x20a + const val KEY_NUMERIC_POUND = 0x20b + const val KEY_NUMERIC_A = 0x20c + const val KEY_NUMERIC_B = 0x20d + const val KEY_NUMERIC_C = 0x20e + const val KEY_NUMERIC_D = 0x20f + const val KEY_CAMERA_FOCUS = 0x210 + const val KEY_WPS_BUTTON = 0x211 + const val KEY_TOUCHPAD_TOGGLE = 0x212 + const val KEY_TOUCHPAD_ON = 0x213 + const val KEY_TOUCHPAD_OFF = 0x214 + const val KEY_CAMERA_ZOOMIN = 0x215 + const val KEY_CAMERA_ZOOMOUT = 0x216 + const val KEY_CAMERA_UP = 0x217 + const val KEY_CAMERA_DOWN = 0x218 + const val KEY_CAMERA_LEFT = 0x219 + const val KEY_CAMERA_RIGHT = 0x21a + const val KEY_ATTENDANT_ON = 0x21b + const val KEY_ATTENDANT_OFF = 0x21c + const val KEY_ATTENDANT_TOGGLE = 0x21d + const val KEY_LIGHTS_TOGGLE = 0x21e + const val BTN_DPAD_UP = 0x220 + const val BTN_DPAD_DOWN = 0x221 + const val BTN_DPAD_LEFT = 0x222 + const val BTN_DPAD_RIGHT = 0x223 + const val KEY_ALS_TOGGLE = 0x230 + const val KEY_ROTATE_LOCK_TOGGLE = 0x231 + const val KEY_BUTTONCONFIG = 0x240 + const val KEY_TASKMANAGER = 0x241 + const val KEY_JOURNAL = 0x242 + const val KEY_CONTROLPANEL = 0x243 + const val KEY_APPSELECT = 0x244 + const val KEY_SCREENSAVER = 0x245 + const val KEY_VOICECOMMAND = 0x246 + const val KEY_ASSISTANT = 0x247 + const val KEY_KBD_LAYOUT_NEXT = 0x248 + const val KEY_EMOJI_PICKER = 0x249 + const val KEY_DICTATE = 0x24a + const val KEY_CAMERA_ACCESS_ENABLE = 0x24b + const val KEY_CAMERA_ACCESS_DISABLE = 0x24c + const val KEY_CAMERA_ACCESS_TOGGLE = 0x24d + const val KEY_BRIGHTNESS_MIN = 0x250 + const val KEY_BRIGHTNESS_MAX = 0x251 + const val KEY_KBDINPUTASSIST_PREV = 0x260 + const val KEY_KBDINPUTASSIST_NEXT = 0x261 + const val KEY_KBDINPUTASSIST_PREVGROUP = 0x262 + const val KEY_KBDINPUTASSIST_NEXTGROUP = 0x263 + const val KEY_KBDINPUTASSIST_ACCEPT = 0x264 + const val KEY_KBDINPUTASSIST_CANCEL = 0x265 + const val KEY_RIGHT_UP = 0x266 + const val KEY_RIGHT_DOWN = 0x267 + const val KEY_LEFT_UP = 0x268 + const val KEY_LEFT_DOWN = 0x269 + const val KEY_ROOT_MENU = 0x26a + const val KEY_MEDIA_TOP_MENU = 0x26b + const val KEY_NUMERIC_11 = 0x26c + const val KEY_NUMERIC_12 = 0x26d + const val KEY_AUDIO_DESC = 0x26e + const val KEY_3D_MODE = 0x26f + const val KEY_NEXT_FAVORITE = 0x270 + const val KEY_STOP_RECORD = 0x271 + const val KEY_PAUSE_RECORD = 0x272 + const val KEY_VOD = 0x273 + const val KEY_UNMUTE = 0x274 + const val KEY_FASTREVERSE = 0x275 + const val KEY_SLOWREVERSE = 0x276 + const val KEY_DATA = 0x277 + const val KEY_ONSCREEN_KEYBOARD = 0x278 + const val KEY_PRIVACY_SCREEN_TOGGLE = 0x279 + const val KEY_SELECTIVE_SCREENSHOT = 0x27a + const val KEY_NEXT_ELEMENT = 0x27b + const val KEY_PREVIOUS_ELEMENT = 0x27c + const val KEY_AUTOPILOT_ENGAGE_TOGGLE = 0x27d + const val KEY_MARK_WAYPOINT = 0x27e + const val KEY_SOS = 0x27f + const val KEY_NAV_CHART = 0x280 + const val KEY_FISHING_CHART = 0x281 + const val KEY_SINGLE_RANGE_RADAR = 0x282 + const val KEY_DUAL_RANGE_RADAR = 0x283 + const val KEY_RADAR_OVERLAY = 0x284 + const val KEY_TRADITIONAL_SONAR = 0x285 + const val KEY_CLEARVU_SONAR = 0x286 + const val KEY_SIDEVU_SONAR = 0x287 + const val KEY_NAV_INFO = 0x288 + const val KEY_BRIGHTNESS_MENU = 0x289 + const val KEY_MACRO1 = 0x290 + const val KEY_MACRO2 = 0x291 + const val KEY_MACRO3 = 0x292 + const val KEY_MACRO4 = 0x293 + const val KEY_MACRO5 = 0x294 + const val KEY_MACRO6 = 0x295 + const val KEY_MACRO7 = 0x296 + const val KEY_MACRO8 = 0x297 + const val KEY_MACRO9 = 0x298 + const val KEY_MACRO10 = 0x299 + const val KEY_MACRO11 = 0x29a + const val KEY_MACRO12 = 0x29b + const val KEY_MACRO13 = 0x29c + const val KEY_MACRO14 = 0x29d + const val KEY_MACRO15 = 0x29e + const val KEY_MACRO16 = 0x29f + const val KEY_MACRO17 = 0x2a0 + const val KEY_MACRO18 = 0x2a1 + const val KEY_MACRO19 = 0x2a2 + const val KEY_MACRO20 = 0x2a3 + const val KEY_MACRO21 = 0x2a4 + const val KEY_MACRO22 = 0x2a5 + const val KEY_MACRO23 = 0x2a6 + const val KEY_MACRO24 = 0x2a7 + const val KEY_MACRO25 = 0x2a8 + const val KEY_MACRO26 = 0x2a9 + const val KEY_MACRO27 = 0x2aa + const val KEY_MACRO28 = 0x2ab + const val KEY_MACRO29 = 0x2ac + const val KEY_MACRO30 = 0x2ad + const val KEY_MACRO_RECORD_START = 0x2b0 + const val KEY_MACRO_RECORD_STOP = 0x2b1 + const val KEY_MACRO_PRESET_CYCLE = 0x2b2 + const val KEY_MACRO_PRESET1 = 0x2b3 + const val KEY_MACRO_PRESET2 = 0x2b4 + const val KEY_MACRO_PRESET3 = 0x2b5 + const val KEY_KBD_LCD_MENU1 = 0x2b8 + const val KEY_KBD_LCD_MENU2 = 0x2b9 + const val KEY_KBD_LCD_MENU3 = 0x2ba + const val KEY_KBD_LCD_MENU4 = 0x2bb + const val KEY_KBD_LCD_MENU5 = 0x2bc + const val BTN_TRIGGER_HAPPY = 0x2c0 + const val BTN_TRIGGER_HAPPY1 = 0x2c0 + const val BTN_TRIGGER_HAPPY2 = 0x2c1 + const val BTN_TRIGGER_HAPPY3 = 0x2c2 + const val BTN_TRIGGER_HAPPY4 = 0x2c3 + const val BTN_TRIGGER_HAPPY5 = 0x2c4 + const val BTN_TRIGGER_HAPPY6 = 0x2c5 + const val BTN_TRIGGER_HAPPY7 = 0x2c6 + const val BTN_TRIGGER_HAPPY8 = 0x2c7 + const val BTN_TRIGGER_HAPPY9 = 0x2c8 + const val BTN_TRIGGER_HAPPY10 = 0x2c9 + const val BTN_TRIGGER_HAPPY11 = 0x2ca + const val BTN_TRIGGER_HAPPY12 = 0x2cb + const val BTN_TRIGGER_HAPPY13 = 0x2cc + const val BTN_TRIGGER_HAPPY14 = 0x2cd + const val BTN_TRIGGER_HAPPY15 = 0x2ce + const val BTN_TRIGGER_HAPPY16 = 0x2cf + const val BTN_TRIGGER_HAPPY17 = 0x2d0 + const val BTN_TRIGGER_HAPPY18 = 0x2d1 + const val BTN_TRIGGER_HAPPY19 = 0x2d2 + const val BTN_TRIGGER_HAPPY20 = 0x2d3 + const val BTN_TRIGGER_HAPPY21 = 0x2d4 + const val BTN_TRIGGER_HAPPY22 = 0x2d5 + const val BTN_TRIGGER_HAPPY23 = 0x2d6 + const val BTN_TRIGGER_HAPPY24 = 0x2d7 + const val BTN_TRIGGER_HAPPY25 = 0x2d8 + const val BTN_TRIGGER_HAPPY26 = 0x2d9 + const val BTN_TRIGGER_HAPPY27 = 0x2da + const val BTN_TRIGGER_HAPPY28 = 0x2db + const val BTN_TRIGGER_HAPPY29 = 0x2dc + const val BTN_TRIGGER_HAPPY30 = 0x2dd + const val BTN_TRIGGER_HAPPY31 = 0x2de + const val BTN_TRIGGER_HAPPY32 = 0x2df + const val BTN_TRIGGER_HAPPY33 = 0x2e0 + const val BTN_TRIGGER_HAPPY34 = 0x2e1 + const val BTN_TRIGGER_HAPPY35 = 0x2e2 + const val BTN_TRIGGER_HAPPY36 = 0x2e3 + const val BTN_TRIGGER_HAPPY37 = 0x2e4 + const val BTN_TRIGGER_HAPPY38 = 0x2e5 + const val BTN_TRIGGER_HAPPY39 = 0x2e6 + const val BTN_TRIGGER_HAPPY40 = 0x2e7 +} \ No newline at end of file diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt index 5f3598ae29..11d23b6b02 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt @@ -4,66 +4,43 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.database.ContentObserver -import android.net.Uri import android.os.Build -import android.os.Handler -import android.os.Looper import android.provider.Settings import android.view.inputmethod.InputMethodManager import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import dagger.hilt.android.qualifiers.ApplicationContext -import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success -import io.github.sds100.keymapper.common.utils.onFailure -import io.github.sds100.keymapper.common.utils.onSuccess -import io.github.sds100.keymapper.common.utils.otherwise import io.github.sds100.keymapper.common.utils.then import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.system.JobSchedulerHelper -import io.github.sds100.keymapper.system.SettingsUtils -import io.github.sds100.keymapper.system.SystemError -import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter -import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceEvent -import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState -import io.github.sds100.keymapper.system.permissions.Permission -import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.root.SuAdapter +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.runBlocking import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton @Singleton class AndroidInputMethodAdapter @Inject constructor( @ApplicationContext private val context: Context, private val coroutineScope: CoroutineScope, - private val serviceAdapter: AccessibilityServiceAdapter, - private val permissionAdapter: PermissionAdapter, private val suAdapter: SuAdapter, - private val buildConfigProvider: BuildConfigProvider, ) : InputMethodAdapter { companion object { const val SETTINGS_SECURE_SUBTYPE_HISTORY_KEY = "input_methods_subtype_history" } - override val inputMethodHistory by lazy { + override val inputMethodHistory: MutableStateFlow> by lazy { val initialValues = getImeHistory().mapNotNull { getInfoById(it).valueOrNull() } MutableStateFlow(initialValues) } @@ -85,7 +62,11 @@ class AndroidInputMethodAdapter @Inject constructor( private val inputMethodManager: InputMethodManager = ctx.getSystemService()!! - override val inputMethods by lazy { MutableStateFlow(getInputMethods()) } + override val inputMethods: MutableStateFlow> by lazy { + MutableStateFlow( + getInputMethods(), + ) + } override val chosenIme: StateFlow = inputMethods @@ -94,67 +75,14 @@ class AndroidInputMethodAdapter @Inject constructor( if (it == null) { Timber.e("No input method is chosen.") } else { - Timber.i("On input method chosen, chosen IME = ${chosenIme.value}") + Timber.d("On input method chosen, chosen IME = ${chosenIme.value}") } } .stateIn(coroutineScope, SharingStarted.Lazily, getChosenIme()) - override val isUserInputRequiredToChangeIme: Flow = channelFlow { - suspend fun invalidate() { - when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && - serviceAdapter.state.first() == AccessibilityServiceState.ENABLED -> send( - true, - ) - - permissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS) -> send(true) - - else -> send(false) - } - } - - invalidate() - - launch { - permissionAdapter.onPermissionsUpdate.collectLatest { - invalidate() - } - } - - launch { - serviceAdapter.state.collectLatest { - invalidate() - } - } - } - init { // use job scheduler because there is there is a much shorter delay when the app is in the background - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - JobSchedulerHelper.observeInputMethods(ctx) - } else { - val observer = object : ContentObserver(Handler(Looper.getMainLooper())) { - override fun onChange(selfChange: Boolean, uri: Uri?) { - super.onChange(selfChange, uri) - - onInputMethodsUpdate() - } - } - - ctx.contentResolver.registerContentObserver( - Settings.Secure.getUriFor(Settings.Secure.ENABLED_INPUT_METHODS), - false, - observer, - ) - - ctx.contentResolver.registerContentObserver( - Settings.Secure.getUriFor( - SETTINGS_SECURE_SUBTYPE_HISTORY_KEY, - ), - false, - observer, - ) - } + JobSchedulerHelper.observeInputMethods(ctx) val filter = IntentFilter() filter.addAction(Intent.ACTION_INPUT_METHOD_CHANGED) @@ -177,79 +105,13 @@ class AndroidInputMethodAdapter @Inject constructor( (Build.VERSION_CODES.O_MR1..Build.VERSION_CODES.P).contains(Build.VERSION.SDK_INT) -> { val command = "am broadcast -a com.android.server.InputMethodManagerService.SHOW_INPUT_METHOD_PICKER" - return suAdapter.execute(command) + return runBlocking { suAdapter.execute(command) } } else -> return KMError.CantShowImePickerInBackground } } - override suspend fun enableIme(imeId: String): KMResult<*> = - enableImeWithoutUserInput(imeId).otherwise { - try { - val intent = Intent(Settings.ACTION_INPUT_METHOD_SETTINGS) - intent.flags = Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_NEW_TASK - - ctx.startActivity(intent) - Success(Unit) - } catch (e: Exception) { - KMError.CantFindImeSettings - } - } - - private suspend fun enableImeWithoutUserInput(imeId: String): KMResult<*> { - return getInfoByPackageName(buildConfigProvider.packageName).then { keyMapperImeInfo -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && imeId == keyMapperImeInfo.id) { - serviceAdapter.send(AccessibilityServiceEvent.EnableInputMethod(keyMapperImeInfo.id)) - } else { - suAdapter.execute("ime enable $imeId") - } - } - } - - override suspend fun chooseImeWithoutUserInput(imeId: String): KMResult { - getInfoById(imeId).onSuccess { - if (!it.isEnabled) { - return SystemError.ImeDisabled(it) - } - }.onFailure { - return it - } - - var failed = true - - if (failed && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && serviceAdapter.state.value == AccessibilityServiceState.ENABLED) { - serviceAdapter.send(AccessibilityServiceEvent.ChangeIme(imeId)).onSuccess { - failed = false - } - } - - if (failed && permissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS)) { - SettingsUtils.putSecureSetting( - ctx, - Settings.Secure.DEFAULT_INPUT_METHOD, - imeId, - ) - - failed = false - } - - if (failed) { - return KMError.FailedToChangeIme - } - - // wait for the ime to change and then return the info of the ime - val didImeChange = withTimeoutOrNull(2000) { - chosenIme.first { it?.id == imeId } - } - - if (didImeChange != null) { - return Success(didImeChange) - } else { - return KMError.FailedToChangeIme - } - } - override fun getInfoById(imeId: String): KMResult { val info = getInputMethods().find { it.id == imeId } ?: return KMError.InputMethodNotFound(imeId) @@ -299,7 +161,7 @@ class AndroidInputMethodAdapter @Inject constructor( SETTINGS_SECURE_SUBTYPE_HISTORY_KEY, ) - private fun getChosenIme(): ImeInfo? { + override fun getChosenIme(): ImeInfo? { val chosenImeId = getChosenImeId() return getInfoById(chosenImeId).valueOrNull() diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputKeyModel.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputKeyModel.kt deleted file mode 100644 index 1142fef197..0000000000 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputKeyModel.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.github.sds100.keymapper.system.inputmethod - -import android.view.InputDevice -import io.github.sds100.keymapper.common.utils.InputEventType - -data class InputKeyModel( - val keyCode: Int, - val inputType: InputEventType = InputEventType.DOWN_UP, - val metaState: Int = 0, - val deviceId: Int = 0, - val scanCode: Int = 0, - val repeat: Int = 0, - val source: Int = InputDevice.SOURCE_UNKNOWN, -) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt index 07ef2b30a2..840038c1ea 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt @@ -1,16 +1,10 @@ package io.github.sds100.keymapper.system.inputmethod import io.github.sds100.keymapper.common.utils.KMResult -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow interface InputMethodAdapter { - val isUserInputRequiredToChangeIme: Flow - fun showImePicker(fromForeground: Boolean): KMResult<*> - suspend fun enableIme(imeId: String): KMResult<*> - - suspend fun chooseImeWithoutUserInput(imeId: String): KMResult fun getInfoById(imeId: String): KMResult fun getInfoByPackageName(packageName: String): KMResult @@ -21,4 +15,6 @@ interface InputMethodAdapter { val inputMethodHistory: StateFlow> val inputMethods: StateFlow> val chosenIme: StateFlow + + fun getChosenIme(): ImeInfo? } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/KeyEventRelayServiceWrapper.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/KeyEventRelayServiceWrapper.kt index 24dccae1b1..a6d1e94c93 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/KeyEventRelayServiceWrapper.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/KeyEventRelayServiceWrapper.kt @@ -9,20 +9,17 @@ import android.os.IBinder import android.os.RemoteException import android.view.KeyEvent import android.view.MotionEvent +import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.api.IKeyEventRelayService import io.github.sds100.keymapper.api.IKeyEventRelayServiceCallback -import io.github.sds100.keymapper.system.inputmethod.KeyEventRelayServiceWrapper - -/** - * This handles connecting to the relay service and exposes an interface - * so other parts of the app can get a reference to the service even when it isn't - * bound yet. This class is copied to the Key Mapper GUI Keyboard app as well. - */ -class KeyEventRelayServiceWrapperImpl( - private val ctx: Context, - private val id: String, - private val servicePackageName: String, - private val callback: IKeyEventRelayServiceCallback, +import io.github.sds100.keymapper.common.BuildConfigProvider +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class KeyEventRelayServiceWrapperImpl @Inject constructor( + @ApplicationContext private val ctx: Context, + private val buildConfigProvider: BuildConfigProvider ) : KeyEventRelayServiceWrapper { private val keyEventRelayServiceLock: Any = Any() @@ -36,7 +33,6 @@ class KeyEventRelayServiceWrapperImpl( ) { synchronized(keyEventRelayServiceLock) { keyEventRelayService = IKeyEventRelayService.Stub.asInterface(service) - keyEventRelayService?.registerCallback(callback, id) } } @@ -51,14 +47,6 @@ class KeyEventRelayServiceWrapperImpl( } } - fun onCreate() { - bind() - } - - fun onDestroy() { - unbind() - } - override fun sendKeyEvent( event: KeyEvent, targetPackageName: String, @@ -93,11 +81,22 @@ class KeyEventRelayServiceWrapperImpl( } } - private fun bind() { + override fun registerClient(id: String, callback: IKeyEventRelayServiceCallback) { + keyEventRelayService?.registerCallback(callback, id) + } + + override fun unregisterClient(id: String) { + keyEventRelayService?.unregisterCallback(id) + } + + fun bind() { try { val relayServiceIntent = Intent() val component = - ComponentName(servicePackageName, "io.github.sds100.keymapper.api.KeyEventRelayService") + ComponentName( + buildConfigProvider.packageName, + "io.github.sds100.keymapper.api.KeyEventRelayService" + ) relayServiceIntent.setComponent(component) val isSuccess = ctx.bindService(relayServiceIntent, serviceConnection, Context.BIND_AUTO_CREATE) @@ -111,13 +110,8 @@ class KeyEventRelayServiceWrapperImpl( } } - private fun unbind() { - // Unregister the callback if this input method is unbinding - // from the relay service. This should not happen in onServiceDisconnected - // because the connection is already broken at that point and it - // will fail. + fun unbind() { try { - keyEventRelayService?.unregisterCallback(id) ctx.unbindService(serviceConnection) } catch (e: RemoteException) { // do nothing @@ -129,6 +123,8 @@ class KeyEventRelayServiceWrapperImpl( } interface KeyEventRelayServiceWrapper { + fun registerClient(id: String, callback: IKeyEventRelayServiceCallback) + fun unregisterClient(id: String) fun sendKeyEvent(event: KeyEvent, targetPackageName: String, callbackId: String): Boolean fun sendMotionEvent(event: MotionEvent, targetPackageName: String, callbackId: String): Boolean } \ No newline at end of file diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/KeyMapperImeService.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/KeyMapperImeService.kt index 470d1707c5..94ee5d311c 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/KeyMapperImeService.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/KeyMapperImeService.kt @@ -9,7 +9,6 @@ import android.content.IntentFilter import android.inputmethodservice.InputMethodService import android.os.Build import android.os.UserManager -import android.util.Log import android.view.KeyEvent import android.view.MotionEvent import android.view.inputmethod.EditorInfo @@ -19,6 +18,9 @@ import androidx.core.content.getSystemService import dagger.hilt.android.AndroidEntryPoint import io.github.sds100.keymapper.api.IKeyEventRelayServiceCallback import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceEvent +import timber.log.Timber import javax.inject.Inject /** @@ -59,6 +61,9 @@ class KeyMapperImeService : InputMethodService() { getSystemService() } + @Inject + lateinit var serviceAdapter: AccessibilityServiceAdapter + private val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { val action = intent?.action ?: return @@ -116,14 +121,8 @@ class KeyMapperImeService : InputMethodService() { } } - private val keyEventRelayServiceWrapper: KeyEventRelayServiceWrapperImpl by lazy { - KeyEventRelayServiceWrapperImpl( - ctx = this, - id = CALLBACK_ID_INPUT_METHOD, - servicePackageName = buildConfigProvider.packageName, - callback = keyEventReceiverCallback, - ) - } + @Inject + lateinit var keyEventRelayServiceWrapper: KeyEventRelayServiceWrapper override fun onCreate() { super.onCreate() @@ -141,7 +140,10 @@ class KeyMapperImeService : InputMethodService() { ContextCompat.RECEIVER_NOT_EXPORTED, ) - keyEventRelayServiceWrapper.onCreate() + keyEventRelayServiceWrapper.registerClient( + CALLBACK_ID_INPUT_METHOD, + keyEventReceiverCallback + ) } override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) { @@ -150,14 +152,21 @@ class KeyMapperImeService : InputMethodService() { // IMPORTANT! Select a keyboard with an actual GUI if the user needs // to unlock their device. This must not be in onCreate because // the switchInputMethod does not work there. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && userManager?.isUserUnlocked == false) { - selectNonBasicKeyboard() - } else if ( - !restarting && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 && - keyguardManager?.isDeviceLocked == true - ) { - selectNonBasicKeyboard() + if (userManager?.isUserUnlocked == false) { + chooseIncompatibleIme() + } else if (!restarting && keyguardManager?.isDeviceLocked == true) { + chooseIncompatibleIme() + } + + // Only send the start input event on versions before the accessibility + // input method API was introduced + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && attribute != null) { + serviceAdapter.sendAsync( + AccessibilityServiceEvent.OnKeyMapperImeStartInput( + attribute = attribute, + restarting = restarting + ) + ) } } @@ -211,28 +220,27 @@ class KeyMapperImeService : InputMethodService() { override fun onDestroy() { unregisterReceiver(broadcastReceiver) - keyEventRelayServiceWrapper.onDestroy() + keyEventRelayServiceWrapper.unregisterClient(CALLBACK_ID_INPUT_METHOD) super.onDestroy() } @SuppressLint("LogNotTimber") - private fun selectNonBasicKeyboard() { + private fun chooseIncompatibleIme() { inputMethodManager ?: return + Timber.d("KeyMapperImeService: Choosing incompatible IME because device is locked") + inputMethodManager!!.enabledInputMethodList .filter { - it.packageName != "io.github.sds100.keymapper" && - it.packageName != "io.github.sds100.keymapper.debug" && - it.packageName != "io.github.sds100.keymapper.ci" + !it.packageName.contains("io.github.sds100.keymapper") } // Select a random one in case one of them can't be used on the lock screen such as - // the Google Voice Typing keyboard. This is critical because i - // f an input method can't be used + // the Google Voice Typing keyboard. This is critical because if an input method can't be used // then it will select the Key Mapper Basic Input method again and loop forever. .randomOrNull() ?.also { - Log.e( + Timber.e( KeyMapperImeService::class.simpleName, "Device is locked! Select ${it.id} input method", ) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/lock/AndroidLockScreenAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/lock/AndroidLockScreenAdapter.kt index 2674c3081f..07b30cb2e5 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/lock/AndroidLockScreenAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/lock/AndroidLockScreenAdapter.kt @@ -6,8 +6,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.os.Build -import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import dagger.hilt.android.qualifiers.ApplicationContext @@ -18,7 +16,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import javax.inject.Inject -class AndroidLockScreenAdapter @Inject constructor(@ApplicationContext private val ctx: Context) : LockScreenAdapter { +class AndroidLockScreenAdapter @Inject constructor(@ApplicationContext private val ctx: Context) : + LockScreenAdapter { private val devicePolicyManager: DevicePolicyManager by lazy { ctx.getSystemService()!! } private val keyguardManager: KeyguardManager by lazy { ctx.getSystemService()!! } @@ -30,10 +29,8 @@ class AndroidLockScreenAdapter @Inject constructor(@ApplicationContext private v when (intent.action) { Intent.ACTION_SCREEN_ON, Intent.ACTION_SCREEN_OFF, Intent.ACTION_USER_PRESENT, Intent.ACTION_USER_UNLOCKED -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - isLockedFlow.update { - isLocked() - } + isLockedFlow.update { + isLocked() } isLockscreenShowingFlow.update { isLockScreenShowing() } @@ -42,13 +39,7 @@ class AndroidLockScreenAdapter @Inject constructor(@ApplicationContext private v } } - private val isLockedFlow by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - MutableStateFlow(isLocked()) - } else { - MutableStateFlow(false) - } - } + private val isLockedFlow by lazy { MutableStateFlow(isLocked()) } private val isLockscreenShowingFlow = MutableStateFlow(isLockScreenShowing()) @@ -60,10 +51,7 @@ class AndroidLockScreenAdapter @Inject constructor(@ApplicationContext private v filter.addAction(Intent.ACTION_SCREEN_ON) filter.addAction(Intent.ACTION_SCREEN_OFF) filter.addAction(Intent.ACTION_USER_PRESENT) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - filter.addAction(Intent.ACTION_USER_UNLOCKED) - } + filter.addAction(Intent.ACTION_USER_UNLOCKED) ContextCompat.registerReceiver( ctx, @@ -80,7 +68,6 @@ class AndroidLockScreenAdapter @Inject constructor(@ApplicationContext private v return Success(Unit) } - @RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) override fun isLocked(): Boolean = keyguardManager.isDeviceLocked override fun isLockScreenShowing(): Boolean = keyguardManager.isKeyguardLocked diff --git a/system/src/main/java/io/github/sds100/keymapper/system/lock/LockScreenAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/lock/LockScreenAdapter.kt index 2823fbe325..e5d5c44095 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/lock/LockScreenAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/lock/LockScreenAdapter.kt @@ -1,7 +1,5 @@ package io.github.sds100.keymapper.system.lock -import android.os.Build -import androidx.annotation.RequiresApi import io.github.sds100.keymapper.common.utils.KMResult import kotlinx.coroutines.flow.Flow @@ -9,12 +7,8 @@ import kotlinx.coroutines.flow.Flow interface LockScreenAdapter { fun secureLockDevice(): KMResult<*> - @RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) fun isLocked(): Boolean - - @RequiresApi(Build.VERSION_CODES.LOLLIPOP_MR1) fun isLockedFlow(): Flow - fun isLockScreenShowing(): Boolean fun isLockScreenShowingFlow(): Flow } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt index 8c05233836..27dc8de180 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt @@ -1,22 +1,36 @@ package io.github.sds100.keymapper.system.network +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.WifiInfo import android.net.wifi.WifiManager import android.os.Build +import android.provider.Settings +import android.telephony.SubscriptionManager import android.telephony.TelephonyManager +import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.common.utils.Constants import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.system.root.SuAdapter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.Headers import okhttp3.OkHttpClient @@ -25,7 +39,6 @@ import okhttp3.RequestBody.Companion.toRequestBody import okio.IOException import okio.use import timber.log.Timber -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @@ -33,10 +46,13 @@ import javax.inject.Singleton class AndroidNetworkAdapter @Inject constructor( @ApplicationContext private val context: Context, private val suAdapter: SuAdapter, + private val systemBridgeConnManager: SystemBridgeConnectionManager ) : NetworkAdapter { private val ctx = context.applicationContext private val wifiManager: WifiManager by lazy { ctx.getSystemService()!! } private val telephonyManager: TelephonyManager by lazy { ctx.getSystemService()!! } + private val connectivityManager: ConnectivityManager by lazy { ctx.getSystemService()!! } + private val httpClient: OkHttpClient by lazy { OkHttpClient() } private val broadcastReceiver = object : BroadcastReceiver() { @@ -51,24 +67,61 @@ class AndroidNetworkAdapter @Inject constructor( } WifiManager.NETWORK_STATE_CHANGED_ACTION -> { - connectedWifiSSIDFlow.update { connectedWifiSSID } + connectedWifiSSIDFlow.update { getWifiSSID() } } } } } - override val connectedWifiSSID: String? - get() = wifiManager.connectionInfo?.ssid?.let { ssid -> - if (ssid == WifiManager.UNKNOWN_SSID) { - null - } else { - ssid.removeSurrounding("\"") - } - } + override val connectedWifiSSIDFlow = MutableStateFlow(getWifiSSID()) + override val isWifiConnected: MutableStateFlow = MutableStateFlow(getIsWifiConnected()) - override val connectedWifiSSIDFlow = MutableStateFlow(connectedWifiSSID) private val isWifiEnabled = MutableStateFlow(isWifiEnabled()) + // This requires Android S+ because of the FLAG_INCLUDE_LOCATION_INFO, which is needed + // to get the SSID. + private val networkCallback: ConnectivityManager.NetworkCallback by lazy { + @RequiresApi(Build.VERSION_CODES.S) + object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) { + override fun onAvailable(network: Network) { + super.onAvailable(network) + + isWifiConnected.update { getIsWifiConnected() } + } + + override fun onLost(network: Network) { + super.onLost(network) + // A network was lost. Check if we are still connected to *any* Wi-Fi. + // This is important because onLost is called for a specific network. + // If multiple Wi-Fi networks were available and one is lost, + // another might still be active. + isWifiConnected.update { getIsWifiConnected() } + connectedWifiSSIDFlow.update { getWifiSSID() } + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + super.onCapabilitiesChanged(network, networkCapabilities) + + isWifiConnected.update { getIsWifiConnected() } + + val wifiInfo = networkCapabilities.transportInfo as? WifiInfo + + // Do nothing if this network is not a wifi network + if (wifiInfo == null) { + return + } + + val ssid = wifiInfo.ssid?.takeIf { it != WifiManager.UNKNOWN_SSID } + ?.removeSurrounding("\"") + + connectedWifiSSIDFlow.update { ssid } + } + } + } + init { IntentFilter().apply { addAction(WifiManager.WIFI_STATE_CHANGED_ACTION) @@ -81,6 +134,14 @@ class AndroidNetworkAdapter @Inject constructor( ContextCompat.RECEIVER_EXPORTED, ) } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val networkRequest = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .build() + + connectivityManager.registerNetworkCallback(networkRequest, networkCallback) + } } override fun isWifiEnabled(): Boolean = wifiManager.isWifiEnabled @@ -88,8 +149,8 @@ class AndroidNetworkAdapter @Inject constructor( override fun isWifiEnabledFlow(): Flow = isWifiEnabled override fun enableWifi(): KMResult<*> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return suAdapter.execute("svc wifi enable") + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + return systemBridgeConnManager.run { bridge -> bridge.setWifiEnabled(true) } } else { wifiManager.isWifiEnabled = true return Success(Unit) @@ -97,33 +158,64 @@ class AndroidNetworkAdapter @Inject constructor( } override fun disableWifi(): KMResult<*> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return suAdapter.execute("svc wifi disable") + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + return systemBridgeConnManager.run { bridge -> bridge.setWifiEnabled(false) } } else { wifiManager.isWifiEnabled = false return Success(Unit) } } + @SuppressLint("MissingPermission") override fun isMobileDataEnabled(): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return telephonyManager.isDataEnabled + return telephonyManager.isDataEnabled + } + + override fun enableMobileData(): KMResult<*> { + val subId = SubscriptionManager.getDefaultSubscriptionId() + + if (subId == SubscriptionManager.INVALID_SUBSCRIPTION_ID) { + throw IllegalStateException("No valid subscription ID") + } + + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + return systemBridgeConnManager.run { bridge -> bridge.setDataEnabled(subId, true) } } else { - return telephonyManager.dataState == TelephonyManager.DATA_CONNECTED + return runBlocking { suAdapter.execute("svc data enable") } } } - override fun enableMobileData(): KMResult<*> = suAdapter.execute("svc data enable") + override fun disableMobileData(): KMResult<*> { + val subId = SubscriptionManager.getDefaultSubscriptionId() + + if (subId == SubscriptionManager.INVALID_SUBSCRIPTION_ID) { + throw IllegalStateException("No valid subscription ID") + } - override fun disableMobileData(): KMResult<*> = suAdapter.execute("svc data disable") + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + return systemBridgeConnManager.run { bridge -> bridge.setDataEnabled(subId, false) } + } else { + return runBlocking { suAdapter.execute("svc data disable") } + } + } /** * @return Null on Android 10+ because there is no API to do this anymore. */ - override fun getKnownWifiSSIDs(): List? { + @SuppressLint("MissingPermission") + override fun getKnownWifiSSIDs(): List { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return null + // On Android Q and above, apps can't access the list of configured Wi-Fi networks. + // As a fallback, we return the currently connected network's SSID. + val ssid = getWifiSSID() + + return if (ssid != null) { + listOf(ssid) + } else { + emptyList() + } } else { + @Suppress("DEPRECATION") return wifiManager.configuredNetworks?.map { it.SSID.removeSurrounding("\"") } ?: emptyList() @@ -175,4 +267,45 @@ class AndroidNetworkAdapter @Inject constructor( return KMError.MalformedUrl } } + + override fun connectWifiNetwork() { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Intent(Settings.Panel.ACTION_WIFI) + } else { + Intent(Settings.ACTION_WIFI_SETTINGS) + } + + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + try { + ctx.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Timber.e(e, "Failed to start Wi-Fi settings activity") + } + } + + private fun getIsWifiConnected(): Boolean { + @Suppress("DEPRECATION") + // The deprecation notice is advice to use the callback instead. getAllNetworks() still + // functions + return connectivityManager.allNetworks.any { network -> + connectivityManager.getNetworkCapabilities(network) + ?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ?: false + } + } + + private fun getWifiSSID(): String? { + return wifiManager.connectionInfo?.ssid?.let { ssid -> + if (ssid == WifiManager.UNKNOWN_SSID) { + null + } else { + ssid.removeSurrounding("\"") + } + } + } + + fun invalidateState() { + connectedWifiSSIDFlow.update { getWifiSSID() } + isWifiConnected.update { getIsWifiConnected() } + } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt index d7a1f04a7c..27862c975e 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt @@ -5,21 +5,22 @@ import kotlinx.coroutines.flow.Flow interface NetworkAdapter { - val connectedWifiSSID: String? val connectedWifiSSIDFlow: Flow + val isWifiConnected: Flow fun isWifiEnabled(): Boolean fun isWifiEnabledFlow(): Flow fun enableWifi(): KMResult<*> fun disableWifi(): KMResult<*> + fun connectWifiNetwork() fun isMobileDataEnabled(): Boolean fun enableMobileData(): KMResult<*> fun disableMobileData(): KMResult<*> - fun getKnownWifiSSIDs(): List? + fun getKnownWifiSSIDs(): List suspend fun sendHttpRequest( method: HttpMethod, diff --git a/system/src/main/java/io/github/sds100/keymapper/system/nfc/AndroidNfcAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/nfc/AndroidNfcAdapter.kt index 0f11120f17..d96b12a4e6 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/nfc/AndroidNfcAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/nfc/AndroidNfcAdapter.kt @@ -2,10 +2,14 @@ package io.github.sds100.keymapper.system.nfc import android.content.Context import android.nfc.NfcManager +import android.os.Build import androidx.core.content.getSystemService +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.common.utils.Constants import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.system.root.SuAdapter -import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.runBlocking import javax.inject.Inject import javax.inject.Singleton @@ -13,14 +17,27 @@ import javax.inject.Singleton class AndroidNfcAdapter @Inject constructor( @ApplicationContext private val context: Context, private val suAdapter: SuAdapter, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager ) : NfcAdapter { private val ctx = context.applicationContext - private val nfcManager: NfcManager by lazy { ctx.getSystemService()!! } + private val nfcManager: NfcManager? by lazy { ctx.getSystemService() } - override fun isEnabled(): Boolean = nfcManager.defaultAdapter.isEnabled + override fun isEnabled(): Boolean = nfcManager?.defaultAdapter?.isEnabled ?: false - override fun enable(): KMResult<*> = suAdapter.execute("svc nfc enable") + override fun enable(): KMResult<*> { + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + return systemBridgeConnectionManager.run { bridge -> bridge.setNfcEnabled(true) } + } else { + return runBlocking { suAdapter.execute("svc nfc enable") } + } + } - override fun disable(): KMResult<*> = suAdapter.execute("svc nfc disable") + override fun disable(): KMResult<*> { + if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + return systemBridgeConnectionManager.run { bridge -> bridge.setNfcEnabled(false) } + } else { + return runBlocking { suAdapter.execute("svc nfc disable") } + } + } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationAdapter.kt index 0b61b3a015..f62834cdac 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationAdapter.kt @@ -1,16 +1,22 @@ package io.github.sds100.keymapper.system.notifications +import io.github.sds100.keymapper.common.notifications.KMNotificationAction import kotlinx.coroutines.flow.Flow - interface NotificationAdapter { /** * The string is the ID of the action. */ - val onNotificationActionClick: Flow + val onNotificationActionClick: Flow + + /** + * Emits text input from notification actions that support RemoteInput. + */ + val onNotificationRemoteInput: Flow fun showNotification(notification: NotificationModel) fun dismissNotification(notificationId: Int) fun createChannel(channel: NotificationChannelModel) fun deleteChannel(channelId: String) + fun openChannelSettings(channelId: String) } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationModel.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationModel.kt index 30bd5c1cb3..2b0e1c1f9a 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationModel.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationModel.kt @@ -1,6 +1,8 @@ package io.github.sds100.keymapper.system.notifications import androidx.annotation.DrawableRes +import androidx.core.app.NotificationCompat +import io.github.sds100.keymapper.common.notifications.KMNotificationAction data class NotificationModel( @@ -12,32 +14,26 @@ data class NotificationModel( /** * Null if nothing should happen when the notification is tapped. */ - val onClickAction: NotificationIntentType? = null, + val onClickAction: KMNotificationAction? = null, val showOnLockscreen: Boolean, val onGoing: Boolean, - val priority: Int, - val actions: List = emptyList(), - val autoCancel: Boolean = false, - val bigTextStyle: Boolean = false, -) { - data class Action(val text: String, val intentType: NotificationIntentType) -} + /** + * On Android Oreo and newer this does nothing because the channel priority is used. + */ + val priority: Int = NotificationCompat.PRIORITY_DEFAULT, -/** - * Due to restrictions on notification trampolines in Android 12+ you can't launch - * activities from a broadcast receiver in response to a notification action. - */ -sealed class NotificationIntentType { /** - * Broadcast an intent to the NotificationReceiver. + * Maps the action intent to the label string. */ - data class Broadcast(val action: String) : NotificationIntentType() + val actions: List> = emptyList(), /** - * Launch the main activity with the specified action in the intent. If it is null - * then it will just launch the activity without a custom action. + * Clicking on the notification will automatically dismiss it. */ - data class MainActivity(val customIntentAction: String? = null) : NotificationIntentType() + val autoCancel: Boolean = false, + val bigTextStyle: Boolean = false, + val silent: Boolean = false, + val showIndeterminateProgress: Boolean = false, + val timeout: Long? = null +) - data class Activity(val action: String) : NotificationIntentType() -} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationRemoteInput.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationRemoteInput.kt new file mode 100644 index 0000000000..9e536e8de4 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationRemoteInput.kt @@ -0,0 +1,13 @@ +package io.github.sds100.keymapper.system.notifications + +import io.github.sds100.keymapper.common.notifications.KMNotificationAction + +/** + * Represents text input from a notification action with RemoteInput. + * @param intentAction The intent action that triggered the text input + * @param text The text that was inputted by the user + */ +data class NotificationRemoteInput( + val intentAction: KMNotificationAction.IntentAction, + val text: String +) \ No newline at end of file diff --git a/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt index b81b9f8d9a..f280d50a2f 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt @@ -5,32 +5,36 @@ import android.app.NotificationManager import android.app.admin.DevicePolicyManager import android.content.ComponentName import android.content.Context -import android.content.pm.IPackageManager import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.Build import android.os.PowerManager import android.os.Process import android.permission.IPermissionManager +import android.permission.PermissionManagerApis import android.provider.Settings import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.utils.Constants import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.common.utils.getIdentifier import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.onSuccess import io.github.sds100.keymapper.common.utils.success +import io.github.sds100.keymapper.common.utils.then import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository +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.DeviceAdmin -import io.github.sds100.keymapper.system.SystemError -import io.github.sds100.keymapper.system.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapter import io.github.sds100.keymapper.system.root.SuAdapter -import io.github.sds100.keymapper.system.shizuku.ShizukuUtils +import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -44,6 +48,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.lsposed.hiddenapibypass.HiddenApiBypass import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuBinderWrapper @@ -59,18 +64,14 @@ class AndroidPermissionAdapter @Inject constructor( private val suAdapter: SuAdapter, private val notificationReceiverAdapter: NotificationReceiverAdapter, private val preferenceRepository: PreferenceRepository, - private val packageManagerAdapter: PackageManagerAdapter, private val buildConfigProvider: BuildConfigProvider, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val shizukuAdapter: ShizukuAdapter ) : PermissionAdapter { companion object { const val REQUEST_CODE_SHIZUKU_PERMISSION = 1 } - private val shizukuPackageManager: IPackageManager by lazy { - val binder = ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package")) - IPackageManager.Stub.asInterface(binder) - } - private val shizukuPermissionManager: IPermissionManager by lazy { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { HiddenApiBypass.addHiddenApiExemptions( @@ -103,7 +104,7 @@ class AndroidPermissionAdapter @Inject constructor( .stateIn(coroutineScope, SharingStarted.Eagerly, false) init { - suAdapter.isGranted + suAdapter.isRootGranted .drop(1) .onEach { onPermissionsChanged() } .launchIn(coroutineScope) @@ -144,32 +145,56 @@ class AndroidPermissionAdapter @Inject constructor( return success() } - if (isGranted(Permission.SHIZUKU)) { - result = try { - grantPermissionWithShizuku(permissionName) + val deviceId: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ctx.deviceId + } else { + -1 + } + + val isSystemBridgeConnected = + Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API && + systemBridgeConnectionManager.connectionState.firstBlocking() is SystemBridgeConnectionState.Connected - // if successfully granted + if (isSystemBridgeConnected) { + result = systemBridgeConnectionManager.run { bridge -> + bridge.grantPermission(permissionName, deviceId) + }.then { if (ContextCompat.checkSelfPermission(ctx, permissionName) == PERMISSION_GRANTED) { success() } else { - KMError.Exception(Exception("Failed to grant permission with Shizuku.")) + KMError.Exception(Exception("Failed to grant permission with system bridge")) } - } catch (e: Exception) { - KMError.Exception(e) } - } else if (isGranted(Permission.ROOT)) { - suAdapter.execute( - "pm grant ${buildConfigProvider.packageName} $permissionName", - block = true, + } else if (shizukuAdapter.isStarted.value && isGranted(Permission.SHIZUKU)) { + val userId = Process.myUserHandle()!!.getIdentifier() + + PermissionManagerApis.grantPermission( + shizukuPermissionManager, buildConfigProvider.packageName, + permissionName, deviceId, userId ) + if (ContextCompat.checkSelfPermission(ctx, permissionName) == PERMISSION_GRANTED) { result = success() } else { result = - KMError.Exception(Exception("Failed to grant permission with root. Key Mapper may not actually have root permission.")) + KMError.Exception(Exception("Failed to grant permission with Shizuku.")) + } + } else if (isGranted(Permission.ROOT)) { + runBlocking { + suAdapter.execute( + "pm grant ${buildConfigProvider.packageName} $permissionName", + ) + } + + if (ContextCompat.checkSelfPermission(ctx, permissionName) == PERMISSION_GRANTED) { + result = success() + } else { + result = + KMError.Exception(Exception("Failed to grant permission with root.")) } } else { - result = SystemError.PermissionDenied(Permission.SHIZUKU) + // The system bridge should be the default way to grant permissions. + result = SystemBridgeError.Disconnected } result.onSuccess { @@ -181,63 +206,8 @@ class AndroidPermissionAdapter @Inject constructor( return result } - private fun grantPermissionWithShizuku(permissionName: String) { - val userId = Process.myUserHandle()!!.getIdentifier() - - try { - // In revisions of Android 14 the method to grant permissions changed - // so try them all. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - try { - shizukuPermissionManager.grantRuntimePermission( - buildConfigProvider.packageName, - permissionName, - ctx.deviceId, - userId, - ) - } catch (_: NoSuchMethodError) { - try { - shizukuPermissionManager.grantRuntimePermission( - buildConfigProvider.packageName, - permissionName, - "0", - userId, - ) - } catch (_: NoSuchMethodError) { - shizukuPermissionManager.grantRuntimePermission( - buildConfigProvider.packageName, - permissionName, - userId, - ) - } - } - // In Android 11 this method was moved from IPackageManager to IPermissionManager. - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - shizukuPermissionManager.grantRuntimePermission( - buildConfigProvider.packageName, - permissionName, - userId, - ) - } else { - shizukuPackageManager.grantRuntimePermission( - buildConfigProvider.packageName, - permissionName, - userId, - ) - } - // The API may change in future Android versions so don't crash the whole app - // just for this shizuku permission feature. - } catch (_: NoSuchMethodError) { - } - } - override fun isGranted(permission: Permission): Boolean = when (permission) { - Permission.WRITE_SETTINGS -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Settings.System.canWrite(ctx) - } else { - true - } + Permission.WRITE_SETTINGS -> Settings.System.canWrite(ctx) Permission.CAMERA -> ContextCompat.checkSelfPermission( @@ -257,11 +227,11 @@ class AndroidPermissionAdapter @Inject constructor( ) == PERMISSION_GRANTED Permission.ACCESS_NOTIFICATION_POLICY -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !neverRequestDndPermission.value) { + if (neverRequestDndPermission.value) { + true + } else { val notificationManager: NotificationManager = ctx.getSystemService()!! notificationManager.isNotificationPolicyAccessGranted - } else { - true } Permission.WRITE_SECURE_SETTINGS -> { @@ -281,21 +251,20 @@ class AndroidPermissionAdapter @Inject constructor( Manifest.permission.CALL_PHONE, ) == PERMISSION_GRANTED - Permission.ROOT -> suAdapter.isGranted.value + Permission.SEND_SMS -> + ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.SEND_SMS, + ) == PERMISSION_GRANTED + + Permission.ROOT -> suAdapter.isRootGranted.value Permission.IGNORE_BATTERY_OPTIMISATION -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val ignoringOptimisations = - powerManager?.isIgnoringBatteryOptimizations(buildConfigProvider.packageName) - - ignoringOptimisations ?: false - } else { - true - } + powerManager?.isIgnoringBatteryOptimizations(buildConfigProvider.packageName) ?: false // this check is super quick (~0ms) so this doesn't need to be cached. Permission.SHIZUKU -> { - if (ShizukuUtils.isSupportedForSdkVersion() && Shizuku.getBinder() != null) { + if (Shizuku.getBinder() != null) { Shizuku.checkSelfPermission() == PERMISSION_GRANTED } else { false diff --git a/system/src/main/java/io/github/sds100/keymapper/system/permissions/Permission.kt b/system/src/main/java/io/github/sds100/keymapper/system/permissions/Permission.kt index f552ff3c8d..468b91aba3 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/permissions/Permission.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/permissions/Permission.kt @@ -1,6 +1,5 @@ package io.github.sds100.keymapper.system.permissions - enum class Permission { WRITE_SETTINGS, CAMERA, @@ -10,6 +9,7 @@ enum class Permission { WRITE_SECURE_SETTINGS, NOTIFICATION_LISTENER, CALL_PHONE, + SEND_SMS, ROOT, IGNORE_BATTERY_OPTIMISATION, SHIZUKU, diff --git a/system/src/main/java/io/github/sds100/keymapper/system/phone/AndroidPhoneAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/phone/AndroidPhoneAdapter.kt index fd23ff5917..934c8635ff 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/phone/AndroidPhoneAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/phone/AndroidPhoneAdapter.kt @@ -1,21 +1,36 @@ package io.github.sds100.keymapper.system.phone +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.app.PendingIntent import android.content.ActivityNotFoundException +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.net.Uri +import android.content.IntentFilter +import android.content.pm.PackageManager import android.os.Build +import android.os.SystemClock import android.telecom.TelecomManager import android.telephony.PhoneStateListener +import android.telephony.SmsManager import android.telephony.TelephonyManager +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat import androidx.core.content.getSystemService +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch -import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.withTimeout +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -24,9 +39,18 @@ class AndroidPhoneAdapter @Inject constructor( @ApplicationContext private val context: Context, private val coroutineScope: CoroutineScope, ) : PhoneAdapter { + companion object { + private const val ACTION_SMS_SENT_RESULT = "io.github.sds100.keymapper.SMS_SENT_RESULT" + + /** + * The minimum frequency that SMS messages can be sent. + */ + private const val SMS_MIN_RATE_MILLIS = 1000L + } + private val ctx: Context = context.applicationContext - private val telecomManager: TelecomManager = ctx.getSystemService()!! - private val telephonyManager: TelephonyManager = ctx.getSystemService()!! + private val telecomManager: TelecomManager? = ctx.getSystemService() + private val telephonyManager: TelephonyManager? = ctx.getSystemService() private val phoneStateListener: PhoneStateListener = object : PhoneStateListener() { override fun onCallStateChanged(state: Int, phoneNumber: String?) { @@ -40,27 +64,70 @@ class AndroidPhoneAdapter @Inject constructor( override val callStateFlow: MutableSharedFlow = MutableSharedFlow() + /** + * Emits the result code in SmsManager + */ + private val smsSentResultFlow = Channel() + + /** + * The time the last SMS was sent. This is used to prevent someone accidentally incurring + * significant charges. + */ + private var lastSmsTime: Long = -1 + + private val broadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + context ?: return + intent ?: return + + when (intent.action) { + ACTION_SMS_SENT_RESULT -> { + smsSentResultFlow.trySend(resultCode) + } + } + } + } + init { - coroutineScope.launch { - callStateFlow.subscriptionCount.collect { subscriptionCount -> - if (subscriptionCount == 0) { - telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE) - } else { - telephonyManager.listen( - phoneStateListener, - PhoneStateListener.LISTEN_CALL_STATE, - ) + if (telephonyManager != null) { + coroutineScope.launch { + callStateFlow.subscriptionCount.collect { subscriptionCount -> + if (subscriptionCount == 0) { + telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE) + } else { + telephonyManager.listen( + phoneStateListener, + PhoneStateListener.LISTEN_CALL_STATE, + ) + } } } } + + IntentFilter().apply { + addAction(ACTION_SMS_SENT_RESULT) + + ContextCompat.registerReceiver( + ctx, + broadcastReceiver, + this, + ContextCompat.RECEIVER_EXPORTED + ) + } } - override fun getCallState(): CallState = callStateConverter(telephonyManager.callState) + override fun getCallState(): CallState { + if (telephonyManager == null) { + throw Exception("TelephonyManager is null. Does this device support telephony?") + } + + return callStateConverter(telephonyManager.callState) + } override fun startCall(number: String): KMResult<*> { try { Intent(Intent.ACTION_CALL).apply { - data = Uri.parse("tel:$number") + data = "tel:$number".toUri() flags = Intent.FLAG_ACTIVITY_NEW_TASK ctx.startActivity(this) } @@ -71,15 +138,90 @@ class AndroidPhoneAdapter @Inject constructor( } } + @SuppressLint("MissingPermission") override fun answerCall() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - telecomManager.acceptRingingCall() + if (!hasAnswerPhoneCallsPermission()) { + return } + + telecomManager?.acceptRingingCall() + } + + private fun hasAnswerPhoneCallsPermission(): Boolean { + return ActivityCompat.checkSelfPermission( + ctx, + Manifest.permission.ANSWER_PHONE_CALLS + ) == PackageManager.PERMISSION_GRANTED } + @SuppressLint("MissingPermission") override fun endCall() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - telecomManager.endCall() + if (!hasAnswerPhoneCallsPermission()) { + return + } + + telecomManager?.endCall() + } + } + + override suspend fun sendSms(number: String, message: String): KMResult { + val smsManager: SmsManager? = ctx.getSystemService() + + if (smsManager == null) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + KMError.SystemFeatureNotSupported(PackageManager.FEATURE_TELEPHONY_MESSAGING) + } else { + KMError.SystemFeatureNotSupported(PackageManager.FEATURE_TELEPHONY) + } + } + + val sentPendingIntent = + PendingIntent.getBroadcast( + ctx, + 0, + Intent(ACTION_SMS_SENT_RESULT), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + try { + if (SystemClock.uptimeMillis() - lastSmsTime < SMS_MIN_RATE_MILLIS) { + Timber.d("SMS rate limit exceeded to protect against significant costs") + return KMError.KeyMapperSmsRateLimit + } + + smsManager.sendTextMessage(number, null, message, sentPendingIntent, null) + lastSmsTime = SystemClock.uptimeMillis() + } catch (e: IllegalArgumentException) { + return KMError.Exception(e) + } + + try { + return withTimeout(10000L) { + val resultCode = smsSentResultFlow.receive() + + when (resultCode) { + Activity.RESULT_OK -> Success(Unit) + else -> KMError.SendSmsError(resultCode) + } + } + } catch (e: TimeoutCancellationException) { + return KMError.Exception(e) + } + } + + override fun composeSms(number: String, message: String): KMResult { + try { + Intent(Intent.ACTION_VIEW).apply { + data = "smsto:$number".toUri() + putExtra("sms_body", message) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + ctx.startActivity(this) + } + + return Success(Unit) + } catch (e: ActivityNotFoundException) { + return KMError.NoAppToSendSms } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/phone/PhoneAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/phone/PhoneAdapter.kt index 83fe2973be..d1b6e47239 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/phone/PhoneAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/phone/PhoneAdapter.kt @@ -15,4 +15,6 @@ interface PhoneAdapter { fun startCall(number: String): KMResult<*> fun answerCall() fun endCall() + suspend fun sendSms(number: String, message: String): KMResult + fun composeSms(number: String, message: String): KMResult } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt index 081f1deafb..97f911ba8c 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/root/SuAdapter.kt @@ -1,92 +1,55 @@ package io.github.sds100.keymapper.system.root -import io.github.sds100.keymapper.common.utils.KMError -import io.github.sds100.keymapper.common.utils.KMResult -import io.github.sds100.keymapper.common.utils.Success -import io.github.sds100.keymapper.common.utils.firstBlocking -import io.github.sds100.keymapper.data.Keys -import io.github.sds100.keymapper.data.repositories.PreferenceRepository -import io.github.sds100.keymapper.system.SystemError -import io.github.sds100.keymapper.system.permissions.Permission +import com.topjohnwu.superuser.Shell +import io.github.sds100.keymapper.system.shell.BaseShellAdapter import io.github.sds100.keymapper.system.shell.ShellAdapter -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import java.io.IOException -import java.io.InputStream import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import timber.log.Timber @Singleton -class SuAdapterImpl @Inject constructor( - coroutineScope: CoroutineScope, - private val shell: ShellAdapter, - private val preferenceRepository: PreferenceRepository, -) : SuAdapter { - private var process: Process? = null - - override val isGranted: StateFlow = preferenceRepository.get(Keys.hasRootPermission) - .map { it ?: false } - .stateIn(coroutineScope, SharingStarted.Eagerly, false) - - override fun requestPermission(): Boolean { - preferenceRepository.set(Keys.hasRootPermission, true) +class SuAdapterImpl @Inject constructor() : + BaseShellAdapter(), + SuAdapter { + override val isRootGranted: MutableStateFlow = MutableStateFlow(getIsRooted()) - // show the su prompt - shell.run("su") - - return true + override fun requestPermission() { + invalidateIsRooted() } - override fun execute(command: String, block: Boolean): KMResult<*> { - if (!isGranted.firstBlocking()) { - return SystemError.PermissionDenied(Permission.ROOT) - } + override fun prepareCommand(command: String): Array { + // Execute through su -c to properly handle multi-line commands and shell syntax + return arrayOf("su", "-c", command) + } + fun invalidateIsRooted() { try { - if (block) { - // Don't use the long running su process because that will block the thread indefinitely - shell.run("su", "-c", command, waitFor = true) - } else { - if (process == null) { - process = ProcessBuilder("su").start() - } + // Close the shell so a new one is started without root permission. + val isRooted = getIsRooted() + isRootGranted.update { isRooted } - with(process!!.outputStream.bufferedWriter()) { - write("$command\n") - flush() - } + if (isRooted) { + Timber.i("Root access granted") + } else { + Timber.i("Root access denied") } - - return Success(Unit) } catch (e: Exception) { - return KMError.Exception(e) + Timber.e("Exception invalidating root detection: $e") } } - override fun getCommandOutput(command: String): KMResult { - if (!isGranted.firstBlocking()) { - return SystemError.PermissionDenied(Permission.ROOT) - } - - try { - val inputStream = shell.getShellCommandStdOut("su", "-c", command) - return Success(inputStream) - } catch (e: IOException) { - return KMError.UnknownIOError - } + private fun getIsRooted(): Boolean { + Shell.getShell().waitAndClose() + val isRooted = Shell.isAppGrantedRoot() ?: false + return isRooted } } -interface SuAdapter { - val isGranted: StateFlow +interface SuAdapter : ShellAdapter { + val isRootGranted: StateFlow - /** - * @return whether root permission was granted successfully - */ - fun requestPermission(): Boolean - fun execute(command: String, block: Boolean = false): KMResult<*> - fun getCommandOutput(command: String): KMResult + fun requestPermission() } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/BaseShellAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/BaseShellAdapter.kt new file mode 100644 index 0000000000..289399166f --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/shell/BaseShellAdapter.kt @@ -0,0 +1,125 @@ +package io.github.sds100.keymapper.system.shell + +import io.github.sds100.keymapper.common.models.ShellResult +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.success +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withTimeoutOrNull +import java.io.IOException +import java.io.InterruptedIOException + +abstract class BaseShellAdapter() : ShellAdapter { + abstract fun prepareCommand(command: String): Array + + override suspend fun execute( + command: String, + timeoutMillis: Long, + ): KMResult = coroutineScope { + try { + val process = ProcessBuilder() + .command(*prepareCommand(command)) + // Redirect stderr to stdout + .redirectErrorStream(true) + .start() + + val stdoutReader = process.inputStream.bufferedReader() + var stdout = "" + + try { + val readStdoutJob = launch(Dispatchers.IO) { + stdout = stdoutReader.readText() + } + + val exitCode = withTimeoutOrNull(timeoutMillis) { + // This is required so that the blocking process code is interrupted when + // the coroutine is cancelled by the timeout. + runInterruptible(Dispatchers.IO) { + process.waitFor() + } + } + + readStdoutJob.cancel() + + if (exitCode == null) { + KMError.ShellCommandTimeout(timeoutMillis, stdout) + } else { + Success(ShellResult(stdout, exitCode)) + } + } finally { + stdoutReader.close() + process.destroy() + } + } catch (e: IOException) { + KMError.Exception(e) + } + } + + override suspend fun executeWithStreamingOutput( + command: String, + timeoutMillis: Long + ): Flow> = callbackFlow { + try { + val process = ProcessBuilder() + .command(*prepareCommand(command)) + // Redirect stderr to stdout + .redirectErrorStream(true) + .start() + + val stdoutReader = process.inputStream.bufferedReader() + val stdout = StringBuilder() + + try { + val readStdoutJob = launch(Dispatchers.IO) { + var line: String? = null + + try { + while (stdoutReader.readLine().also { line = it } != null) { + stdout.appendLine(line) + if (line != null) { + send(ShellResult(stdout.toString(), null).success()) + } + } + } catch (e: InterruptedIOException) { + // Do nothing. This is thrown due to the timeout below. + } + } + + val exitCode = withTimeoutOrNull(timeoutMillis) { + // This is required so that the blocking process code is interrupted when + // the coroutine is cancelled by the timeout. + runInterruptible(Dispatchers.IO) { + process.waitFor() + } + } + + readStdoutJob.cancel() + + if (exitCode == null) { + send(KMError.ShellCommandTimeout(timeoutMillis, stdout.toString())) + } else { + send(ShellResult(stdout.toString(), exitCode).success()) + } + + readStdoutJob.cancel() + + } finally { + process.destroy() + stdoutReader.close() + } + + } catch (e: IOException) { + trySend(KMError.Exception(e)) + } finally { + this@callbackFlow.close() + awaitClose {} + } + } +} \ No newline at end of file diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt index f22b677ec6..a2090d3c7c 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/shell/ShellAdapter.kt @@ -1,10 +1,17 @@ package io.github.sds100.keymapper.system.shell +import io.github.sds100.keymapper.common.models.ShellResult import io.github.sds100.keymapper.common.utils.KMResult -import java.io.InputStream +import kotlinx.coroutines.flow.Flow interface ShellAdapter { - fun run(vararg command: String, waitFor: Boolean = false): Boolean - fun execute(command: String): KMResult<*> - fun getShellCommandStdOut(vararg command: String): InputStream + suspend fun execute( + command: String, + timeoutMillis: Long = 10000L + ): KMResult + + suspend fun executeWithStreamingOutput( + command: String, + timeoutMillis: Long + ): Flow> } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shell/StandardShellAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/shell/StandardShellAdapter.kt new file mode 100644 index 0000000000..5d11a0bb7b --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/shell/StandardShellAdapter.kt @@ -0,0 +1,12 @@ +package io.github.sds100.keymapper.system.shell + +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StandardShellAdapter @Inject constructor() : BaseShellAdapter() { + override fun prepareCommand(command: String): Array { + // Execute through sh -c to properly handle multi-line commands and shell syntax + return arrayOf("sh", "-c", command) + } +} \ No newline at end of file diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shizuku/ShizukuInputEventInjector.kt b/system/src/main/java/io/github/sds100/keymapper/system/shizuku/ShizukuInputEventInjector.kt deleted file mode 100644 index e63b4e4590..0000000000 --- a/system/src/main/java/io/github/sds100/keymapper/system/shizuku/ShizukuInputEventInjector.kt +++ /dev/null @@ -1,57 +0,0 @@ -package io.github.sds100.keymapper.system.shizuku - -import android.annotation.SuppressLint -import android.content.Context -import android.hardware.input.IInputManager -import android.os.SystemClock -import android.view.KeyEvent -import io.github.sds100.keymapper.common.utils.InputEventType -import io.github.sds100.keymapper.system.inputevents.InputEventInjector -import io.github.sds100.keymapper.system.inputmethod.InputKeyModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import rikka.shizuku.ShizukuBinderWrapper -import rikka.shizuku.SystemServiceHelper -import timber.log.Timber - -@SuppressLint("PrivateApi") -class ShizukuInputEventInjector : InputEventInjector { - - companion object { - // private const val INJECT_INPUT_EVENT_MODE_ASYNC = 0 - - private const val INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH = 2 - } - - private val iInputManager: IInputManager by lazy { - val binder = - ShizukuBinderWrapper(SystemServiceHelper.getSystemService(Context.INPUT_SERVICE)) - IInputManager.Stub.asInterface(binder) - } - - override suspend fun inputKeyEvent(model: InputKeyModel) { - Timber.d("Inject input event with Shizuku ${KeyEvent.keyCodeToString(model.keyCode)}, $model") - - val action = when (model.inputType) { - InputEventType.DOWN, InputEventType.DOWN_UP -> KeyEvent.ACTION_DOWN - InputEventType.UP -> KeyEvent.ACTION_UP - } - - val eventTime = SystemClock.uptimeMillis() - - val keyEvent = createInjectedKeyEvent(eventTime, action, model) - - withContext(Dispatchers.IO) { - // MUST wait for the application to finish processing the event before sending the next one. - // Otherwise, rapidly repeating input events will go in a big queue and all inputs - // into the application will be delayed or overloaded. - iInputManager.injectInputEvent(keyEvent, INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH) - - if (model.inputType == InputEventType.DOWN_UP) { - val upEvent = KeyEvent.changeAction(keyEvent, KeyEvent.ACTION_UP) - - iInputManager.injectInputEvent(upEvent, INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH) - } - } - } -} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/shizuku/ShizukuUtils.kt b/system/src/main/java/io/github/sds100/keymapper/system/shizuku/ShizukuUtils.kt index df8a057936..856129b345 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/shizuku/ShizukuUtils.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/shizuku/ShizukuUtils.kt @@ -11,6 +11,4 @@ object ShizukuUtils { * Android 11 because a PC/mac isn't needed after every reboot to make it work. */ fun isRecommendedForSdkVersion(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R - - fun isSupportedForSdkVersion(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/volume/AndroidVolumeAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/volume/AndroidVolumeAdapter.kt index e0b3c50c73..34376eab8a 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/volume/AndroidVolumeAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/volume/AndroidVolumeAdapter.kt @@ -4,21 +4,27 @@ import android.app.NotificationManager import android.content.Context import android.media.AudioManager import android.os.Build -import androidx.annotation.RequiresApi +import android.provider.Settings import androidx.core.content.getSystemService -import io.github.sds100.keymapper.common.utils.KMError +import dagger.hilt.android.qualifiers.ApplicationContext +import io.github.sds100.keymapper.common.utils.Constants import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.SettingsUtils import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.otherwise import io.github.sds100.keymapper.common.utils.then +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.isConnected import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.permissions.Permission -import dagger.hilt.android.qualifiers.ApplicationContext +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class AndroidVolumeAdapter @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager ) : VolumeAdapter { private val ctx = context.applicationContext @@ -26,13 +32,30 @@ class AndroidVolumeAdapter @Inject constructor( private val notificationManager: NotificationManager by lazy { ctx.getSystemService()!! } override val ringerMode: RingerMode - get() = when (audioManager.ringerMode) { - AudioManager.RINGER_MODE_NORMAL -> RingerMode.NORMAL - AudioManager.RINGER_MODE_VIBRATE -> RingerMode.VIBRATE - AudioManager.RINGER_MODE_SILENT -> RingerMode.SILENT - else -> throw Exception("Don't know how to convert this ringer moder ${audioManager.ringerMode}") + get() { + // Get the current ringer mode with the setting because the AudioManager + // always returns the same value when the value has been changed by the + // the system bridge. + val ringerModeSdk = if (systemBridgeConnectionManager.isConnected()) { + SettingsUtils.getGlobalSetting( + ctx, + Settings.Global.MODE_RINGER + ) ?: 0 + } else { + audioManager.ringerMode + } + + return when (ringerModeSdk) { + AudioManager.RINGER_MODE_NORMAL -> RingerMode.NORMAL + AudioManager.RINGER_MODE_VIBRATE -> RingerMode.VIBRATE + AudioManager.RINGER_MODE_SILENT -> RingerMode.SILENT + else -> throw Exception("Don't know how to convert this ringer moder ${audioManager.ringerMode}") + } } + override val isMicrophoneMuted: Boolean + get() = audioManager.isMicrophoneMute + override fun raiseVolume(stream: VolumeStream?, showVolumeUi: Boolean): KMResult<*> = stream.convert().then { streamType -> adjustVolume(AudioManager.ADJUST_RAISE, showVolumeUi, streamType) @@ -44,32 +67,20 @@ class AndroidVolumeAdapter @Inject constructor( } override fun muteVolume(stream: VolumeStream?, showVolumeUi: Boolean): KMResult<*> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - return stream.convert().then { streamType -> - adjustVolume(AudioManager.ADJUST_MUTE, showVolumeUi, streamType) - } - } else { - return KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) + return stream.convert().then { streamType -> + adjustVolume(AudioManager.ADJUST_MUTE, showVolumeUi, streamType) } } override fun unmuteVolume(stream: VolumeStream?, showVolumeUi: Boolean): KMResult<*> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - return stream.convert().then { streamType -> - adjustVolume(AudioManager.ADJUST_UNMUTE, showVolumeUi, streamType) - } - } else { - return KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) + return stream.convert().then { streamType -> + adjustVolume(AudioManager.ADJUST_UNMUTE, showVolumeUi, streamType) } } override fun toggleMuteVolume(stream: VolumeStream?, showVolumeUi: Boolean): KMResult<*> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - return stream.convert().then { streamType -> - adjustVolume(AudioManager.ADJUST_TOGGLE_MUTE, showVolumeUi, streamType) - } - } else { - return KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) + return stream.convert().then { streamType -> + adjustVolume(AudioManager.ADJUST_TOGGLE_MUTE, showVolumeUi, streamType) } } @@ -84,39 +95,49 @@ class AndroidVolumeAdapter @Inject constructor( RingerMode.SILENT -> AudioManager.RINGER_MODE_SILENT } - audioManager.ringerMode = sdkMode + return if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) { + systemBridgeConnectionManager.run { systemBridge -> + systemBridge.setRingerMode(sdkMode) + }.otherwise { + Timber.e("Failed to set ringer mode with System Bridge, falling back to AudioManager") - return Success(Unit) + audioManager.ringerMode = sdkMode + Success(Unit) + } + } else { + audioManager.ringerMode = sdkMode + Success(Unit) + } } catch (e: SecurityException) { return SystemError.PermissionDenied(Permission.ACCESS_NOTIFICATION_POLICY) } } override fun enableDndMode(dndMode: DndMode): KMResult<*> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - notificationManager.setInterruptionFilter(dndMode.convert()) - return Success(Unit) - } + notificationManager.setInterruptionFilter(dndMode.convert()) + return Success(Unit) - return KMError.SdkVersionTooLow(Build.VERSION_CODES.M) } override fun disableDndMode(): KMResult<*> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - notificationManager.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_ALL) - return Success(Unit) - } + notificationManager.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_ALL) + return Success(Unit) - return KMError.SdkVersionTooLow(Build.VERSION_CODES.M) } - override fun isDndEnabled(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + override fun isDndEnabled(): Boolean = notificationManager.currentInterruptionFilter != NotificationManager.INTERRUPTION_FILTER_ALL - } else { - false + + override fun muteMicrophone(): KMResult<*> { + audioManager.isMicrophoneMute = true + return Success(Unit) + } + + override fun unmuteMicrophone(): KMResult<*> { + audioManager.isMicrophoneMute = false + return Success(Unit) } - @RequiresApi(Build.VERSION_CODES.M) private fun DndMode.convert(): Int = when (this) { DndMode.ALARMS -> NotificationManager.INTERRUPTION_FILTER_ALARMS DndMode.PRIORITY -> NotificationManager.INTERRUPTION_FILTER_PRIORITY @@ -131,12 +152,7 @@ class AndroidVolumeAdapter @Inject constructor( VolumeStream.RING -> Success(AudioManager.STREAM_RING) VolumeStream.SYSTEM -> Success(AudioManager.STREAM_SYSTEM) VolumeStream.VOICE_CALL -> Success(AudioManager.STREAM_VOICE_CALL) - VolumeStream.ACCESSIBILITY -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Success(AudioManager.STREAM_ACCESSIBILITY) - } else { - KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.O) - } + VolumeStream.ACCESSIBILITY -> Success(AudioManager.STREAM_ACCESSIBILITY) null -> Success(null) } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/volume/VolumeAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/volume/VolumeAdapter.kt index 2c71fd9020..d6bf4f30ac 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/volume/VolumeAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/volume/VolumeAdapter.kt @@ -14,6 +14,10 @@ interface VolumeAdapter { fun showVolumeUi(): KMResult<*> fun setRingerMode(mode: RingerMode): KMResult<*> + val isMicrophoneMuted: Boolean + fun muteMicrophone(): KMResult<*> + fun unmuteMicrophone(): KMResult<*> + fun isDndEnabled(): Boolean fun enableDndMode(dndMode: DndMode): KMResult<*> fun disableDndMode(): KMResult<*> diff --git a/system/src/main/res/values/strings.xml b/system/src/main/res/values/strings.xml index b644907005..ba3e567712 100644 --- a/system/src/main/res/values/strings.xml +++ b/system/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - Key Mapper Basic Input Method + Key Mapper Input Method \ No newline at end of file diff --git a/systemstubs/build.gradle.kts b/systemstubs/build.gradle.kts index 98e0963f57..9c067452a1 100644 --- a/systemstubs/build.gradle.kts +++ b/systemstubs/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) } android { @@ -36,4 +36,5 @@ android { } dependencies { + implementation(libs.androidx.annotation.jvm) } diff --git a/systemstubs/src/main/aidl/android/bluetooth/IBluetoothManager.aidl b/systemstubs/src/main/aidl/android/bluetooth/IBluetoothManager.aidl new file mode 100644 index 0000000000..4a9624f012 --- /dev/null +++ b/systemstubs/src/main/aidl/android/bluetooth/IBluetoothManager.aidl @@ -0,0 +1,10 @@ +package android.bluetooth; + +import android.content.AttributionSource; + +interface IBluetoothManager { + // Requires Android 13+ + boolean enable(in AttributionSource attributionSource); + // Requires Android 13+ + boolean disable(in AttributionSource attributionSource, boolean persist); +} \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/content/pm/IPackageManager.aidl b/systemstubs/src/main/aidl/android/content/pm/IPackageManager.aidl index 51f2382d8b..23f67f7321 100644 --- a/systemstubs/src/main/aidl/android/content/pm/IPackageManager.aidl +++ b/systemstubs/src/main/aidl/android/content/pm/IPackageManager.aidl @@ -2,4 +2,5 @@ package android.content.pm; interface IPackageManager { void grantRuntimePermission(String packageName, String permissionName, int userId); + boolean hasSystemFeature(String name, int version); } \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/hardware/input/IInputManager.aidl b/systemstubs/src/main/aidl/android/hardware/input/IInputManager.aidl index 0cbe5f383d..15eaf6e908 100644 --- a/systemstubs/src/main/aidl/android/hardware/input/IInputManager.aidl +++ b/systemstubs/src/main/aidl/android/hardware/input/IInputManager.aidl @@ -1,6 +1,8 @@ package android.hardware.input; +import android.view.InputDevice; interface IInputManager { boolean injectInputEvent(in InputEvent event, int mode); + InputDevice getInputDevice(int id); } \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/media/IAudioService.aidl b/systemstubs/src/main/aidl/android/media/IAudioService.aidl new file mode 100644 index 0000000000..9228ec5cd5 --- /dev/null +++ b/systemstubs/src/main/aidl/android/media/IAudioService.aidl @@ -0,0 +1,5 @@ +package android.media; + +interface IAudioService { + void setRingerModeInternal(int ringerMode, String caller); +} \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/net/IConnectivityManager.aidl b/systemstubs/src/main/aidl/android/net/IConnectivityManager.aidl new file mode 100644 index 0000000000..6402a6e148 --- /dev/null +++ b/systemstubs/src/main/aidl/android/net/IConnectivityManager.aidl @@ -0,0 +1,5 @@ +package android.net; + +interface IConnectivityManager { + void setAirplaneMode(boolean enable); +} \ No newline at end of file diff --git a/systemstubs/src/main/aidl/android/net/wifi/IWifiManager.aidl b/systemstubs/src/main/aidl/android/net/wifi/IWifiManager.aidl new file mode 100644 index 0000000000..3aab61dd86 --- /dev/null +++ b/systemstubs/src/main/aidl/android/net/wifi/IWifiManager.aidl @@ -0,0 +1,5 @@ +package android.net.wifi; + +interface IWifiManager { + boolean setWifiEnabled(String packageName, boolean enable); +} \ No newline at end of file diff --git a/systemstubs/src/main/aidl/com/android/internal/telephony/ITelephony.aidl b/systemstubs/src/main/aidl/com/android/internal/telephony/ITelephony.aidl new file mode 100644 index 0000000000..c26a955b6b --- /dev/null +++ b/systemstubs/src/main/aidl/com/android/internal/telephony/ITelephony.aidl @@ -0,0 +1,9 @@ +package com.android.internal.telephony; + +interface ITelephony { + // Requires Android 12+ + void setDataEnabledForReason(int subId, int reason, boolean enable, String callingPackage); + + // Max Android 11 + void setUserDataEnabled(int subId, boolean enable); +} \ No newline at end of file diff --git a/systemstubs/src/main/java/android/app/ActivityTaskManagerApis.kt b/systemstubs/src/main/java/android/app/ActivityTaskManagerApis.kt new file mode 100644 index 0000000000..a980d7dcc4 --- /dev/null +++ b/systemstubs/src/main/java/android/app/ActivityTaskManagerApis.kt @@ -0,0 +1,27 @@ +package android.app + +import android.app.ActivityManager.RunningTaskInfo +import android.os.Build + +object ActivityTaskManagerApis { + fun getTasks( + activityTaskManager: IActivityTaskManager, + maxNum: Int, + filterOnlyVisibleRecents: Boolean, + keepIntentExtra: Boolean, + displayId: Int + ): MutableList? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return activityTaskManager.getTasks( + maxNum, + filterOnlyVisibleRecents, + keepIntentExtra, + displayId + ) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + return activityTaskManager.getTasks(maxNum, filterOnlyVisibleRecents, keepIntentExtra) + } else { + return activityTaskManager.getTasks(maxNum) + } + } +} \ No newline at end of file diff --git a/systemstubs/src/main/java/android/app/IActivityManager.java b/systemstubs/src/main/java/android/app/IActivityManager.java new file mode 100644 index 0000000000..5ef1b2eef7 --- /dev/null +++ b/systemstubs/src/main/java/android/app/IActivityManager.java @@ -0,0 +1,15 @@ +package android.app; + +import android.os.IBinder; + +public interface IActivityManager extends android.os.IInterface { + boolean removeTask(int taskId); + + void forceStopPackage(String packageName, int userId); + + abstract class Stub extends android.os.Binder implements IActivityManager { + public static IActivityManager asInterface(IBinder obj) { + throw new RuntimeException("Stub!"); + } + } +} diff --git a/systemstubs/src/main/java/android/app/IActivityTaskManager.java b/systemstubs/src/main/java/android/app/IActivityTaskManager.java new file mode 100644 index 0000000000..9ea2c4369f --- /dev/null +++ b/systemstubs/src/main/java/android/app/IActivityTaskManager.java @@ -0,0 +1,25 @@ +package android.app; + +import android.os.Build; +import android.os.IBinder; + +import java.util.List; + +import androidx.annotation.RequiresApi; + +public interface IActivityTaskManager extends android.os.IInterface { + List getTasks(int maxNum); + + @RequiresApi(Build.VERSION_CODES.S) + List getTasks(int maxNum, boolean filterOnlyVisibleRecents, + boolean keepIntentExtra); + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + List getTasks(int maxNum, boolean filterOnlyVisibleRecents, boolean keepIntentExtra, int displayId); + + abstract class Stub extends android.os.Binder implements IActivityTaskManager { + public static IActivityTaskManager asInterface(IBinder obj) { + throw new RuntimeException("Stub!"); + } + } +} diff --git a/systemstubs/src/main/java/android/nfc/INfcAdapter.java b/systemstubs/src/main/java/android/nfc/INfcAdapter.java new file mode 100644 index 0000000000..2e4b958915 --- /dev/null +++ b/systemstubs/src/main/java/android/nfc/INfcAdapter.java @@ -0,0 +1,24 @@ +package android.nfc; + +import android.os.Build; +import android.os.IBinder; + +import androidx.annotation.RequiresApi; + +public interface INfcAdapter extends android.os.IInterface { + boolean enable(); + + boolean disable(boolean saveState); + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + boolean enable(String pkg); + + @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) + boolean disable(boolean saveState, String pkg); + + abstract class Stub extends android.os.Binder implements INfcAdapter { + public static INfcAdapter asInterface(IBinder obj) { + throw new RuntimeException("Stub!"); + } + } +} \ No newline at end of file diff --git a/systemstubs/src/main/java/android/nfc/NfcAdapterApis.kt b/systemstubs/src/main/java/android/nfc/NfcAdapterApis.kt new file mode 100644 index 0000000000..3f7dce07c9 --- /dev/null +++ b/systemstubs/src/main/java/android/nfc/NfcAdapterApis.kt @@ -0,0 +1,21 @@ +package android.nfc + +import android.os.Build + +object NfcAdapterApis { + fun enable(adapter: INfcAdapter, packageName: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + adapter.enable(packageName) + } else { + adapter.enable() + } + } + + fun disable(adapter: INfcAdapter, saveState: Boolean, packageName: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + adapter.disable(saveState, packageName) + } else { + adapter.disable(saveState) + } + } +} \ No newline at end of file diff --git a/systemstubs/src/main/java/android/permission/PermissionManagerApis.kt b/systemstubs/src/main/java/android/permission/PermissionManagerApis.kt new file mode 100644 index 0000000000..c5983b5a34 --- /dev/null +++ b/systemstubs/src/main/java/android/permission/PermissionManagerApis.kt @@ -0,0 +1,54 @@ +package android.permission + +import android.os.Build + +object PermissionManagerApis { + fun grantPermission( + permissionManager: IPermissionManager, + packageName: String, + permission: String, + deviceId: Int, + userId: Int + ) { + // In revisions of Android 14 the method to grant permissions changed + // so try them all. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + try { + permissionManager.grantRuntimePermission( + packageName, + permission, + deviceId, + userId, + ) + } catch (_: NoSuchMethodError) { + try { + permissionManager.grantRuntimePermission( + packageName, + permission, + "0", + userId, + ) + } catch (_: NoSuchMethodError) { + permissionManager.grantRuntimePermission( + packageName, + permission, + userId, + ) + } + } + // In Android 11 this method was moved from IPackageManager to IPermissionManager. + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + permissionManager.grantRuntimePermission( + packageName, + permission, + userId, + ) + } else { + permissionManager.grantRuntimePermission( + packageName, + permission, + userId, + ) + } + } +} \ No newline at end of file diff --git a/systemstubs/src/main/java/com/android/org/conscrypt/Conscrypt.java b/systemstubs/src/main/java/com/android/org/conscrypt/Conscrypt.java new file mode 100644 index 0000000000..14396b0421 --- /dev/null +++ b/systemstubs/src/main/java/com/android/org/conscrypt/Conscrypt.java @@ -0,0 +1,28 @@ +package com.android.org.conscrypt; + +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSocket; + +import androidx.annotation.RequiresApi; + +@RequiresApi(29) +public class Conscrypt { + + /** + * Exports a value derived from the TLS master secret as described in RFC 5705. + * + * @param label the label to use in calculating the exported value. This must be + * an ASCII-only string. + * @param context the application-specific context value to use in calculating the + * exported value. This may be {@code null} to use no application context, which is + * treated differently than an empty byte array. + * @param length the number of bytes of keying material to return. + * @return a value of the specified length, or {@code null} if the handshake has not yet + * completed or the connection has been closed. + * @throws SSLException if the value could not be exported. + */ + public static byte[] exportKeyingMaterial(SSLSocket socket, String label, byte[] context, + int length) throws SSLException { + throw new RuntimeException("STUB"); + } +}