diff --git a/.cursor/rules/create-action.mdc b/.cursor/rules/create-action.mdc new file mode 100644 index 0000000000..e8b352f9db --- /dev/null +++ b/.cursor/rules/create-action.mdc @@ -0,0 +1,27 @@ +--- +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 new file mode 100644 index 0000000000..ef3b89eb3a --- /dev/null +++ b/.cursor/rules/jetpack-compose.mdc @@ -0,0 +1,71 @@ +--- +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/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 9b58552057..17554251a8 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -5,6 +5,41 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb8048a0a..d71394fc8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,25 @@ +## [3.1.0](https://github.com/sds100/KeyMapper/releases/tag/v3.1.0) + +#### 10 May 2025 + +## Added + +- #699 Time constraints ⏰ +- #257 Action to interact with user interface elements inside other apps. +- #1663 Actions to stop, step forward, and step backward playing media. +- #1682 Show "Purchased!" text next to the use button for advanced triggers. + +## Changed + +- Rename tap screen actions inside key maps. + +## Bug fixes + +- #1683 key event actions work in Minecraft and other apps again. +- Export log files as .txt instead of .zip files. +- #1684 Removed the redundant and broken refresh devices button when configuring a key event action because they are automatically refreshed anyway. +- #1687 restoring key map groups would sometimes fail. + ## [3.0.1](https://github.com/sds100/KeyMapper/releases/tag/v3.0.1) #### 28 April 2025 diff --git a/app/build.gradle b/app/build.gradle index 00b2fdc6af..13f120a407 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -119,6 +119,9 @@ android { } compileOptions { + // Required for desugaring new Java time API on lower than API 26 + coreLibraryDesugaringEnabled = true + sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } @@ -162,7 +165,7 @@ dependencies { compileOnly project(":systemstubs") - def room_version = "2.6.1" + def room_version = "2.7.1" def coroutinesVersion = "1.9.0" def nav_version = '2.8.9' def epoxy_version = "4.6.2" @@ -176,7 +179,7 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0" // random stuff - implementation "com.google.android.material:material:1.13.0-alpha12" + implementation "com.google.android.material:material:1.13.0-alpha13" implementation "com.github.salomonbrys.kotson:kotson:2.5.0" implementation "com.airbnb.android:epoxy:$epoxy_version" implementation "com.airbnb.android:epoxy-databinding:$epoxy_version" @@ -189,9 +192,10 @@ dependencies { implementation "dev.rikka.shizuku:api:$shizuku_version" implementation "dev.rikka.shizuku:provider:$shizuku_version" implementation "org.lsposed.hiddenapibypass:hiddenapibypass:4.3" - proImplementation 'com.revenuecat.purchases:purchases:8.15.0' + proImplementation 'com.revenuecat.purchases:purchases:8.17.0' proImplementation "com.airbnb.android:lottie-compose:6.6.3" implementation("com.squareup.okhttp3:okhttp:4.12.0") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") // splitties implementation "com.louiscad.splitties:splitties-bitflags:$splitties_version" @@ -203,7 +207,7 @@ dependencies { // androidx implementation "androidx.legacy:legacy-support-core-ui:1.0.0" - implementation "androidx.core:core-ktx:1.15.0" + implementation "androidx.core:core-ktx:1.16.0" implementation "androidx.activity:activity-ktx:1.10.1" implementation "androidx.fragment:fragment-ktx:1.8.6" @@ -221,7 +225,7 @@ dependencies { implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" implementation "androidx.room:room-runtime:$room_version" implementation "androidx.viewpager2:viewpager2:1.1.0" - implementation "androidx.datastore:datastore-preferences:1.2.0-alpha01" + implementation "androidx.datastore:datastore-preferences:1.2.0-alpha02" implementation "androidx.core:core-splashscreen:1.0.1" implementation "androidx.activity:activity-compose:1.10.1" implementation "androidx.navigation:navigation-compose:2.8.9" @@ -229,7 +233,7 @@ dependencies { ksp "androidx.room:room-compiler:$room_version" // Compose - Dependency composeBom = platform('androidx.compose:compose-bom-beta:2025.03.01') + Dependency composeBom = platform('androidx.compose:compose-bom-beta:2025.04.01') implementation composeBom implementation 'androidx.compose.foundation:foundation' implementation "androidx.compose.ui:ui-android" diff --git a/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/19.json b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/19.json new file mode 100644 index 0000000000..ed9aef420f --- /dev/null +++ b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/19.json @@ -0,0 +1,441 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "300aa53acf7905efdf0c5b0ff7516ec9", + "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, 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" + } + ], + "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)", + "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 + } + ], + "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, '300aa53acf7905efdf0c5b0ff7516ec9')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/20.json b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/20.json new file mode 100644 index 0000000000..3ee2ff236a --- /dev/null +++ b/app/schemas/io.github.sds100.keymapper.data.db.AppDatabase/20.json @@ -0,0 +1,460 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "f2f5eac59b7bdee472c0dd7ff9bae4b2", + "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, 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" + } + ], + "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, 'f2f5eac59b7bdee472c0dd7ff9bae4b2')" + ] + } +} \ No newline at end of file diff --git a/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt b/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt index 3d2a18d9cf..8e0fb9dda5 100644 --- a/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt +++ b/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.system.accessibility import io.github.sds100.keymapper.actions.PerformActionsUseCase import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase +import io.github.sds100.keymapper.data.repositories.AccessibilityNodeRepository import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.FingerprintGesturesSupportedUseCase import io.github.sds100.keymapper.mappings.PauseKeyMapsUseCase @@ -17,7 +18,7 @@ import kotlinx.coroutines.flow.SharedFlow class AccessibilityServiceController( coroutineScope: CoroutineScope, - accessibilityService: IAccessibilityService, + accessibilityService: MyAccessibilityService, inputEvents: SharedFlow, outputEvents: MutableSharedFlow, detectConstraintsUseCase: DetectConstraintsUseCase, @@ -30,6 +31,7 @@ class AccessibilityServiceController( suAdapter: SuAdapter, inputMethodAdapter: InputMethodAdapter, settingsRepository: PreferenceRepository, + nodeRepository: AccessibilityNodeRepository, ) : BaseAccessibilityServiceController( coroutineScope, accessibilityService, @@ -45,4 +47,5 @@ class AccessibilityServiceController( suAdapter, inputMethodAdapter, settingsRepository, + nodeRepository, ) diff --git a/app/src/main/assets/whats-new.txt b/app/src/main/assets/whats-new.txt index 7c29bd65d8..d2b07cf8ea 100644 --- a/app/src/main/assets/whats-new.txt +++ b/app/src/main/assets/whats-new.txt @@ -1,4 +1,10 @@ -Key Mapper 3.0 is here! 🎉 +Fix for Minecraft 1.21.80! + +⏰ Time constraints. + +🔎 Action to interact with app elements. + +== 3.0 features == 🫧 Floating Buttons: you can create custom on-screen buttons to trigger key maps. diff --git a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt index b5b3275dc6..162bbd5858 100644 --- a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt +++ b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.OnLifecycleEvent import androidx.lifecycle.ProcessLifecycleOwner import androidx.multidex.MultiDexApplication +import io.github.sds100.keymapper.actions.uielement.InteractUiElementController import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.entities.LogEntryEntity import io.github.sds100.keymapper.logging.KeyMapperLoggingTree @@ -151,6 +152,15 @@ class KeyMapperApp : MultiDexApplication() { RecordTriggerController(appCoroutineScope, accessibilityServiceAdapter) } + val interactUiElementController by lazy { + InteractUiElementController( + appCoroutineScope, + accessibilityServiceAdapter, + ServiceLocator.accessibilityNodeRepository(this), + packageManagerAdapter, + ) + } + val autoGrantPermissionController by lazy { AutoGrantPermissionController( appCoroutineScope, diff --git a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt index 00f14520f2..c0dd401af4 100755 --- a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt @@ -8,6 +8,8 @@ import io.github.sds100.keymapper.actions.sound.SoundsManagerImpl import io.github.sds100.keymapper.backup.BackupManager import io.github.sds100.keymapper.backup.BackupManagerImpl import io.github.sds100.keymapper.data.db.AppDatabase +import io.github.sds100.keymapper.data.repositories.AccessibilityNodeRepository +import io.github.sds100.keymapper.data.repositories.AccessibilityNodeRepositoryImpl import io.github.sds100.keymapper.data.repositories.FloatingButtonRepository import io.github.sds100.keymapper.data.repositories.FloatingLayoutRepository import io.github.sds100.keymapper.data.repositories.GroupRepository @@ -214,6 +216,20 @@ object ServiceLocator { ) } + @Volatile + private var accessibilityNodeRepository: AccessibilityNodeRepository? = null + + fun accessibilityNodeRepository(context: Context): AccessibilityNodeRepository { + synchronized(this) { + return accessibilityNodeRepository ?: AccessibilityNodeRepositoryImpl( + (context.applicationContext as KeyMapperApp).appCoroutineScope, + database(context).accessibilityNodeDao(), + ).also { + this.accessibilityNodeRepository = it + } + } + } + fun fileAdapter(context: Context): FileAdapter = (context.applicationContext as KeyMapperApp).fileAdapter fun inputMethodAdapter(context: Context): InputMethodAdapter = (context.applicationContext as KeyMapperApp).inputMethodAdapter diff --git a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt index caee6e48b0..260a3eca4a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt +++ b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt @@ -142,7 +142,7 @@ object UseCases { ServiceLocator.intentAdapter(ctx), getActionError(ctx), keyMapperImeMessenger(ctx, keyEventRelayService), - ShizukuInputEventInjector(coroutineScope = ServiceLocator.appCoroutineScope(ctx)), + ShizukuInputEventInjector(), ServiceLocator.packageManagerAdapter(ctx), ServiceLocator.appShortcutAdapter(ctx), ServiceLocator.popupMessageAdapter(ctx), @@ -179,7 +179,7 @@ object UseCases { ServiceLocator.audioAdapter(ctx), keyMapperImeMessenger(ctx, keyEventRelayService), service, - ShizukuInputEventInjector(ServiceLocator.appCoroutineScope(ctx)), + ShizukuInputEventInjector(), ServiceLocator.popupMessageAdapter(ctx), ServiceLocator.permissionAdapter(ctx), ServiceLocator.resourceProvider(ctx), diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/Action.kt b/app/src/main/java/io/github/sds100/keymapper/actions/Action.kt index df4eccb63b..b5a2a5db4e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/Action.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/Action.kt @@ -33,11 +33,7 @@ data class Action( val multiplier: Int? = null, val delayBeforeNextAction: Int? = null, -) { - companion object { - const val REPEAT_DELAY_MIN = 0 - } -} +) object ActionEntityMapper { fun fromEntity(entity: ActionEntity): Action? { @@ -108,7 +104,7 @@ object ActionEntityMapper { ) } - fun toEntity(keyMap: KeyMap): List = keyMap.actionList.mapNotNull { action -> + fun toEntity(keyMap: KeyMap): List = keyMap.actionList.map { action -> val base = ActionDataEntityMapper.toEntity(action.data) val extras = mutableListOf().apply { @@ -187,7 +183,7 @@ object ActionEntityMapper { flags = flags.withFlag(ActionEntity.ACTION_FLAG_HOLD_DOWN) } - return@mapNotNull ActionEntity( + return@map ActionEntity( type = base.type, data = base.data, extras = base.extras.plus(extras), diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt index 5f4799294d..9a2734bb14 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionData.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.actions import io.github.sds100.keymapper.actions.pinchscreen.PinchScreenType +import io.github.sds100.keymapper.actions.uielement.NodeInteractionType import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.display.Orientation import io.github.sds100.keymapper.system.intents.IntentExtraModel @@ -260,7 +261,7 @@ sealed class ActionData : Comparable { } @Serializable - object Disable : DoNotDisturb() { + data object Disable : DoNotDisturb() { override val id = ActionId.DISABLE_DND_MODE } } @@ -268,32 +269,32 @@ sealed class ActionData : Comparable { @Serializable sealed class Rotation : ActionData() { @Serializable - object EnableAuto : Rotation() { + data object EnableAuto : Rotation() { override val id = ActionId.ENABLE_AUTO_ROTATE } @Serializable - object DisableAuto : Rotation() { + data object DisableAuto : Rotation() { override val id = ActionId.DISABLE_AUTO_ROTATE } @Serializable - object ToggleAuto : Rotation() { + data object ToggleAuto : Rotation() { override val id = ActionId.TOGGLE_AUTO_ROTATE } @Serializable - object Portrait : Rotation() { + data object Portrait : Rotation() { override val id = ActionId.PORTRAIT_MODE } @Serializable - object Landscape : Rotation() { + data object Landscape : Rotation() { override val id = ActionId.LANDSCAPE_MODE } @Serializable - object SwitchOrientation : Rotation() { + data object SwitchOrientation : Rotation() { override val id = ActionId.SWITCH_ORIENTATION } @@ -364,44 +365,74 @@ sealed class ActionData : Comparable { data class Rewind(override val packageName: String) : ControlMediaForApp() { override val id = ActionId.REWIND_PACKAGE } + + @Serializable + data class Stop(override val packageName: String) : ControlMediaForApp() { + override val id = ActionId.STOP_MEDIA_PACKAGE + } + + @Serializable + data class StepForward(override val packageName: String) : ControlMediaForApp() { + override val id = ActionId.STEP_FORWARD_PACKAGE + } + + @Serializable + data class StepBackward(override val packageName: String) : ControlMediaForApp() { + override val id = ActionId.STEP_BACKWARD_PACKAGE + } } @Serializable sealed class ControlMedia : ActionData() { @Serializable - object Pause : ControlMedia() { + data object Pause : ControlMedia() { override val id = ActionId.PAUSE_MEDIA } @Serializable - object Play : ControlMedia() { + data object Play : ControlMedia() { override val id = ActionId.PLAY_MEDIA } @Serializable - object PlayPause : ControlMedia() { + data object PlayPause : ControlMedia() { override val id = ActionId.PLAY_PAUSE_MEDIA } @Serializable - object NextTrack : ControlMedia() { + data object NextTrack : ControlMedia() { override val id = ActionId.NEXT_TRACK } @Serializable - object PreviousTrack : ControlMedia() { + data object PreviousTrack : ControlMedia() { override val id = ActionId.PREVIOUS_TRACK } @Serializable - object FastForward : ControlMedia() { + data object FastForward : ControlMedia() { override val id = ActionId.FAST_FORWARD } @Serializable - object Rewind : ControlMedia() { + data object Rewind : ControlMedia() { override val id = ActionId.REWIND } + + @Serializable + data object Stop : ControlMedia() { + override val id = ActionId.STOP_MEDIA + } + + @Serializable + data object StepForward : ControlMedia() { + override val id = ActionId.STEP_FORWARD + } + + @Serializable + data object StepBackward : ControlMedia() { + override val id = ActionId.STEP_BACKWARD + } } @Serializable @@ -537,17 +568,17 @@ sealed class ActionData : Comparable { @Serializable sealed class Wifi : ActionData() { @Serializable - object Enable : Wifi() { + data object Enable : Wifi() { override val id = ActionId.ENABLE_WIFI } @Serializable - object Disable : Wifi() { + data object Disable : Wifi() { override val id = ActionId.DISABLE_WIFI } @Serializable - object Toggle : Wifi() { + data object Toggle : Wifi() { override val id = ActionId.TOGGLE_WIFI } } @@ -555,17 +586,17 @@ sealed class ActionData : Comparable { @Serializable sealed class Bluetooth : ActionData() { @Serializable - object Enable : Bluetooth() { + data object Enable : Bluetooth() { override val id = ActionId.ENABLE_BLUETOOTH } @Serializable - object Disable : Bluetooth() { + data object Disable : Bluetooth() { override val id = ActionId.DISABLE_BLUETOOTH } @Serializable - object Toggle : Bluetooth() { + data object Toggle : Bluetooth() { override val id = ActionId.TOGGLE_BLUETOOTH } } @@ -573,17 +604,17 @@ sealed class ActionData : Comparable { @Serializable sealed class Nfc : ActionData() { @Serializable - object Enable : Nfc() { + data object Enable : Nfc() { override val id = ActionId.ENABLE_NFC } @Serializable - object Disable : Nfc() { + data object Disable : Nfc() { override val id = ActionId.DISABLE_NFC } @Serializable - object Toggle : Nfc() { + data object Toggle : Nfc() { override val id = ActionId.TOGGLE_NFC } } @@ -591,17 +622,17 @@ sealed class ActionData : Comparable { @Serializable sealed class AirplaneMode : ActionData() { @Serializable - object Enable : AirplaneMode() { + data object Enable : AirplaneMode() { override val id = ActionId.ENABLE_AIRPLANE_MODE } @Serializable - object Disable : AirplaneMode() { + data object Disable : AirplaneMode() { override val id = ActionId.DISABLE_AIRPLANE_MODE } @Serializable - object Toggle : AirplaneMode() { + data object Toggle : AirplaneMode() { override val id = ActionId.TOGGLE_AIRPLANE_MODE } } @@ -609,17 +640,17 @@ sealed class ActionData : Comparable { @Serializable sealed class MobileData : ActionData() { @Serializable - object Enable : MobileData() { + data object Enable : MobileData() { override val id = ActionId.ENABLE_MOBILE_DATA } @Serializable - object Disable : MobileData() { + data object Disable : MobileData() { override val id = ActionId.DISABLE_MOBILE_DATA } @Serializable - object Toggle : MobileData() { + data object Toggle : MobileData() { override val id = ActionId.TOGGLE_MOBILE_DATA } } @@ -627,27 +658,27 @@ sealed class ActionData : Comparable { @Serializable sealed class Brightness : ActionData() { @Serializable - object EnableAuto : Brightness() { + data object EnableAuto : Brightness() { override val id = ActionId.ENABLE_AUTO_BRIGHTNESS } @Serializable - object DisableAuto : Brightness() { + data object DisableAuto : Brightness() { override val id = ActionId.DISABLE_AUTO_BRIGHTNESS } @Serializable - object ToggleAuto : Brightness() { + data object ToggleAuto : Brightness() { override val id = ActionId.TOGGLE_AUTO_BRIGHTNESS } @Serializable - object Increase : Brightness() { + data object Increase : Brightness() { override val id = ActionId.INCREASE_BRIGHTNESS } @Serializable - object Decrease : Brightness() { + data object Decrease : Brightness() { override val id = ActionId.DECREASE_BRIGHTNESS } } @@ -655,178 +686,178 @@ sealed class ActionData : Comparable { @Serializable sealed class StatusBar : ActionData() { @Serializable - object ExpandNotifications : StatusBar() { + data object ExpandNotifications : StatusBar() { override val id = ActionId.EXPAND_NOTIFICATION_DRAWER } @Serializable - object ToggleNotifications : StatusBar() { + data object ToggleNotifications : StatusBar() { override val id = ActionId.TOGGLE_NOTIFICATION_DRAWER } @Serializable - object ExpandQuickSettings : StatusBar() { + data object ExpandQuickSettings : StatusBar() { override val id = ActionId.EXPAND_QUICK_SETTINGS } @Serializable - object ToggleQuickSettings : StatusBar() { + data object ToggleQuickSettings : StatusBar() { override val id = ActionId.TOGGLE_QUICK_SETTINGS } @Serializable - object Collapse : StatusBar() { + data object Collapse : StatusBar() { override val id = ActionId.COLLAPSE_STATUS_BAR } } @Serializable - object GoBack : ActionData() { + data object GoBack : ActionData() { override val id = ActionId.GO_BACK } @Serializable - object GoHome : ActionData() { + data object GoHome : ActionData() { override val id = ActionId.GO_HOME } @Serializable - object OpenRecents : ActionData() { + data object OpenRecents : ActionData() { override val id = ActionId.OPEN_RECENTS } @Serializable - object GoLastApp : ActionData() { + data object GoLastApp : ActionData() { override val id = ActionId.GO_LAST_APP } @Serializable - object OpenMenu : ActionData() { + data object OpenMenu : ActionData() { override val id = ActionId.OPEN_MENU } @Serializable - object ToggleSplitScreen : ActionData() { + data object ToggleSplitScreen : ActionData() { override val id = ActionId.TOGGLE_SPLIT_SCREEN } @Serializable - object Screenshot : ActionData() { + data object Screenshot : ActionData() { override val id = ActionId.SCREENSHOT } @Serializable - object MoveCursorToEnd : ActionData() { + data object MoveCursorToEnd : ActionData() { override val id = ActionId.MOVE_CURSOR_TO_END } @Serializable - object ToggleKeyboard : ActionData() { + data object ToggleKeyboard : ActionData() { override val id = ActionId.TOGGLE_KEYBOARD } @Serializable - object ShowKeyboard : ActionData() { + data object ShowKeyboard : ActionData() { override val id = ActionId.SHOW_KEYBOARD } @Serializable - object HideKeyboard : ActionData() { + data object HideKeyboard : ActionData() { override val id = ActionId.HIDE_KEYBOARD } @Serializable - object ShowKeyboardPicker : ActionData() { + data object ShowKeyboardPicker : ActionData() { override val id = ActionId.SHOW_KEYBOARD_PICKER } @Serializable - object CopyText : ActionData() { + data object CopyText : ActionData() { override val id = ActionId.TEXT_COPY } @Serializable - object PasteText : ActionData() { + data object PasteText : ActionData() { override val id = ActionId.TEXT_PASTE } @Serializable - object CutText : ActionData() { + data object CutText : ActionData() { override val id = ActionId.TEXT_CUT } @Serializable - object SelectWordAtCursor : ActionData() { + data object SelectWordAtCursor : ActionData() { override val id = ActionId.SELECT_WORD_AT_CURSOR } @Serializable - object VoiceAssistant : ActionData() { + data object VoiceAssistant : ActionData() { override val id = ActionId.OPEN_VOICE_ASSISTANT } @Serializable - object DeviceAssistant : ActionData() { + data object DeviceAssistant : ActionData() { override val id = ActionId.OPEN_DEVICE_ASSISTANT } @Serializable - object OpenCamera : ActionData() { + data object OpenCamera : ActionData() { override val id = ActionId.OPEN_CAMERA } @Serializable - object LockDevice : ActionData() { + data object LockDevice : ActionData() { override val id = ActionId.LOCK_DEVICE } @Serializable - object ScreenOnOff : ActionData() { + data object ScreenOnOff : ActionData() { override val id = ActionId.POWER_ON_OFF_DEVICE } @Serializable - object SecureLock : ActionData() { + data object SecureLock : ActionData() { override val id = ActionId.SECURE_LOCK_DEVICE } @Serializable - object ConsumeKeyEvent : ActionData() { + data object ConsumeKeyEvent : ActionData() { override val id = ActionId.CONSUME_KEY_EVENT } @Serializable - object OpenSettings : ActionData() { + data object OpenSettings : ActionData() { override val id = ActionId.OPEN_SETTINGS } @Serializable - object ShowPowerMenu : ActionData() { + data object ShowPowerMenu : ActionData() { override val id = ActionId.SHOW_POWER_MENU } @Serializable - object DismissLastNotification : ActionData() { + data object DismissLastNotification : ActionData() { override val id: ActionId = ActionId.DISMISS_MOST_RECENT_NOTIFICATION } @Serializable - object DismissAllNotifications : ActionData() { + data object DismissAllNotifications : ActionData() { override val id: ActionId = ActionId.DISMISS_ALL_NOTIFICATIONS } @Serializable - object AnswerCall : ActionData() { + data object AnswerCall : ActionData() { override val id: ActionId = ActionId.ANSWER_PHONE_CALL } @Serializable - object EndCall : ActionData() { + data object EndCall : ActionData() { override val id: ActionId = ActionId.END_PHONE_CALL } @Serializable - object DeviceControls : ActionData() { + data object DeviceControls : ActionData() { override val id: ActionId = ActionId.DEVICE_CONTROLS } @@ -845,4 +876,24 @@ sealed class ActionData : Comparable { return "HttpRequest(description=$description)" } } + + @Serializable + data class InteractUiElement( + val description: String, + val nodeAction: NodeInteractionType, + val packageName: String, + val text: String?, + val tooltip: String?, + val hint: String?, + val contentDescription: String?, + val className: String?, + val viewResourceId: String?, + val uniqueId: String?, + /** + * A list of the allowed accessibility node actions. + */ + val nodeActions: Set, + ) : ActionData() { + override val id: ActionId = ActionId.INTERACT_UI_ELEMENT + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt index 53a6515254..ce246ca4cf 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionDataEntityMapper.kt @@ -1,7 +1,9 @@ package io.github.sds100.keymapper.actions import io.github.sds100.keymapper.actions.pinchscreen.PinchScreenType +import io.github.sds100.keymapper.actions.uielement.NodeInteractionType import io.github.sds100.keymapper.data.db.typeconverter.ConstantTypeConverters +import io.github.sds100.keymapper.data.db.typeconverter.NodeInteractionTypeSetTypeConverter import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.data.entities.EntityExtra import io.github.sds100.keymapper.data.entities.getData @@ -12,6 +14,9 @@ import io.github.sds100.keymapper.system.network.HttpMethod import io.github.sds100.keymapper.system.volume.DndMode import io.github.sds100.keymapper.system.volume.RingerMode import io.github.sds100.keymapper.system.volume.VolumeStream +import io.github.sds100.keymapper.util.Error +import io.github.sds100.keymapper.util.Result +import io.github.sds100.keymapper.util.Success import io.github.sds100.keymapper.util.getKey import io.github.sds100.keymapper.util.success import io.github.sds100.keymapper.util.then @@ -41,6 +46,8 @@ object ActionDataEntityMapper { ActionEntity.Type.SYSTEM_ACTION -> { SYSTEM_ACTION_ID_MAP.getKey(entity.data) ?: return null } + + ActionEntity.Type.INTERACT_UI_ELEMENT -> ActionId.INTERACT_UI_ELEMENT } return when (actionId) { @@ -349,6 +356,9 @@ object ActionDataEntityMapper { ActionId.PREVIOUS_TRACK_PACKAGE, ActionId.FAST_FORWARD_PACKAGE, ActionId.REWIND_PACKAGE, + ActionId.STOP_MEDIA_PACKAGE, + ActionId.STEP_FORWARD_PACKAGE, + ActionId.STEP_BACKWARD_PACKAGE, -> { val packageName = entity.extras.getData(ActionEntity.EXTRA_PACKAGE_NAME).valueOrNull() @@ -376,6 +386,15 @@ object ActionDataEntityMapper { ActionId.REWIND_PACKAGE -> ActionData.ControlMediaForApp.Rewind(packageName) + ActionId.STOP_MEDIA_PACKAGE -> + ActionData.ControlMediaForApp.Stop(packageName) + + ActionId.STEP_FORWARD_PACKAGE -> + ActionData.ControlMediaForApp.StepForward(packageName) + + ActionId.STEP_BACKWARD_PACKAGE -> + ActionData.ControlMediaForApp.StepBackward(packageName) + else -> throw Exception("don't know how to create system action for $actionId") } } @@ -454,6 +473,9 @@ object ActionDataEntityMapper { ActionId.PREVIOUS_TRACK -> ActionData.ControlMedia.PreviousTrack ActionId.FAST_FORWARD -> ActionData.ControlMedia.FastForward ActionId.REWIND -> ActionData.ControlMedia.Rewind + ActionId.STOP_MEDIA -> ActionData.ControlMedia.Stop + ActionId.STEP_FORWARD -> ActionData.ControlMedia.StepForward + ActionId.STEP_BACKWARD -> ActionData.ControlMedia.StepBackward ActionId.GO_BACK -> ActionData.GoBack ActionId.GO_HOME -> ActionData.GoHome @@ -522,9 +544,67 @@ object ActionDataEntityMapper { authorizationHeader = authorizationHeader, ) } + + ActionId.INTERACT_UI_ELEMENT -> { + val packageName = + entity.extras.getData(ActionEntity.EXTRA_ACCESSIBILITY_PACKAGE_NAME) + .valueOrNull()!! + + val contentDescription = + entity.extras.getData(ActionEntity.EXTRA_ACCESSIBILITY_CONTENT_DESCRIPTION) + .valueOrNull() + + val text = + entity.extras.getData(ActionEntity.EXTRA_ACCESSIBILITY_TEXT).valueOrNull() + + val tooltip = + entity.extras.getData(ActionEntity.EXTRA_ACCESSIBILITY_TOOLTIP).valueOrNull() + + val hint = + entity.extras.getData(ActionEntity.EXTRA_ACCESSIBILITY_HINT).valueOrNull() + + val className = + entity.extras.getData(ActionEntity.EXTRA_ACCESSIBILITY_CLASS_NAME).valueOrNull() + + val viewResourceId = + entity.extras.getData(ActionEntity.EXTRA_ACCESSIBILITY_VIEW_RESOURCE_ID) + .valueOrNull() + + val uniqueId = + entity.extras.getData(ActionEntity.EXTRA_ACCESSIBILITY_UNIQUE_ID).valueOrNull() + + val actions = entity.extras.getData(ActionEntity.EXTRA_ACCESSIBILITY_ACTIONS).then { + Success(NodeInteractionTypeSetTypeConverter().toSet(it.toInt())) + }.valueOrNull() ?: emptySet() + + val nodeAction = + entity.extras.getData(ActionEntity.EXTRA_ACCESSIBILITY_NODE_ACTION).then { + convertNodeInteractionType(it) + }.valueOrNull() ?: return null + + ActionData.InteractUiElement( + description = entity.data, + nodeAction = nodeAction, + packageName = packageName, + text = text, + contentDescription = contentDescription, + tooltip = tooltip, + hint = hint, + className = className, + viewResourceId = viewResourceId, + uniqueId = uniqueId, + nodeActions = actions, + ) + } } } + private fun convertNodeInteractionType(string: String): Result = try { + Success(NodeInteractionType.valueOf(string)) + } catch (e: IllegalArgumentException) { + Error.Exception(e) + } + fun toEntity(data: ActionData): ActionEntity { val type = when (data) { is ActionData.Intent -> ActionEntity.Type.INTENT @@ -538,6 +618,7 @@ object ActionDataEntityMapper { is ActionData.Text -> ActionEntity.Type.TEXT_BLOCK is ActionData.Url -> ActionEntity.Type.URL is ActionData.Sound -> ActionEntity.Type.SOUND + is ActionData.InteractUiElement -> ActionEntity.Type.INTERACT_UI_ELEMENT else -> ActionEntity.Type.SYSTEM_ACTION } @@ -579,6 +660,12 @@ object ActionDataEntityMapper { is ActionData.Text -> data.text is ActionData.Url -> data.url is ActionData.Sound -> data.soundUid + is ActionData.InteractUiElement -> data.description + is ActionData.ControlMediaForApp.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.ControlMediaForApp.Stop -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.ControlMedia.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.ControlMedia.Stop -> SYSTEM_ACTION_ID_MAP[data.id]!! + is ActionData.GoBack -> SYSTEM_ACTION_ID_MAP[data.id]!! else -> SYSTEM_ACTION_ID_MAP[data.id]!! } @@ -750,6 +837,73 @@ object ActionDataEntityMapper { ), ) + is ActionData.InteractUiElement -> buildList { + add( + EntityExtra( + ActionEntity.EXTRA_ACCESSIBILITY_NODE_ACTION, + data.nodeAction.toString(), + ), + ) + + add( + EntityExtra( + ActionEntity.EXTRA_ACCESSIBILITY_PACKAGE_NAME, + data.packageName, + ), + ) + + data.contentDescription?.let { + add( + EntityExtra( + ActionEntity.EXTRA_ACCESSIBILITY_CONTENT_DESCRIPTION, + it, + ), + ) + } + + data.text?.let { add(EntityExtra(ActionEntity.EXTRA_ACCESSIBILITY_TEXT, it)) } + + data.tooltip?.let { add(EntityExtra(ActionEntity.EXTRA_ACCESSIBILITY_TOOLTIP, it)) } + + data.hint?.let { add(EntityExtra(ActionEntity.EXTRA_ACCESSIBILITY_HINT, it)) } + + data.className?.let { + add( + EntityExtra( + ActionEntity.EXTRA_ACCESSIBILITY_CLASS_NAME, + it, + ), + ) + } + + data.viewResourceId?.let { + add( + EntityExtra( + ActionEntity.EXTRA_ACCESSIBILITY_VIEW_RESOURCE_ID, + it, + ), + ) + } + + data.uniqueId?.let { + add( + EntityExtra( + ActionEntity.EXTRA_ACCESSIBILITY_UNIQUE_ID, + it, + ), + ) + } + + if (data.nodeActions.isNotEmpty()) { + add( + EntityExtra( + ActionEntity.EXTRA_ACCESSIBILITY_ACTIONS, + NodeInteractionTypeSetTypeConverter().toMask(data.nodeActions).toString(), + ), + ) + } + } + else -> emptyList() } @@ -860,6 +1014,12 @@ object ActionDataEntityMapper { ActionId.FAST_FORWARD_PACKAGE to "fast_forward_package", ActionId.REWIND to "rewind", ActionId.REWIND_PACKAGE to "rewind_package", + ActionId.STOP_MEDIA to "stop_media", + ActionId.STOP_MEDIA_PACKAGE to "stop_media_package", + ActionId.STEP_FORWARD to "step_forward", + ActionId.STEP_FORWARD_PACKAGE to "step_forward_package", + ActionId.STEP_BACKWARD to "step_backward", + ActionId.STEP_BACKWARD_PACKAGE to "step_backward_package", ActionId.GO_BACK to "go_back", ActionId.GO_HOME to "go_home", diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionId.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionId.kt index 515474d744..5b93ee03a7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionId.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionId.kt @@ -16,6 +16,7 @@ enum class ActionId { INTENT, PHONE_CALL, SOUND, + INTERACT_UI_ELEMENT, TOGGLE_WIFI, ENABLE_WIFI, @@ -78,6 +79,12 @@ enum class ActionId { FAST_FORWARD_PACKAGE, REWIND, REWIND_PACKAGE, + STOP_MEDIA, + STOP_MEDIA_PACKAGE, + STEP_FORWARD, + STEP_FORWARD_PACKAGE, + STEP_BACKWARD, + STEP_BACKWARD_PACKAGE, GO_BACK, GO_HOME, diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionListItem.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionListItem.kt index 0ce11b41af..92276cfeb8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionListItem.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionListItem.kt @@ -44,10 +44,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.accompanist.drawablepainter.rememberDrawablePainter import io.github.sds100.keymapper.R -import io.github.sds100.keymapper.compose.draggable.DragDropState import io.github.sds100.keymapper.util.drawable import io.github.sds100.keymapper.util.ui.LinkType import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.util.ui.compose.DragDropState @Composable fun ActionListItem( diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt index 755353b04d..08c7b3c5c9 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUiHelper.kt @@ -222,6 +222,9 @@ class ActionUiHelper( 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) @@ -235,6 +238,9 @@ class ActionUiHelper( 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) @@ -356,7 +362,7 @@ class ActionUiHelper( } else { getString( R.string.description_tap_coordinate_with_description, - arrayOf(action.x, action.y, action.description), + action.description, ) } @@ -453,6 +459,9 @@ class ActionUiHelper( ActionData.ControlMedia.PlayPause -> getString(R.string.action_play_pause_media) ActionData.ControlMedia.PreviousTrack -> getString(R.string.action_previous_track) ActionData.ControlMedia.Rewind -> getString(R.string.action_rewind) + ActionData.ControlMedia.Stop -> getString(R.string.action_stop_media) + ActionData.ControlMedia.StepForward -> getString(R.string.action_step_forward_media) + ActionData.ControlMedia.StepBackward -> getString(R.string.action_step_backward_media) ActionData.CopyText -> getString(R.string.action_text_copy) ActionData.CutText -> getString(R.string.action_text_cut) @@ -529,6 +538,8 @@ class ActionUiHelper( ActionData.DeviceControls -> getString(R.string.action_device_controls) is ActionData.HttpRequest -> action.description + + is ActionData.InteractUiElement -> action.description } fun getIcon(action: ActionData): ComposeIconInfo = when (action) { diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt index e3b696fc8c..7cff7aeb8d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionUtils.kt @@ -30,6 +30,7 @@ import androidx.compose.material.icons.outlined.FastForward import androidx.compose.material.icons.outlined.FastRewind import androidx.compose.material.icons.outlined.FlashlightOff import androidx.compose.material.icons.outlined.FlashlightOn +import androidx.compose.material.icons.outlined.Forward30 import androidx.compose.material.icons.outlined.Fullscreen import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Http @@ -45,6 +46,7 @@ import androidx.compose.material.icons.outlined.PhonelinkRing import androidx.compose.material.icons.outlined.Pinch import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.PowerSettingsNew +import androidx.compose.material.icons.outlined.Replay30 import androidx.compose.material.icons.outlined.ScreenLockRotation import androidx.compose.material.icons.outlined.ScreenRotation import androidx.compose.material.icons.outlined.Settings @@ -55,6 +57,7 @@ import androidx.compose.material.icons.outlined.SkipPrevious import androidx.compose.material.icons.outlined.Splitscreen import androidx.compose.material.icons.outlined.StayCurrentLandscape 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.ViewArray @@ -73,6 +76,7 @@ import io.github.sds100.keymapper.R import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.util.ui.compose.icons.HomeIotDevice import io.github.sds100.keymapper.util.ui.compose.icons.InstantMix +import io.github.sds100.keymapper.util.ui.compose.icons.JumpToElement import io.github.sds100.keymapper.util.ui.compose.icons.KeyMapperIcons import io.github.sds100.keymapper.util.ui.compose.icons.MatchWord import io.github.sds100.keymapper.util.ui.compose.icons.NfcOff @@ -181,6 +185,12 @@ object ActionUtils { ActionId.FAST_FORWARD_PACKAGE -> ActionCategory.MEDIA ActionId.REWIND -> ActionCategory.MEDIA ActionId.REWIND_PACKAGE -> ActionCategory.MEDIA + ActionId.STOP_MEDIA -> ActionCategory.MEDIA + ActionId.STOP_MEDIA_PACKAGE -> ActionCategory.MEDIA + ActionId.STEP_FORWARD -> ActionCategory.MEDIA + ActionId.STEP_FORWARD_PACKAGE -> ActionCategory.MEDIA + ActionId.STEP_BACKWARD -> ActionCategory.MEDIA + ActionId.STEP_BACKWARD_PACKAGE -> ActionCategory.MEDIA ActionId.GO_BACK -> ActionCategory.NAVIGATION ActionId.GO_HOME -> ActionCategory.NAVIGATION @@ -230,6 +240,8 @@ object ActionUtils { ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> ActionCategory.NOTIFICATIONS ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionCategory.NOTIFICATIONS ActionId.DEVICE_CONTROLS -> ActionCategory.APPS + + ActionId.INTERACT_UI_ELEMENT -> ActionCategory.APPS } @StringRes @@ -288,6 +300,12 @@ object ActionUtils { ActionId.FAST_FORWARD_PACKAGE -> R.string.action_fast_forward_package ActionId.REWIND -> R.string.action_rewind ActionId.REWIND_PACKAGE -> R.string.action_rewind_package + ActionId.STOP_MEDIA -> R.string.action_stop_media + ActionId.STOP_MEDIA_PACKAGE -> R.string.action_stop_media_package + ActionId.STEP_FORWARD -> R.string.action_step_forward_media + ActionId.STEP_FORWARD_PACKAGE -> R.string.action_step_forward_media_package + ActionId.STEP_BACKWARD -> R.string.action_step_backward_media + ActionId.STEP_BACKWARD_PACKAGE -> R.string.action_step_backward_media_package ActionId.GO_BACK -> R.string.action_go_back ActionId.GO_HOME -> R.string.action_go_home ActionId.OPEN_RECENTS -> R.string.action_open_recents @@ -342,6 +360,7 @@ object ActionUtils { ActionId.END_PHONE_CALL -> R.string.action_end_call ActionId.DEVICE_CONTROLS -> R.string.action_device_controls ActionId.HTTP_REQUEST -> R.string.action_http_request + ActionId.INTERACT_UI_ELEMENT -> R.string.action_interact_ui_element_title } @DrawableRes @@ -400,6 +419,12 @@ object ActionUtils { ActionId.FAST_FORWARD_PACKAGE -> R.drawable.ic_outline_fast_forward_24 ActionId.REWIND -> R.drawable.ic_outline_fast_rewind_24 ActionId.REWIND_PACKAGE -> R.drawable.ic_outline_fast_rewind_24 + ActionId.STOP_MEDIA -> R.drawable.ic_outline_pause_24 + ActionId.STOP_MEDIA_PACKAGE -> R.drawable.ic_outline_pause_24 + ActionId.STEP_FORWARD -> null + ActionId.STEP_FORWARD_PACKAGE -> null + ActionId.STEP_BACKWARD -> null + ActionId.STEP_BACKWARD_PACKAGE -> null ActionId.GO_BACK -> R.drawable.ic_baseline_arrow_back_24 ActionId.GO_HOME -> R.drawable.ic_outline_home_24 ActionId.OPEN_RECENTS -> null @@ -454,6 +479,7 @@ object ActionUtils { ActionId.END_PHONE_CALL -> R.drawable.ic_outline_call_end_24 ActionId.DEVICE_CONTROLS -> R.drawable.ic_home_automation ActionId.HTTP_REQUEST -> null + ActionId.INTERACT_UI_ELEMENT -> null } fun getMinApi(id: ActionId): Int = when (id) { @@ -716,6 +742,12 @@ object ActionUtils { ActionId.FAST_FORWARD_PACKAGE -> Icons.Outlined.FastForward ActionId.REWIND -> Icons.Outlined.FastRewind ActionId.REWIND_PACKAGE -> Icons.Outlined.FastRewind + ActionId.STOP_MEDIA -> Icons.Outlined.StopCircle + ActionId.STOP_MEDIA_PACKAGE -> Icons.Outlined.StopCircle + ActionId.STEP_FORWARD -> Icons.Outlined.Forward30 + ActionId.STEP_FORWARD_PACKAGE -> Icons.Outlined.Forward30 + ActionId.STEP_BACKWARD -> Icons.Outlined.Replay30 + ActionId.STEP_BACKWARD_PACKAGE -> Icons.Outlined.Replay30 ActionId.GO_BACK -> Icons.AutoMirrored.Outlined.ArrowBack ActionId.GO_HOME -> Icons.Outlined.Home ActionId.OPEN_RECENTS -> Icons.Outlined.ViewArray @@ -770,6 +802,7 @@ object ActionUtils { ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice ActionId.HTTP_REQUEST -> Icons.Outlined.Http + ActionId.INTERACT_UI_ELEMENT -> KeyMapperIcons.JumpToElement } } @@ -821,6 +854,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.Url, is ActionData.PhoneCall, is ActionData.HttpRequest, + is ActionData.InteractUiElement, -> true else -> false diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt index 003235c51e..6e4126b876 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ActionsScreen.kt @@ -39,14 +39,14 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.sds100.keymapper.R import io.github.sds100.keymapper.compose.KeyMapperTheme -import io.github.sds100.keymapper.compose.draggable.DraggableItem -import io.github.sds100.keymapper.compose.draggable.rememberDragDropState import io.github.sds100.keymapper.mappings.keymaps.ShortcutModel import io.github.sds100.keymapper.mappings.keymaps.ShortcutRow import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.ui.LinkType import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.util.ui.compose.DraggableItem +import io.github.sds100.keymapper.util.ui.compose.rememberDragDropState import kotlinx.coroutines.flow.update @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt b/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt index 9001bf183b..34cc3a8616 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/ChooseActionScreen.kt @@ -15,27 +15,18 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Android import androidx.compose.material.icons.rounded.Bluetooth -import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.BottomAppBar import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DockedSearchBar import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection @@ -51,7 +42,8 @@ import io.github.sds100.keymapper.R import io.github.sds100.keymapper.compose.KeyMapperTheme import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo -import io.github.sds100.keymapper.util.ui.compose.SimpleListItem +import io.github.sds100.keymapper.util.ui.compose.SearchAppBarActions +import io.github.sds100.keymapper.util.ui.compose.SimpleListItemFixedHeight import io.github.sds100.keymapper.util.ui.compose.SimpleListItemGroup import io.github.sds100.keymapper.util.ui.compose.SimpleListItemHeader import io.github.sds100.keymapper.util.ui.compose.SimpleListItemModel @@ -92,69 +84,18 @@ private fun ChooseActionScreen( onClickAction: (String) -> Unit = {}, onNavigateBack: () -> Unit = {}, ) { - var isExpanded: Boolean by rememberSaveable { mutableStateOf(false) } - Scaffold( modifier = modifier.displayCutoutPadding(), bottomBar = { BottomAppBar( modifier = Modifier.imePadding(), actions = { - IconButton(onClick = { - if (isExpanded) { - onCloseSearch() - isExpanded = false - } else { - onNavigateBack() - } - }) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.bottom_app_bar_back_content_description), - ) - } - - DockedSearchBar( - modifier = Modifier.align(Alignment.CenterVertically), - inputField = { - SearchBarDefaults.InputField( - modifier = Modifier.align(Alignment.CenterVertically), - onSearch = { - onQueryChange(it) - isExpanded = false - }, - leadingIcon = { - Icon( - Icons.Rounded.Search, - contentDescription = null, - ) - }, - enabled = state is State.Data, - placeholder = { Text(stringResource(R.string.search_placeholder)) }, - query = query ?: "", - onQueryChange = onQueryChange, - expanded = isExpanded, - onExpandedChange = { expanded -> - if (expanded) { - isExpanded = true - } else { - onCloseSearch() - isExpanded = false - } - }, - ) - }, - // This is false to prevent an empty "content" showing underneath. - expanded = isExpanded, - onExpandedChange = { expanded -> - if (expanded) { - isExpanded = true - } else { - onCloseSearch() - isExpanded = false - } - }, - content = {}, + SearchAppBarActions( + onCloseSearch = onCloseSearch, + onNavigateBack = onNavigateBack, + onQueryChange = onQueryChange, + enabled = state is State.Data, + query = query, ) }, ) @@ -260,7 +201,7 @@ private fun ListScreen( group.items, contentType = { "list_item" }, ) { model -> - SimpleListItem( + SimpleListItemFixedHeight( modifier = Modifier.fillMaxWidth(), model = model, onClick = { onClickAction(model.id) }, @@ -363,6 +304,15 @@ private fun PreviewGrid() { isEnabled = false, ), + SimpleListItemModel( + "long", + title = "Very very very very very very very long title", + icon = ComposeIconInfo.Vector(Icons.Rounded.Bluetooth), + subtitle = null, + isSubtitleError = true, + isEnabled = false, + ), + ), ), diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionDelegate.kt b/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionDelegate.kt index 7d34983a0d..0e1a52efe1 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionDelegate.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/CreateActionDelegate.kt @@ -181,6 +181,9 @@ class CreateActionDelegate( ActionId.PREVIOUS_TRACK_PACKAGE, ActionId.FAST_FORWARD_PACKAGE, ActionId.REWIND_PACKAGE, + ActionId.STOP_MEDIA_PACKAGE, + ActionId.STEP_FORWARD_PACKAGE, + ActionId.STEP_BACKWARD_PACKAGE, -> { val packageName = navigate( @@ -211,6 +214,15 @@ class CreateActionDelegate( ActionId.REWIND_PACKAGE -> ActionData.ControlMediaForApp.Rewind(packageName) + ActionId.STOP_MEDIA_PACKAGE -> + ActionData.ControlMediaForApp.Stop(packageName) + + ActionId.STEP_FORWARD_PACKAGE -> + ActionData.ControlMediaForApp.StepForward(packageName) + + ActionId.STEP_BACKWARD_PACKAGE -> + ActionData.ControlMediaForApp.StepBackward(packageName) + else -> throw Exception("don't know how to create action for $actionId") } @@ -729,6 +741,9 @@ class CreateActionDelegate( ActionId.PREVIOUS_TRACK -> return ActionData.ControlMedia.PreviousTrack ActionId.FAST_FORWARD -> return ActionData.ControlMedia.FastForward ActionId.REWIND -> return ActionData.ControlMedia.Rewind + ActionId.STOP_MEDIA -> return ActionData.ControlMedia.Stop + ActionId.STEP_FORWARD -> return ActionData.ControlMedia.StepForward + ActionId.STEP_BACKWARD -> return ActionData.ControlMedia.StepBackward ActionId.GO_BACK -> return ActionData.GoBack ActionId.GO_HOME -> return ActionData.GoHome @@ -786,6 +801,15 @@ class CreateActionDelegate( } return null } + + ActionId.INTERACT_UI_ELEMENT -> { + val oldAction = oldData as? ActionData.InteractUiElement + + return navigate( + "config_interact_ui_element_action", + NavDestination.InteractUiElement(oldAction), + ) + } } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/HttpRequestBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/actions/HttpRequestBottomSheet.kt index 25725b2a41..15d0063245 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/HttpRequestBottomSheet.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/HttpRequestBottomSheet.kt @@ -48,7 +48,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @OptIn(ExperimentalMaterial3Api::class) @Composable fun HttpRequestBottomSheet(delegate: CreateActionDelegate) { - val scope = rememberCoroutineScope() + rememberCoroutineScope() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) if (delegate.httpRequestBottomSheetState != null) { @@ -139,10 +139,10 @@ private fun HttpRequestBottomSheet( .padding(horizontal = 16.dp), expanded = methodExpanded, onExpandedChange = { methodExpanded = it }, - value = state.method.toString(), - onValueChanged = { - onSelectMethod(HttpMethod.valueOf(it)) - }, + label = { Text(stringResource(R.string.action_http_request_method_label)) }, + selectedValue = state.method, + values = HttpMethod.entries.map { it to it.toString() }, + onValueChanged = onSelectMethod, ) diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt index 9734435728..a3661b0721 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/PerformActionsUseCase.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.actions import android.accessibilityservice.AccessibilityService import android.os.Build +import android.view.InputDevice import android.view.KeyEvent import android.view.accessibility.AccessibilityNodeInfo import io.github.sds100.keymapper.R @@ -10,6 +11,7 @@ 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.AccessibilityNodeAction +import io.github.sds100.keymapper.system.accessibility.AccessibilityNodeModel import io.github.sds100.keymapper.system.accessibility.IAccessibilityService import io.github.sds100.keymapper.system.accessibility.ServiceAdapter import io.github.sds100.keymapper.system.airplanemode.AirplaneModeAdapter @@ -65,6 +67,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import splitties.bitflags.withFlag @@ -150,11 +153,19 @@ class PerformActionsUseCaseImpl( is ActionData.InputKeyEvent -> { val deviceId: Int = getDeviceIdForKeyEventAction(action) + // 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 + else -> InputDevice.SOURCE_KEYBOARD + } + val model = InputKeyModel( keyCode = action.keyCode, inputType = inputEventType, metaState = keyMetaState.withFlag(action.metaState), deviceId = deviceId, + source = source, ) result = when { @@ -221,6 +232,18 @@ class PerformActionsUseCaseImpl( result = mediaAdapter.rewind(action.packageName) } + is ActionData.ControlMediaForApp.Stop -> { + result = mediaAdapter.stop(action.packageName) + } + + is ActionData.ControlMediaForApp.StepForward -> { + result = mediaAdapter.stepForward(action.packageName) + } + + is ActionData.ControlMediaForApp.StepBackward -> { + result = mediaAdapter.stepBackward(action.packageName) + } + is ActionData.Rotation.CycleRotations -> { result = displayAdapter.disableAutoRotate().then { val currentOrientation = displayAdapter.cachedOrientation @@ -338,7 +361,7 @@ class PerformActionsUseCaseImpl( is ActionData.Sound -> { result = soundsManager.getSound(action.soundUid).then { file -> - mediaAdapter.playSoundFile(file.uri, VolumeStream.ACCESSIBILITY) + mediaAdapter.playFile(file.uri, VolumeStream.ACCESSIBILITY) } } @@ -545,6 +568,18 @@ class PerformActionsUseCaseImpl( result = mediaAdapter.rewind() } + is ActionData.ControlMedia.Stop -> { + result = mediaAdapter.stop() + } + + is ActionData.ControlMedia.StepForward -> { + result = mediaAdapter.stepForward() + } + + is ActionData.ControlMedia.StepBackward -> { + result = mediaAdapter.stepBackward() + } + is ActionData.GoBack -> { result = accessibilityService.doGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) @@ -799,6 +834,19 @@ class PerformActionsUseCaseImpl( authorizationHeader = action.authorizationHeader, ) } + + is ActionData.InteractUiElement -> { + if (accessibilityService.activeWindowPackage.first() != action.packageName) { + result = Error.UiElementNotFound + } else { + result = accessibilityService.performActionOnNode( + findNode = { node -> + matchAccessibilityNode(node, action) + }, + performAction = { AccessibilityNodeAction(action = action.nodeAction.accessibilityActionId) }, + ).otherwise { Error.UiElementNotFound } + } + } } when (result) { @@ -891,6 +939,49 @@ class PerformActionsUseCaseImpl( popupMessageAdapter.showPopupMessage(it.getFullMessage(resourceProvider)) } } + + private fun matchAccessibilityNode( + node: AccessibilityNodeModel, + action: ActionData.InteractUiElement, + ): Boolean { + if (!node.actions.contains(action.nodeAction.accessibilityActionId)) { + return false + } + + if (compareIfNonNull(node.uniqueId, action.uniqueId)) { + return true + } + + if (action.contentDescription == null && action.text == null) { + if (compareIfNonNull(node.viewResourceId, action.viewResourceId)) { + return true + } + + if (compareIfNonNull(node.className, action.className)) { + return true + } + } else { + if (compareIfNonNull(node.contentDescription, action.contentDescription) || + compareIfNonNull(node.text, action.text) + ) { + if (action.viewResourceId != null) { + return node.viewResourceId == action.viewResourceId + } + + if (action.className != null) { + return node.className == action.className + } + + return true + } + } + + return false + } + + private fun compareIfNonNull(a: T?, b: T?): Boolean { + return a != null && b != null && a == b + } } interface PerformActionsUseCase { diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/keyevent/ConfigKeyEventActionFragment.kt b/app/src/main/java/io/github/sds100/keymapper/actions/keyevent/ConfigKeyEventActionFragment.kt index 6f90c1ceaa..1f6dfcf81d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/keyevent/ConfigKeyEventActionFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/keyevent/ConfigKeyEventActionFragment.kt @@ -51,6 +51,14 @@ class ConfigKeyEventActionFragment : Fragment() { val binding: FragmentConfigKeyEventBinding get() = _binding!! + private val deviceArrayAdapter: ArrayAdapter by lazy { + ArrayAdapter( + requireContext(), + R.layout.dropdown_menu_popup_item, + mutableListOf(), + ) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -98,47 +106,9 @@ class ConfigKeyEventActionFragment : Fragment() { findNavController().navigateUp() } - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.returnResult.collectLatest { - setFragmentResult( - requestKey, - Bundle().apply { putJsonSerializable(EXTRA_RESULT, it) }, - ) - - findNavController().navigateUp() - } - } - - viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.uiState.collectLatest { state -> - binding.epoxyRecyclerViewModifiers.withModels { - state.modifierListItems.forEach { listItem -> - configuredCheckBox(listItem) { isChecked -> - viewModel.setModifierKeyChecked(listItem.id.toInt(), isChecked) - } - } - } - - ArrayAdapter( - requireContext(), - R.layout.dropdown_menu_popup_item, - mutableListOf(), - ).apply { - clear() - add(str(R.string.from_no_device)) - - state.deviceListItems.forEach { - add(it.name) - } - - binding.dropdownDeviceId.setAdapter(this) - } - - binding.textInputLayoutKeyCode.error = state.keyCodeErrorMessage - } - } - binding.dropdownDeviceId.apply { + setAdapter(deviceArrayAdapter) + // set the default value setText(str(R.string.from_no_device), false) @@ -173,12 +143,44 @@ class ConfigKeyEventActionFragment : Fragment() { } } } - } - override fun onResume() { - super.onResume() + viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.returnResult.collectLatest { + setFragmentResult( + requestKey, + Bundle().apply { putJsonSerializable(EXTRA_RESULT, it) }, + ) + + findNavController().navigateUp() + } + } - viewModel.rebuildUiState() + viewLifecycleOwner.launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.uiState.collect { state -> + binding.epoxyRecyclerViewModifiers.withModels { + state.modifierListItems.forEach { listItem -> + configuredCheckBox(listItem) { isChecked -> + viewModel.setModifierKeyChecked(listItem.id.toInt(), isChecked) + } + } + } + + deviceArrayAdapter.apply { + clear() + add(str(R.string.from_no_device)) + for (device in state.deviceListItems) { + add(device.name) + } + notifyDataSetChanged() + } + + // Filtering must be false so that the dropdown items aren't cleared + // when setting text. + binding.dropdownDeviceId.setText(state.chosenDeviceName, false) + + binding.textInputLayoutKeyCode.error = state.keyCodeErrorMessage + } + } } override fun onDestroyView() { diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/keyevent/ConfigKeyEventActionViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/actions/keyevent/ConfigKeyEventActionViewModel.kt index e6734d0921..30d1236f18 100644 --- a/app/src/main/java/io/github/sds100/keymapper/actions/keyevent/ConfigKeyEventActionViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/actions/keyevent/ConfigKeyEventActionViewModel.kt @@ -27,13 +27,13 @@ import io.github.sds100.keymapper.util.ui.navigate import io.github.sds100.keymapper.util.valueOrNull import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import splitties.bitflags.hasFlag import splitties.bitflags.minusFlag import splitties.bitflags.withFlag @@ -51,35 +51,25 @@ class ConfigKeyEventActionViewModel( private val keyEventState = MutableStateFlow(KeyEventState()) - private val _uiState = MutableStateFlow( + val uiState: StateFlow = combine( + keyEventState, + useCase.inputDevices, + useCase.showDeviceDescriptors, + ) { state, inputDevices, showDeviceDescriptors -> + buildUiState(state, inputDevices, showDeviceDescriptors) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, buildUiState( keyEventState.value, inputDeviceList = emptyList(), showDeviceDescriptors = false, ), ) - val uiState = _uiState.asStateFlow() private val _returnResult = MutableSharedFlow() val returnResult = _returnResult.asSharedFlow() - private val rebuildUiState = MutableSharedFlow() - - init { - viewModelScope.launch { - - combine( - keyEventState, - useCase.inputDevices, - useCase.showDeviceDescriptors, - ) { state, inputDevices, showDeviceDescriptors -> - buildUiState(state, inputDevices, showDeviceDescriptors) - }.collectLatest { - _uiState.value = it - } - } - } - fun setModifierKeyChecked(modifier: Int, isChecked: Boolean) { val oldMetaState = keyEventState.value.metaState @@ -143,17 +133,15 @@ class ConfigKeyEventActionViewModel( } fun chooseDevice(index: Int) { - viewModelScope.launch { - val chosenDevice = uiState.value.deviceListItems.getOrNull(index) - - if (chosenDevice == null) { - return@launch - } + val chosenDevice = uiState.value.deviceListItems.getOrNull(index) - keyEventState.value = keyEventState.value.copy( - chosenDevice = chosenDevice, - ) + if (chosenDevice == null) { + return } + + keyEventState.value = keyEventState.value.copy( + chosenDevice = chosenDevice, + ) } fun onDoneClick() { @@ -178,14 +166,6 @@ class ConfigKeyEventActionViewModel( } } - fun refreshDevices() { - rebuildUiState() - } - - fun rebuildUiState() { - runBlocking { rebuildUiState.emit(Unit) } - } - private fun buildUiState( state: KeyEventState, inputDeviceList: List, diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/uielement/ChooseUiElementScreen.kt b/app/src/main/java/io/github/sds100/keymapper/actions/uielement/ChooseUiElementScreen.kt new file mode 100644 index 0000000000..340f304358 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/actions/uielement/ChooseUiElementScreen.kt @@ -0,0 +1,490 @@ +package io.github.sds100.keymapper.actions.uielement + +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.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.imePadding +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +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.adaptive.currentWindowAdaptiveInfo +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Devices +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.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.util.State +import io.github.sds100.keymapper.util.ui.compose.CheckBoxText +import io.github.sds100.keymapper.util.ui.compose.KeyMapperDropdownMenu +import io.github.sds100.keymapper.util.ui.compose.SearchAppBarActions +import io.github.sds100.keymapper.util.ui.compose.WindowSizeClassExt.compareTo + +@Composable +fun ChooseElementScreen( + modifier: Modifier = Modifier, + state: State, + query: String?, + onCloseSearch: () -> Unit = {}, + onNavigateBack: () -> Unit = {}, + onQueryChange: (String) -> Unit = {}, + onClickElement: (Long) -> Unit = {}, + onSelectInteractionType: (NodeInteractionType?) -> Unit = {}, + onAdditionalElementsCheckedChange: (Boolean) -> Unit = {}, +) { + val windowAdaptiveInfo = currentWindowAdaptiveInfo() + val widthSizeClass = windowAdaptiveInfo.windowSizeClass.windowWidthSizeClass + val heightSizeClass = windowAdaptiveInfo.windowSizeClass.windowHeightSizeClass + + Scaffold( + modifier.displayCutoutPadding(), + bottomBar = { + BottomAppBar( + modifier = Modifier.imePadding(), + actions = { + SearchAppBarActions( + onCloseSearch = onCloseSearch, + onNavigateBack = onNavigateBack, + onQueryChange = onQueryChange, + enabled = state is State.Data, + query = query, + ) + }, + ) + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + Column { + Text( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 8.dp, + ), + text = stringResource(R.string.action_interact_ui_element_choose_element_title), + style = MaterialTheme.typography.titleLarge, + ) + + if (heightSizeClass == WindowHeightSizeClass.COMPACT || widthSizeClass >= WindowWidthSizeClass.EXPANDED) { + Row { + InfoSection( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(1f), + state = state, + onSelectInteractionType = onSelectInteractionType, + onAdditionalElementsCheckedChange = onAdditionalElementsCheckedChange, + ) + + ListSection( + modifier = Modifier.weight(1f), + state = state, + onClickElement = onClickElement, + ) + } + } else { + InfoSection( + state = state, + onSelectInteractionType = onSelectInteractionType, + onAdditionalElementsCheckedChange = onAdditionalElementsCheckedChange, + ) + + ListSection( + modifier = Modifier.fillMaxSize(), + state = state, + onClickElement = onClickElement, + ) + } + } + } + } +} + +@Composable +private fun InfoSection( + modifier: Modifier = Modifier, + state: State, + onSelectInteractionType: (NodeInteractionType?) -> Unit, + onAdditionalElementsCheckedChange: (Boolean) -> Unit, +) { + Column(modifier = modifier) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(R.string.action_interact_ui_element_choose_element_text), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.ErrorOutline, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.action_interact_ui_element_choose_element_not_found_subtitle), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.titleSmall, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(R.string.action_interact_ui_element_choose_element_not_found_text), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (state is State.Data) { + var interactionTypeExpanded by rememberSaveable { mutableStateOf(false) } + + CheckBoxText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + text = stringResource(R.string.action_interact_ui_element_checkbox_additional_elements), + isChecked = state.data.showAdditionalElements, + onCheckedChange = onAdditionalElementsCheckedChange, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + KeyMapperDropdownMenu( + modifier = Modifier.padding(horizontal = 16.dp), + expanded = interactionTypeExpanded, + onExpandedChange = { interactionTypeExpanded = it }, + label = { Text(stringResource(R.string.action_interact_ui_element_filter_interaction_type_dropdown)) }, + values = state.data.interactionTypes, + selectedValue = state.data.selectedInteractionType, + onValueChanged = onSelectInteractionType, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +private fun ListSection( + modifier: Modifier = Modifier, + state: State, + onClickElement: (Long) -> Unit, +) { + when (state) { + State.Loading -> LoadingList(modifier = modifier.fillMaxSize()) + is State.Data -> { + val listItems = state.data.listItems + + Column(modifier = modifier) { + if (listItems.isEmpty()) { + EmptyList( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + ) + } else { + LoadedList( + modifier = Modifier.fillMaxSize(), + listItems = listItems, + onClick = onClickElement, + ) + } + } + } + } +} + +@Composable +private fun LoadingList(modifier: Modifier = Modifier) { + Box(modifier) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } +} + +@Composable +private fun EmptyList(modifier: Modifier = Modifier) { + Box(modifier) { + val shrug = stringResource(R.string.shrug) + val text = stringResource(R.string.ui_element_list_empty) + Text( + modifier = Modifier.align(Alignment.Center), + text = buildAnnotatedString { + withStyle(MaterialTheme.typography.headlineLarge.toSpanStyle()) { + append(shrug) + } + appendLine() + appendLine() + withStyle(MaterialTheme.typography.bodyLarge.toSpanStyle()) { + append(text) + } + }, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun LoadedList( + modifier: Modifier = Modifier, + listItems: List, + onClick: (Long) -> Unit, +) { + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(listItems, key = { it.id }) { model -> + UiElementListItem( + modifier = Modifier.fillMaxWidth(), + model = model, + onClick = { onClick(model.id) }, + ) + } + } +} + +@Composable +private fun UiElementListItem( + modifier: Modifier = Modifier, + model: UiElementListItemModel, + onClick: () -> Unit, +) { + OutlinedCard(modifier = modifier, onClick = onClick) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (model.nodeViewResourceId != null) { + Text( + text = "View ID: ${model.nodeViewResourceId}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + if (model.nodeText != null) { + Text( + text = "\"${model.nodeText}\"", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + if (model.nodeClassName != null) { + TextWithLeadingLabel( + title = stringResource(R.string.action_interact_ui_element_class_name_label), + text = model.nodeClassName, + ) + } + + if (model.nodeTooltipHint != null) { + TextWithLeadingLabel( + title = stringResource(R.string.action_interact_ui_element_tooltip_label), + text = model.nodeTooltipHint, + ) + } + + if (model.nodeUniqueId != null) { + TextWithLeadingLabel( + title = stringResource(R.string.action_interact_ui_element_unique_id_label), + text = model.nodeUniqueId, + ) + } + + TextWithLeadingLabel( + title = stringResource(R.string.action_interact_ui_element_interaction_types_label), + text = model.interactionTypesText, + ) + } + } +} + +@Composable +private fun TextWithLeadingLabel( + modifier: Modifier = Modifier, + title: String, + text: String, +) { + val text = buildAnnotatedString { + pushStyle( + MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold).toSpanStyle(), + ) + append(title) + pop() + append(": ") + append(text) + } + + Text( + modifier = modifier, + text = text, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) +} + +@Preview +@Composable +private fun Empty() { + KeyMapperTheme { + ChooseElementScreen( + state = State.Data( + SelectUiElementState( + listItems = emptyList(), + interactionTypes = emptyList(), + selectedInteractionType = null, + showAdditionalElements = false, + ), + ), + query = "Key Mapper", + ) + } +} + +@Preview +@Composable +private fun Loading() { + KeyMapperTheme { + ChooseElementScreen( + state = State.Loading, + query = null, + ) + } +} + +private val listItems = listOf( + UiElementListItemModel( + id = 1L, + nodeText = "Open Settings", + nodeClassName = "android.widget.ImageButton", + nodeViewResourceId = "menu_button", + nodeUniqueId = "123456789", + nodeTooltipHint = "Open menu", + interactionTypesText = "Tap, Tap and hold, Scroll forward", + interactionTypes = setOf( + NodeInteractionType.CLICK, + NodeInteractionType.LONG_CLICK, + NodeInteractionType.SCROLL_FORWARD, + ), + interacted = true, + ), +) + +private val loadedState = SelectUiElementState( + listItems = listItems, + interactionTypes = listOf( + null to "Any", + NodeInteractionType.CLICK to "Tap", + NodeInteractionType.LONG_CLICK to "Tap and hold", + ), + selectedInteractionType = null, + showAdditionalElements = true, +) + +@Preview +@Composable +private fun LoadedPortrait() { + KeyMapperTheme { + ChooseElementScreen( + state = State.Data(loadedState), + query = "Key Mapper", + ) + } +} + +@Preview(widthDp = 800, heightDp = 300) +@Composable +private fun LoadedPhoneLandscape() { + KeyMapperTheme { + ChooseElementScreen( + state = State.Data(loadedState), + query = "Key Mapper", + ) + } +} + +@Preview(device = Devices.TABLET) +@Composable +private fun LoadedTablet() { + KeyMapperTheme { + ChooseElementScreen( + state = State.Data(loadedState), + query = "Key Mapper", + ) + } +} + +@Preview(device = Devices.NEXUS_7) +@Composable +private fun LoadedTabletVertical() { + KeyMapperTheme { + ChooseElementScreen( + state = State.Data(loadedState), + query = "Key Mapper", + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/uielement/InteractUiElementFragment.kt b/app/src/main/java/io/github/sds100/keymapper/actions/uielement/InteractUiElementFragment.kt new file mode 100644 index 0000000000..ddde228691 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/actions/uielement/InteractUiElementFragment.kt @@ -0,0 +1,90 @@ +package io.github.sds100.keymapper.actions.uielement + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.withStateAtLeast +import androidx.navigation.findNavController +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.databinding.FragmentComposeBinding +import io.github.sds100.keymapper.util.Inject +import io.github.sds100.keymapper.util.launchRepeatOnLifecycle +import io.github.sds100.keymapper.util.ui.showPopups +import io.github.sds100.keymapper.util.viewLifecycleScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +class InteractUiElementFragment : Fragment() { + + companion object { + const val EXTRA_ACTION = "extra_action" + } + + private val args: InteractUiElementFragmentArgs by navArgs() + + private val viewModel by viewModels { + Inject.interactUiElementViewModel(requireContext()) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + args.action?.let { argsAction -> viewModel.loadAction(Json.decodeFromString(argsAction)) } + + launchRepeatOnLifecycle(Lifecycle.State.CREATED) { + viewModel.returnAction.collectLatest { action -> + viewLifecycleScope.launch { + withStateAtLeast(Lifecycle.State.RESUMED) { + setFragmentResult( + args.requestKey, + bundleOf(EXTRA_ACTION to Json.encodeToString(action)), + ) + findNavController().navigateUp() + } + } + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + 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 { + KeyMapperTheme { + InteractUiElementScreen( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + navigateBack = findNavController()::navigateUp, + ) + } + } + } + return this.root + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.showPopups(this, view) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/uielement/InteractUiElementScreen.kt b/app/src/main/java/io/github/sds100/keymapper/actions/uielement/InteractUiElementScreen.kt new file mode 100644 index 0000000000..7ff879f499 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/actions/uielement/InteractUiElementScreen.kt @@ -0,0 +1,717 @@ +package io.github.sds100.keymapper.actions.uielement + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.foundation.BorderStroke +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.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.fillMaxHeight +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.sizeIn +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.outlined.Info +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.HorizontalDivider +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.OutlinedButton +import androidx.compose.material3.OutlinedTextField +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.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.contentColorFor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowWidthSizeClass +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.system.apps.ChooseAppScreen +import io.github.sds100.keymapper.util.State +import io.github.sds100.keymapper.util.drawable +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.util.ui.compose.KeyMapperDropdownMenu +import io.github.sds100.keymapper.util.ui.compose.OptionsHeaderRow +import io.github.sds100.keymapper.util.ui.compose.WindowSizeClassExt.compareTo +import io.github.sds100.keymapper.util.ui.compose.icons.AdGroup +import io.github.sds100.keymapper.util.ui.compose.icons.JumpToElement +import io.github.sds100.keymapper.util.ui.compose.icons.KeyMapperIcons +import kotlinx.coroutines.flow.update + +private const val DEST_LANDING = "landing" +private const val DEST_SELECT_APP = "select_app" +private const val DEST_SELECT_ELEMENT = "select_element" + +@Composable +fun InteractUiElementScreen( + modifier: Modifier = Modifier, + viewModel: InteractUiElementViewModel, + navigateBack: () -> Unit, +) { + val navController = rememberNavController() + + val recordState by viewModel.recordState.collectAsStateWithLifecycle() + val selectedElementState by viewModel.selectedElementState.collectAsStateWithLifecycle() + + val appListState by viewModel.filteredAppListItems.collectAsStateWithLifecycle() + val appSearchQuery by viewModel.appSearchQuery.collectAsStateWithLifecycle() + + val elementListState by viewModel.selectUiElementState.collectAsStateWithLifecycle() + val elementSearchQuery by viewModel.elementSearchQuery.collectAsStateWithLifecycle() + + val onBackClick = { + if (!navController.navigateUp()) { + navigateBack() + } + } + + BackHandler(onBack = onBackClick) + + NavHost( + 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) }, + ) { + composable(DEST_LANDING) { + LandingScreen( + modifier = Modifier.fillMaxSize(), + recordState = recordState, + selectedElementState = selectedElementState, + onRecordClick = viewModel::onRecordClick, + onBackClick = onBackClick, + onDoneClick = viewModel::onDoneClick, + openSelectAppScreen = { + navController.navigate(DEST_SELECT_APP) + }, + onSelectInteractionType = viewModel::onSelectElementInteractionType, + onDescriptionChanged = viewModel::onDescriptionChanged, + ) + } + + composable(DEST_SELECT_APP) { + ChooseAppScreen( + modifier = Modifier.fillMaxSize(), + title = stringResource(R.string.action_interact_ui_element_choose_app_title), + state = appListState, + query = appSearchQuery, + onQueryChange = { query -> viewModel.appSearchQuery.update { query } }, + onCloseSearch = { viewModel.appSearchQuery.update { null } }, + onNavigateBack = onBackClick, + onClickApp = { + viewModel.onSelectApp(it) + navController.navigate(DEST_SELECT_ELEMENT) + }, + ) + } + + composable(DEST_SELECT_ELEMENT) { + ChooseElementScreen( + modifier = Modifier.fillMaxSize(), + state = elementListState, + query = elementSearchQuery, + onCloseSearch = { viewModel.elementSearchQuery.update { null } }, + onNavigateBack = onBackClick, + onQueryChange = { query -> viewModel.elementSearchQuery.update { query } }, + onClickElement = { + viewModel.onSelectElement(it) + navController.popBackStack(route = DEST_LANDING, inclusive = false) + }, + onSelectInteractionType = viewModel::onSelectInteractionTypeFilter, + onAdditionalElementsCheckedChange = viewModel::onAdditionalElementsCheckedChanged, + ) + } + } +} + +@Composable +private fun LandingScreen( + modifier: Modifier = Modifier, + recordState: State, + selectedElementState: SelectedUiElementState?, + onRecordClick: () -> Unit = {}, + onBackClick: () -> Unit = {}, + onDoneClick: () -> Unit = {}, + openSelectAppScreen: () -> Unit = {}, + onSelectInteractionType: (NodeInteractionType) -> Unit = {}, + onDescriptionChanged: (String) -> Unit = {}, +) { + val snackbarHostState = SnackbarHostState() + + val windowAdaptiveInfo = currentWindowAdaptiveInfo() + val widthSizeClass = windowAdaptiveInfo.windowSizeClass.windowWidthSizeClass + val heightSizeClass = windowAdaptiveInfo.windowSizeClass.windowHeightSizeClass + + Scaffold( + modifier.displayCutoutPadding(), + snackbarHost = { SnackbarHost(snackbarHostState) }, + bottomBar = { + BottomAppBar(actions = { + IconButton(onClick = onBackClick) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + stringResource(R.string.action_go_back), + ) + } + }, floatingActionButton = { + if (selectedElementState == null || selectedElementState.description.isBlank()) { + DisabledExtendedFloatingActionButton( + icon = { Icon(Icons.Rounded.Check, stringResource(R.string.button_done)) }, + text = stringResource(R.string.button_done), + ) + } else { + ExtendedFloatingActionButton( + onClick = onDoneClick, + text = { Text(stringResource(R.string.button_done)) }, + icon = { + Icon(Icons.Rounded.Check, stringResource(R.string.button_done)) + }, + elevation = FloatingActionButtonDefaults.bottomAppBarFabElevation(), + ) + } + }) + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + Column { + Text( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 8.dp, + ), + text = stringResource(R.string.action_interact_ui_element_title), + style = MaterialTheme.typography.titleLarge, + ) + + if (heightSizeClass == WindowHeightSizeClass.COMPACT || widthSizeClass >= WindowWidthSizeClass.EXPANDED) { + Row { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxHeight() + .weight(1f), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = stringResource(R.string.action_interact_ui_element_description), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + RecordingSection( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + state = recordState, + onRecordClick = onRecordClick, + openSelectAppScreen = openSelectAppScreen, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + if (selectedElementState != null) { + SelectedElementSection( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxHeight() + .padding(horizontal = 16.dp) + .weight(1f), + state = selectedElementState, + onSelectInteractionType = onSelectInteractionType, + onDescriptionChanged = onDescriptionChanged, + ) + } + } + } else { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = stringResource(R.string.action_interact_ui_element_description), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + RecordingSection( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + state = recordState, + onRecordClick = onRecordClick, + openSelectAppScreen = openSelectAppScreen, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (selectedElementState != null) { + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + SelectedElementSection( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + state = selectedElementState, + onSelectInteractionType = onSelectInteractionType, + onDescriptionChanged = onDescriptionChanged, + ) + } + } + } + } + } + } +} + +@Composable +private fun DisabledExtendedFloatingActionButton( + modifier: Modifier = Modifier, + icon: @Composable () -> Unit, + text: String, +) { + Surface( + modifier = modifier, + shape = FloatingActionButtonDefaults.extendedFabShape, + color = FloatingActionButtonDefaults.containerColor.copy(alpha = 0.5f), + ) { + Row( + modifier = + Modifier + .sizeIn(minWidth = 80.dp, minHeight = 56.dp) + .padding(start = 16.dp, end = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + val contentColor = + MaterialTheme.colorScheme.contentColorFor(FloatingActionButtonDefaults.containerColor) + .copy(alpha = 0.5f) + + CompositionLocalProvider(LocalContentColor provides contentColor) { + icon() + Spacer(Modifier.width(12.dp)) + Text( + text, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } +} + +@Composable +private fun RecordingSection( + modifier: Modifier = Modifier, + state: State, + onRecordClick: () -> Unit = {}, + openSelectAppScreen: () -> Unit = {}, +) { + Column(modifier = modifier) { + when (state) { + is State.Data -> { + val interactionCount: Int = when (state.data) { + is RecordUiElementState.CountingDown -> state.data.interactionCount + is RecordUiElementState.Recorded -> state.data.interactionCount + RecordUiElementState.Empty -> 0 + } + + InteractionCountBox( + modifier = Modifier.fillMaxWidth(), + interactionCount = interactionCount, + onClick = openSelectAppScreen, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + RecordButton( + modifier = Modifier.fillMaxWidth(), + state = state.data, + onClick = onRecordClick, + ) + } + + State.Loading -> { + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} + +@Composable +private fun InteractionCountBox( + modifier: Modifier = Modifier, + interactionCount: Int, + onClick: () -> Unit, +) { + val enabled = interactionCount > 0 + + val color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } + + Surface( + modifier = modifier, + onClick = onClick, + enabled = enabled, + shape = MaterialTheme.shapes.medium, + ) { + CompositionLocalProvider( + LocalContentColor provides color, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = KeyMapperIcons.AdGroup, contentDescription = null) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + pluralStringResource( + R.plurals.action_interact_ui_element_elements_detected, + interactionCount, + interactionCount, + ), + style = MaterialTheme.typography.bodyLarge, + ) + + Text( + stringResource(R.string.action_interact_ui_element_choose_app_title), + style = MaterialTheme.typography.bodyMedium, + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + Icon(imageVector = Icons.Rounded.ChevronRight, contentDescription = null) + } + } + } +} + +@Composable +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.CountingDown -> stringResource( + R.string.action_interact_ui_element_stop_recording, + state.timeRemaining, + ) + } + + if (state is RecordUiElementState.Recorded) { + OutlinedButton( + modifier = modifier, + onClick = onClick, + colors = ButtonDefaults.outlinedButtonColors().copy( + contentColor = LocalCustomColorsPalette.current.red, + ), + border = BorderStroke(1.dp, color = LocalCustomColorsPalette.current.red), + ) { + Text( + text = text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } else { + FilledTonalButton( + modifier = modifier, + onClick = onClick, + colors = ButtonDefaults.filledTonalButtonColors().copy( + containerColor = LocalCustomColorsPalette.current.red, + contentColor = LocalCustomColorsPalette.current.onRed, + ), + ) { + Text( + text = text, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun SelectedElementSection( + modifier: Modifier = Modifier, + state: SelectedUiElementState, + onDescriptionChanged: (String) -> Unit = {}, + onSelectInteractionType: (NodeInteractionType) -> Unit = {}, +) { + var interactionTypeExpanded by rememberSaveable { mutableStateOf(false) } + + Column(modifier = modifier) { + val isError = state.description.isBlank() + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.description, + onValueChange = onDescriptionChanged, + isError = isError, + maxLines = 1, + singleLine = true, + supportingText = if (isError) { + { Text(stringResource(R.string.error_cant_be_empty)) } + } else { + null + }, + label = { + Text(stringResource(R.string.action_interact_ui_element_description_label)) + }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + OptionsHeaderRow( + icon = Icons.Outlined.Info, + text = stringResource(R.string.action_interact_ui_element_interaction_details_title), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.action_interact_ui_element_app_label), + style = MaterialTheme.typography.titleSmall, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (state.appIcon != null) { + val painter = rememberDrawablePainter(state.appIcon.drawable) + Icon( + modifier = Modifier.size(24.dp), + painter = painter, + contentDescription = null, + tint = Color.Unspecified, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = state.appName, style = MaterialTheme.typography.bodyMedium) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + if (state.nodeText != null) { + Text( + text = stringResource(R.string.action_interact_ui_element_text_label), + style = MaterialTheme.typography.titleSmall, + ) + + Text(text = state.nodeText, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(8.dp)) + } + + if (state.nodeToolTipHint != null) { + Text( + text = stringResource(R.string.action_interact_ui_element_tooltip_label), + style = MaterialTheme.typography.titleSmall, + ) + + Text(text = state.nodeToolTipHint, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(8.dp)) + } + + if (state.nodeClassName != null) { + Text( + text = stringResource(R.string.action_interact_ui_element_class_name_label), + style = MaterialTheme.typography.titleSmall, + ) + + Text(text = state.nodeClassName, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(8.dp)) + } + + if (state.nodeViewResourceId != null) { + Text( + text = stringResource(R.string.action_interact_ui_element_view_id_label), + style = MaterialTheme.typography.titleSmall, + ) + + Text(text = state.nodeViewResourceId, style = MaterialTheme.typography.bodyMedium) + + Spacer(modifier = Modifier.height(8.dp)) + } + + if (state.nodeUniqueId != null) { + Text( + text = stringResource(R.string.action_interact_ui_element_unique_id_label), + style = MaterialTheme.typography.titleSmall, + ) + + Text(text = state.nodeUniqueId, style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(8.dp)) + } + + OptionsHeaderRow( + icon = KeyMapperIcons.JumpToElement, + text = stringResource(R.string.action_interact_ui_element_interaction_type_dropdown), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.action_interact_ui_element_interaction_type_dropdown_caption), + style = MaterialTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + KeyMapperDropdownMenu( + expanded = interactionTypeExpanded, + onExpandedChange = { interactionTypeExpanded = it }, + values = state.interactionTypes, + selectedValue = state.selectedInteraction, + onValueChanged = onSelectInteractionType, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } +} + +@Preview +@Composable +private fun PreviewEmpty() { + KeyMapperTheme { + LandingScreen( + recordState = State.Data(RecordUiElementState.Empty), + selectedElementState = null, + ) + } +} + +@Preview +@Composable +private fun PreviewLoading() { + KeyMapperTheme { + LandingScreen( + recordState = State.Loading, + selectedElementState = null, + ) + } +} + +@Composable +private fun selectedUiElementState(): SelectedUiElementState { + val appIcon = LocalContext.current.drawable(R.mipmap.ic_launcher_round) + + return SelectedUiElementState( + description = "Tap test node", + packageName = "com.example.test", + appName = "Test App", + appIcon = ComposeIconInfo.Drawable(appIcon), + nodeText = "Test Node", + nodeToolTipHint = "Test tooltip", + nodeClassName = "android.widget.ImageButton", + nodeViewResourceId = "io.github.sds100.keymapper:id/menu_button", + nodeUniqueId = "123", + interactionTypes = listOf(NodeInteractionType.LONG_CLICK to "Tap and hold"), + selectedInteraction = NodeInteractionType.LONG_CLICK, + ) +} + +@Preview(device = Devices.PIXEL_7) +@Composable +private fun PreviewSelectedElementPortrait() { + KeyMapperTheme { + LandingScreen( + recordState = State.Data(RecordUiElementState.Recorded(3)), + selectedElementState = selectedUiElementState(), + ) + } +} + +@Preview(widthDp = 800, heightDp = 300) +@Composable +private fun PreviewSelectedElementLandscape() { + KeyMapperTheme { + LandingScreen( + recordState = State.Data(RecordUiElementState.Recorded(3)), + selectedElementState = selectedUiElementState(), + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/uielement/InteractUiElementUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/actions/uielement/InteractUiElementUseCase.kt new file mode 100644 index 0000000000..9a325f22f3 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/actions/uielement/InteractUiElementUseCase.kt @@ -0,0 +1,96 @@ +package io.github.sds100.keymapper.actions.uielement + +import android.graphics.drawable.Drawable +import io.github.sds100.keymapper.data.entities.AccessibilityNodeEntity +import io.github.sds100.keymapper.data.repositories.AccessibilityNodeRepository +import io.github.sds100.keymapper.system.accessibility.RecordAccessibilityNodeState +import io.github.sds100.keymapper.system.accessibility.ServiceAdapter +import io.github.sds100.keymapper.system.apps.PackageManagerAdapter +import io.github.sds100.keymapper.util.Result +import io.github.sds100.keymapper.util.ServiceEvent +import io.github.sds100.keymapper.util.State +import io.github.sds100.keymapper.util.mapData +import io.github.sds100.keymapper.util.onFailure +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update + +class InteractUiElementController( + private val coroutineScope: CoroutineScope, + private val serviceAdapter: ServiceAdapter, + private val nodeRepository: AccessibilityNodeRepository, + private val packageManagerAdapter: PackageManagerAdapter, +) : InteractUiElementUseCase { + override val recordState: MutableStateFlow = + MutableStateFlow(RecordAccessibilityNodeState.Idle) + + override val interactionCount: Flow> = + nodeRepository.nodes.map { state -> state.mapData { it.size } } + + override val interactedPackages: Flow>> = nodeRepository.nodes.map { state -> + state.mapData { nodes -> + nodes.map { it.packageName }.distinct() + } + } + + init { + serviceAdapter.eventReceiver + .filterIsInstance() + .onEach { event -> recordState.update { event.state } } + .launchIn(coroutineScope) + } + + override fun getInteractionsByPackage(packageName: String): Flow>> { + return nodeRepository.nodes.map { state -> + state.mapData { nodes -> + nodes.filter { it.packageName == packageName } + } + } + } + + override suspend fun getInteractionById(id: Long): AccessibilityNodeEntity? { + return nodeRepository.get(id) + } + + override fun getAppName(packageName: String): Result = packageManagerAdapter.getAppName(packageName) + + override fun getAppIcon(packageName: String): Result = packageManagerAdapter.getAppIcon(packageName) + + override suspend fun startRecording(): Result<*> { + nodeRepository.deleteAll() + return serviceAdapter.send(ServiceEvent.StartRecordingNodes) + } + + override suspend fun stopRecording() { + serviceAdapter.send(ServiceEvent.StopRecordingNodes).onFailure { + recordState.update { RecordAccessibilityNodeState.Idle } + } + } + + override fun startService(): Boolean { + return serviceAdapter.start() + } +} + +interface InteractUiElementUseCase { + val recordState: StateFlow + + val interactionCount: Flow> + val interactedPackages: Flow>> + fun getInteractionsByPackage(packageName: String): Flow>> + suspend fun getInteractionById(id: Long): AccessibilityNodeEntity? + + fun getAppName(packageName: String): Result + fun getAppIcon(packageName: String): Result + + suspend fun startRecording(): Result<*> + suspend fun stopRecording() + + fun startService(): Boolean +} diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/uielement/InteractUiElementViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/actions/uielement/InteractUiElementViewModel.kt new file mode 100644 index 0000000000..67dcac391c --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/actions/uielement/InteractUiElementViewModel.kt @@ -0,0 +1,468 @@ +package io.github.sds100.keymapper.actions.uielement + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Android +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.actions.ActionData +import io.github.sds100.keymapper.data.entities.AccessibilityNodeEntity +import io.github.sds100.keymapper.system.accessibility.RecordAccessibilityNodeState +import io.github.sds100.keymapper.util.Error +import io.github.sds100.keymapper.util.State +import io.github.sds100.keymapper.util.Success +import io.github.sds100.keymapper.util.containsQuery +import io.github.sds100.keymapper.util.dataOrNull +import io.github.sds100.keymapper.util.ifIsData +import io.github.sds100.keymapper.util.mapData +import io.github.sds100.keymapper.util.onFailure +import io.github.sds100.keymapper.util.then +import io.github.sds100.keymapper.util.ui.PopupViewModel +import io.github.sds100.keymapper.util.ui.PopupViewModelImpl +import io.github.sds100.keymapper.util.ui.ResourceProvider +import io.github.sds100.keymapper.util.ui.ViewModelHelper +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.util.ui.compose.SimpleListItemModel +import io.github.sds100.keymapper.util.valueOrNull +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +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.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.Locale + +class InteractUiElementViewModel( + private val useCase: InteractUiElementUseCase, + private val resourceProvider: ResourceProvider, +) : ViewModel(), + ResourceProvider by resourceProvider, + PopupViewModel by PopupViewModelImpl() { + + private val _returnAction: MutableSharedFlow = MutableSharedFlow() + val returnAction: SharedFlow = _returnAction.asSharedFlow() + + val recordState: StateFlow> = combine( + useCase.recordState, + useCase.interactionCount, + ) { recordState, interactionCountState -> + val interactionCount = interactionCountState.dataOrNull() ?: return@combine State.Loading + + when (recordState) { + is RecordAccessibilityNodeState.CountingDown -> { + val mins = recordState.timeLeft / 60 + val secs = recordState.timeLeft % 60 + + val timeRemainingText = String.format( + Locale.getDefault(), + "%02d:%02d", + mins, + secs, + ) + + State.Data( + RecordUiElementState.CountingDown( + timeRemaining = timeRemainingText, + interactionCount = interactionCount, + ), + ) + } + + RecordAccessibilityNodeState.Idle -> { + if (interactionCount == 0) { + State.Data(RecordUiElementState.Empty) + } else { + State.Data(RecordUiElementState.Recorded(interactionCount = interactionCount)) + } + } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, State.Loading) + + private val selectedElementEntity = MutableStateFlow(null) + private val _selectedElementState = MutableStateFlow(null) + val selectedElementState: StateFlow = + _selectedElementState.asStateFlow() + + val appSearchQuery = MutableStateFlow(null) + + private val appListItems: Flow>> = useCase.interactedPackages + .map { state -> state.mapData { list -> list.map(::buildInteractedPackageListItem) } } + + val filteredAppListItems = combine( + appListItems, + appSearchQuery, + ) { state, query -> + state.mapData { listItems -> + listItems.filter { model -> + model.title.containsQuery(query) + } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, State.Loading) + + private val selectedApp = MutableStateFlow(null) + + val elementSearchQuery = MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + private val interactionsByPackage: StateFlow>> = selectedApp + .filterNotNull() + .flatMapLatest { packageName -> useCase.getInteractionsByPackage(packageName) } + .onEach { state -> + // Automatically show additional elements if no elements that were interacted with + // were detected. + state.ifIsData { list -> + if (list.count { it.interacted } == 0) { + showAdditionalElements.update { true } + } + } + } + .stateIn(viewModelScope, SharingStarted.Lazily, State.Loading) + + private val elementListItems: Flow>> = interactionsByPackage + .map { state -> state.mapData { list -> list.map(::buildUiElementListItem) } } + + private val interactionTypesFilterItems: Flow>>> = + interactionsByPackage + .map { state -> + state.mapData { list -> + val any = Pair( + null, + getString(R.string.action_interact_ui_element_interaction_type_any), + ) + + val interactionTypes = list.flatMap { it.actions }.toSet() + + listOf(any).plus(buildInteractionTypeFilterItems(interactionTypes)) + } + } + + private val selectedInteractionTypeFilter = MutableStateFlow(null) + + private val showAdditionalElements: MutableStateFlow = MutableStateFlow(false) + + private val filteredElementListItems = combine( + elementListItems, + elementSearchQuery, + selectedInteractionTypeFilter, + showAdditionalElements, + ) { state, query, interactionType, showAdditionalElements -> + state.mapData { listItems -> + listItems.filter { model -> + if (!showAdditionalElements && !model.interacted) { + return@filter false + } + + if (interactionType != null && !model.interactionTypes.contains(interactionType)) { + return@filter false + } + + val modelString = buildString { + append(model.nodeText) + append(" ") + append(model.nodeTooltipHint) + append(" ") + append(model.nodeClassName) + append(" ") + append(model.nodeViewResourceId) + } + modelString.containsQuery(query) + } + } + }.stateIn(viewModelScope, SharingStarted.Lazily, State.Loading) + + val selectUiElementState: StateFlow> = combine( + filteredElementListItems, + interactionTypesFilterItems, + selectedInteractionTypeFilter, + showAdditionalElements, + ) { listItemsState, interactionTypesState, selectedInteractionType, showAdditionalElements -> + val listItems = listItemsState.dataOrNull() ?: return@combine State.Loading + val interactionTypes = interactionTypesState.dataOrNull() ?: return@combine State.Loading + + val newState = SelectUiElementState( + listItems = listItems, + interactionTypes = interactionTypes, + selectedInteractionType = selectedInteractionType, + showAdditionalElements = showAdditionalElements, + ) + State.Data(newState) + }.stateIn(viewModelScope, SharingStarted.Lazily, State.Loading) + + fun loadAction(action: ActionData.InteractUiElement) { + viewModelScope.launch { + val appName = useCase.getAppName(action.packageName).valueOrNull() ?: action.packageName + val appIcon = getAppIcon(action.packageName) + + val newState = SelectedUiElementState( + description = action.description, + packageName = action.packageName, + appName = appName, + appIcon = appIcon, + nodeText = action.text ?: action.contentDescription, + nodeToolTipHint = action.tooltip ?: action.hint, + nodeClassName = action.className, + nodeViewResourceId = action.viewResourceId, + nodeUniqueId = action.uniqueId, + interactionTypes = buildInteractionTypeFilterItems(action.nodeActions), + selectedInteraction = action.nodeAction, + ) + + _selectedElementState.update { newState } + } + } + + fun onDoneClick() { + val selectedElementState = _selectedElementState.value + val selectedElementEntity = selectedElementEntity.value + + if (selectedElementState == null || selectedElementEntity == null) { + return + } + + if (selectedElementState.description.isBlank()) { + return + } + + val action = ActionData.InteractUiElement( + description = selectedElementState.description, + nodeAction = selectedElementState.selectedInteraction, + packageName = selectedElementEntity.packageName, + text = selectedElementEntity.text, + contentDescription = selectedElementEntity.contentDescription, + tooltip = selectedElementEntity.tooltip, + hint = selectedElementEntity.hint, + className = selectedElementEntity.className, + viewResourceId = selectedElementEntity.viewResourceId, + uniqueId = selectedElementEntity.uniqueId, + nodeActions = selectedElementEntity.actions, + ) + + viewModelScope.launch { + _returnAction.emit(action) + } + } + + fun onRecordClick() { + recordState.value.ifIsData { recordState -> + viewModelScope.launch { + when (recordState) { + is RecordUiElementState.CountingDown -> useCase.stopRecording() + RecordUiElementState.Empty -> startRecording() + is RecordUiElementState.Recorded -> startRecording() + } + } + } + } + + fun onSelectApp(packageName: String) { + elementSearchQuery.update { null } + + if (packageName != selectedApp.value) { + showAdditionalElements.update { false } + } + + selectedApp.update { packageName } + } + + fun onSelectElement(id: Long) { + viewModelScope.launch { + val interaction = useCase.getInteractionById(id) ?: return@launch + + val appName = + useCase.getAppName(interaction.packageName).valueOrNull() ?: interaction.packageName + val appIcon = getAppIcon(interaction.packageName) + + val selectedInteraction = + NodeInteractionType.entries.first { interaction.actions.contains(it) } + val interactionText = getInteractionTypeString(selectedInteraction) + val descriptionElement = + interaction.text ?: interaction.contentDescription ?: interaction.tooltip + ?: interaction.hint ?: interaction.viewResourceId + + val description = if (descriptionElement == null) { + "" + } else { + "$interactionText: $descriptionElement" + } + + val newState = SelectedUiElementState( + description = description, + packageName = interaction.packageName, + appName = appName, + appIcon = appIcon, + nodeText = interaction.text ?: interaction.contentDescription, + nodeClassName = interaction.className, + nodeToolTipHint = interaction.tooltip ?: interaction.hint, + nodeViewResourceId = interaction.viewResourceId, + nodeUniqueId = interaction.uniqueId, + interactionTypes = buildInteractionTypeFilterItems(interaction.actions), + selectedInteraction = selectedInteraction, + ) + + selectedElementEntity.update { interaction } + _selectedElementState.update { newState } + } + } + + fun onSelectElementInteractionType(interactionType: NodeInteractionType) { + _selectedElementState.update { state -> + state?.copy(selectedInteraction = interactionType) + } + } + + fun onSelectInteractionTypeFilter(interactionType: NodeInteractionType?) { + selectedInteractionTypeFilter.update { interactionType } + } + + fun onDescriptionChanged(description: String) { + _selectedElementState.update { state -> + state?.copy(description = description) + } + } + + fun onAdditionalElementsCheckedChanged(checked: Boolean) { + showAdditionalElements.update { checked } + } + + private suspend fun startRecording() { + useCase.startRecording().onFailure { error -> + if (error == Error.AccessibilityServiceDisabled) { + ViewModelHelper.handleAccessibilityServiceStoppedDialog( + this, + this, + startService = { useCase.startService() }, + ) + } else if (error == Error.AccessibilityServiceCrashed) { + ViewModelHelper.handleAccessibilityServiceCrashedDialog( + this, + this, + restartService = { useCase.startService() }, + ) + } + } + } + + private fun buildInteractedPackageListItem(packageName: String): SimpleListItemModel { + val appName = useCase.getAppName(packageName).valueOrNull() ?: packageName + val appIcon = getAppIcon(packageName) ?: ComposeIconInfo.Vector(Icons.Rounded.Android) + + return SimpleListItemModel( + id = packageName, + title = appName, + icon = appIcon, + ) + } + + private fun buildUiElementListItem(node: AccessibilityNodeEntity): UiElementListItemModel { + val resourceIdText = node.viewResourceId?.split("/")?.lastOrNull() + + return UiElementListItemModel( + id = node.id, + nodeViewResourceId = resourceIdText, + nodeText = node.text ?: node.contentDescription, + nodeClassName = node.className, + nodeUniqueId = node.uniqueId, + nodeTooltipHint = node.tooltip ?: node.hint, + interactionTypesText = node.actions.joinToString { getInteractionTypeString(it) }, + interactionTypes = node.actions, + interacted = node.interacted, + ) + } + + 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) { + if (interactionTypes.contains(type)) { + add(type to getInteractionTypeString(type)) + } + } + } + } + + private fun getAppIcon(packageName: String): ComposeIconInfo.Drawable? = useCase + .getAppIcon(packageName) + .then { Success(ComposeIconInfo.Drawable(it)) } + .valueOrNull() + + 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) + } + } + + @Suppress("UNCHECKED_CAST") + class Factory( + private val useCase: InteractUiElementUseCase, + private val resourceProvider: ResourceProvider, + ) : ViewModelProvider.NewInstanceFactory() { + override fun create(modelClass: Class): T { + return InteractUiElementViewModel(useCase, resourceProvider) as T + } + } +} + +data class SelectedUiElementState( + val description: String, + val packageName: String, + val appName: String, + val appIcon: ComposeIconInfo.Drawable?, + val nodeText: String?, + val nodeToolTipHint: String?, + val nodeClassName: String?, + val nodeViewResourceId: String?, + val nodeUniqueId: String?, + val interactionTypes: List>, + val selectedInteraction: NodeInteractionType, +) + +sealed class RecordUiElementState { + data class Recorded(val interactionCount: Int) : RecordUiElementState() + + data class CountingDown( + val timeRemaining: String, + val interactionCount: Int, + ) : RecordUiElementState() + + data object Empty : RecordUiElementState() +} + +data class SelectUiElementState( + val listItems: List, + val interactionTypes: List>, + val selectedInteractionType: NodeInteractionType?, + val showAdditionalElements: Boolean, +) + +data class UiElementListItemModel( + val id: Long, + val nodeViewResourceId: String?, + val nodeText: String?, + val nodeTooltipHint: String?, + val nodeClassName: String?, + val nodeUniqueId: String?, + val interactionTypesText: String, + val interactionTypes: Set, + /** + * Whether the user interacted with this element. + */ + val interacted: Boolean, +) diff --git a/app/src/main/java/io/github/sds100/keymapper/actions/uielement/NodeInteractionType.kt b/app/src/main/java/io/github/sds100/keymapper/actions/uielement/NodeInteractionType.kt new file mode 100644 index 0000000000..8d02874b45 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/actions/uielement/NodeInteractionType.kt @@ -0,0 +1,13 @@ +package io.github.sds100.keymapper.actions.uielement + +import android.view.accessibility.AccessibilityNodeInfo + +enum class NodeInteractionType(val accessibilityActionId: Int) { + CLICK(AccessibilityNodeInfo.ACTION_CLICK), + LONG_CLICK(AccessibilityNodeInfo.ACTION_LONG_CLICK), + FOCUS(AccessibilityNodeInfo.ACTION_FOCUS), + SCROLL_FORWARD(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD), + SCROLL_BACKWARD(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD), + EXPAND(AccessibilityNodeInfo.ACTION_EXPAND), + COLLAPSE(AccessibilityNodeInfo.ACTION_COLLAPSE), +} diff --git a/app/src/main/java/io/github/sds100/keymapper/backup/BackupContent.kt b/app/src/main/java/io/github/sds100/keymapper/backup/BackupContent.kt index d9664aaba6..07a71956d1 100644 --- a/app/src/main/java/io/github/sds100/keymapper/backup/BackupContent.kt +++ b/app/src/main/java/io/github/sds100/keymapper/backup/BackupContent.kt @@ -6,7 +6,6 @@ import io.github.sds100.keymapper.data.entities.FloatingLayoutEntity import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity -// TODO back up groups that are referenced by key maps - back up all the children as well. If the parent is not included in the back up then set the parent uid to null data class BackupContent( @SerializedName(NAME_DB_VERSION) val dbVersion: Int, diff --git a/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt b/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt index 659c66b79e..d56aaf4d19 100644 --- a/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt +++ b/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt @@ -54,7 +54,9 @@ import io.github.sds100.keymapper.util.Error import io.github.sds100.keymapper.util.Result import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.Success +import io.github.sds100.keymapper.util.TreeNode import io.github.sds100.keymapper.util.UuidGenerator +import io.github.sds100.keymapper.util.breadFirstTraversal import io.github.sds100.keymapper.util.onFailure import io.github.sds100.keymapper.util.then import kotlinx.coroutines.CoroutineScope @@ -249,6 +251,12 @@ class BackupManagerImpl( // Do nothing. It just removed the group name index. JsonMigration(17, 18) { json -> json }, + + // Do nothing. Just added the accessibility node table. + JsonMigration(18, 19) { json -> json }, + + // Do nothing. Just added columns to the accessibility node table. + JsonMigration(19, 20) { json -> json }, ) if (keyMapListJsonArray != null) { @@ -467,55 +475,11 @@ class BackupManagerImpl( // Group parents must be restored first so an SqliteConstraintException // is not thrown when restoring a child group. - val groupsToRestoreMap = backupContent.groups.associateBy { it.uid }.toMutableMap() - val groupRestoreQueue = LinkedList() - - // Order the groups into a queue such that a parent is always before a child. - for (group in backupContent.groups) { - if (groupsToRestoreMap.containsKey(group.uid)) { - groupRestoreQueue.addFirst(group) - } - - var parent = groupsToRestoreMap[group.parentUid] - - while (parent != null) { - groupRestoreQueue.addFirst(parent) - groupsToRestoreMap.remove(parent.uid) - parent = groupsToRestoreMap[parent.parentUid] - } - } - - for (group in groupRestoreQueue) { - // Set the last opened date to now so that the imported group - // shows as the most recent. - var modifiedGroup = group.copy(lastOpenedDate = currentTime) - - // If the group's parent wasn't backed up or doesn't exist - // then set it the parent to the root group - if (!groupUids.contains(group.parentUid)) { - modifiedGroup = modifiedGroup.copy(parentUid = null) - } - - val siblings = - groupRepository.getGroupsByParent(modifiedGroup.parentUid).first() + val groupRestoreTrees = buildGroupTrees(backupContent.groups) - modifiedGroup = RepositoryUtils.saveUniqueName( - 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 }) { - throw IllegalStateException("Non unique group name") - } - }, - renameBlock = { entity, suffix -> - entity.copy(name = "${entity.name} $suffix") - }, - ) - - if (existingGroupUids.contains(modifiedGroup.uid)) { - groupRepository.update(modifiedGroup) - } else { - groupRepository.insert(modifiedGroup) + for (tree in groupRestoreTrees) { + tree.breadFirstTraversal { group -> + restoreGroup(group, currentTime, groupUids, existingGroupUids) } } } @@ -609,6 +573,91 @@ class BackupManagerImpl( } } + private suspend fun restoreGroup( + group: GroupEntity, + currentTime: Long, + groupUids: Set, + existingGroupUids: Set, + ) { + // Set the last opened date to now so that the imported group + // shows as the most recent. + var modifiedGroup = group.copy(lastOpenedDate = currentTime) + + // If the group's parent wasn't backed up or doesn't exist + // then set it the parent to the root group + if (!groupUids.contains(group.parentUid)) { + modifiedGroup = modifiedGroup.copy(parentUid = null) + } + + val siblings = + groupRepository.getGroupsByParent(modifiedGroup.parentUid).first() + + modifiedGroup = RepositoryUtils.saveUniqueName( + 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 }) { + throw IllegalStateException("Non unique group name") + } + }, + renameBlock = { entity, suffix -> + entity.copy(name = "${entity.name} $suffix") + }, + ) + + if (existingGroupUids.contains(modifiedGroup.uid)) { + groupRepository.update(modifiedGroup) + } else { + groupRepository.insert(modifiedGroup) + } + } + + /** + * Converts the group relationships into trees. This first finds all the root groups which + * have no parent. Then it loops over all the other groups indefinitely until they have been + * added to their parent. If the parent does not exist while looping then it is skipped and + * processed in the next iteration. + * + * @return A list of the root nodes for all the group trees. + */ + private fun buildGroupTrees(groups: List): List> { + if (groups.isEmpty()) { + return emptyList() + } + + val nodeMap = mutableMapOf>() + val rootNodes = mutableListOf>() + + val groupQueue = LinkedList() + + for (group in groups) { + if (group.parentUid == null) { + val node = TreeNode(group) + nodeMap[group.uid] = node + rootNodes.add(node) + } else { + groupQueue.add(group) + } + } + + while (groupQueue.isNotEmpty()) { + val groupsToRemove = mutableListOf() + + for (group in groupQueue) { + if (nodeMap.containsKey(group.parentUid)) { + val node = TreeNode(group) + nodeMap[group.uid] = node + nodeMap[group.parentUid]!!.children.add(node) + groupsToRemove.add(group) + } + } + + groupQueue.removeAll(groupsToRemove.toSet()) + } + + return rootNodes + } + private suspend fun appendKeyMapsInRepository(keyMaps: List) = withContext(dispatchers.default()) { val randomUids = keyMaps.map { it.copy(uid = UUID.randomUUID().toString()) } keyMapRepository.insert(*randomUids.toTypedArray()) diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintScreen.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintScreen.kt index 6a0425827d..6ed94f2c43 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintScreen.kt @@ -62,6 +62,8 @@ fun ChooseConstraintScreen( val listItems by viewModel.listItems.collectAsStateWithLifecycle() val query by viewModel.searchQuery.collectAsStateWithLifecycle() + TimeConstraintBottomSheet(viewModel) + ChooseConstraintScreen( modifier = modifier, state = listItems, diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt index 04a6070f6a..ef87621a6e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ChooseConstraintViewModel.kt @@ -1,5 +1,8 @@ package io.github.sds100.keymapper.constraints +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -88,6 +91,8 @@ class ChooseConstraintViewModel( ConstraintId.CHARGING, ConstraintId.DISCHARGING, + + ConstraintId.TIME, ) } @@ -104,6 +109,17 @@ class ChooseConstraintViewModel( State.Data(filteredItems) }.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, State.Loading) + var timeConstraintState: Constraint.Time? by mutableStateOf(null) + + fun onDoneConfigTimeConstraintClick() { + timeConstraintState?.let { constraint -> + viewModelScope.launch { + _returnResult.emit(constraint) + timeConstraintState = null + } + } + } + fun onListItemClick(id: String) { viewModelScope.launch { when (val constraintType = ConstraintId.valueOf(id)) { @@ -192,6 +208,15 @@ class ChooseConstraintViewModel( ConstraintId.LOCK_SCREEN_NOT_SHOWING -> _returnResult.emit(Constraint.LockScreenNotShowing()) + + ConstraintId.TIME -> { + timeConstraintState = Constraint.Time( + startHour = 0, + startMinute = 0, + endHour = 0, + endMinute = 0, + ) + } } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/Constraint.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/Constraint.kt index 16f3726d85..ccb07858b0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/Constraint.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/Constraint.kt @@ -8,6 +8,7 @@ import io.github.sds100.keymapper.system.display.Orientation import io.github.sds100.keymapper.util.getKey import io.github.sds100.keymapper.util.valueOrNull import kotlinx.serialization.Serializable +import java.time.LocalTime import java.util.UUID /** @@ -216,6 +217,20 @@ sealed class Constraint { data class Discharging(override val uid: String = UUID.randomUUID().toString()) : Constraint() { override val id: ConstraintId = ConstraintId.DISCHARGING } + + @Serializable + data class Time( + override val uid: String = UUID.randomUUID().toString(), + val startHour: Int, + val startMinute: Int, + val endHour: Int, + val endMinute: Int, + ) : Constraint() { + override val id: ConstraintId = ConstraintId.TIME + + val startTime: LocalTime by lazy { LocalTime.of(startHour, startMinute) } + val endTime: LocalTime by lazy { LocalTime.of(endHour, endMinute) } + } } object ConstraintModeEntityMapper { @@ -375,6 +390,28 @@ object ConstraintEntityMapper { ConstraintEntity.CHARGING -> Constraint.Charging(uid = entity.uid) ConstraintEntity.DISCHARGING -> Constraint.Discharging(uid = entity.uid) + ConstraintEntity.TIME -> { + val startTime = + entity.extras.getData(ConstraintEntity.EXTRA_START_TIME).valueOrNull()!! + .split(":") + val startHour = startTime[0].toInt() + val startMin = startTime[1].toInt() + + val endTime = + entity.extras.getData(ConstraintEntity.EXTRA_END_TIME).valueOrNull()!! + .split(":") + val endHour = endTime[0].toInt() + val endMin = endTime[1].toInt() + + Constraint.Time( + uid = entity.uid, + startHour = startHour, + startMinute = startMin, + endHour = endHour, + endMinute = endMin, + ) + } + else -> throw Exception("don't know how to convert constraint entity with type ${entity.type}") } } @@ -606,5 +643,18 @@ object ConstraintEntityMapper { uid = constraint.uid, ConstraintEntity.DISCHARGING, ) + + is Constraint.Time -> ConstraintEntity( + uid = constraint.uid, + type = ConstraintEntity.TIME, + EntityExtra( + ConstraintEntity.EXTRA_START_TIME, + "${constraint.startHour}:${constraint.startMinute}", + ), + EntityExtra( + ConstraintEntity.EXTRA_END_TIME, + "${constraint.endHour}:${constraint.endMinute}", + ), + ) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintId.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintId.kt index 72f347cb3d..b015752aca 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintId.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintId.kt @@ -48,4 +48,6 @@ enum class ConstraintId { CHARGING, DISCHARGING, + + TIME, } diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintSnapshot.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintSnapshot.kt index 4ef6ceb373..5a813bc92a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintSnapshot.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintSnapshot.kt @@ -17,6 +17,7 @@ import io.github.sds100.keymapper.system.phone.PhoneAdapter import io.github.sds100.keymapper.system.power.PowerAdapter import io.github.sds100.keymapper.util.firstBlocking import timber.log.Timber +import java.time.LocalTime /** * Created by sds100 on 08/05/2021.f @@ -69,6 +70,8 @@ class LazyConstraintSnapshot( lockScreenAdapter.isLockScreenShowing() } + private val localTime = LocalTime.now() + private fun isMediaPlaying(): Boolean { return audioVolumeStreams.contains(AudioManager.STREAM_MUSIC) || appsPlayingMedia.isNotEmpty() } @@ -156,6 +159,13 @@ class LazyConstraintSnapshot( // 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 Constraint.Time -> + if (constraint.startTime.isAfter(constraint.endTime)) { + localTime.isAfter(constraint.startTime) || localTime.isBefore(constraint.endTime) + } else { + localTime.isAfter(constraint.startTime) && localTime.isBefore(constraint.endTime) + } } if (isSatisfied) { diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt index 7e2cc119d2..223b606c77 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUiHelper.kt @@ -5,10 +5,12 @@ import androidx.compose.material.icons.rounded.Android import io.github.sds100.keymapper.R import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.display.Orientation +import io.github.sds100.keymapper.util.TimeUtils import io.github.sds100.keymapper.util.handle import io.github.sds100.keymapper.util.ui.ResourceProvider import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.util.valueIfFailure +import java.time.format.FormatStyle /** * Created by sds100 on 18/03/2021. @@ -20,6 +22,8 @@ class ConstraintUiHelper( ) : DisplayConstraintUseCase by displayConstraintUseCase, ResourceProvider by resourceProvider { + private val timeFormatter by lazy { TimeUtils.localeDateFormatter(FormatStyle.SHORT) } + fun getTitle(constraint: Constraint): String = when (constraint) { is Constraint.AppInForeground -> getAppName(constraint.packageName).handle( @@ -144,6 +148,13 @@ class ConstraintUiHelper( 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( + R.string.constraint_time_formatted, + arrayOf( + timeFormatter.format(constraint.startTime), + timeFormatter.format(constraint.endTime), + ), + ) } fun getIcon(constraint: Constraint): ComposeIconInfo = when (constraint) { diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUtils.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUtils.kt index 94e5a4dd5e..db5e3421e9 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintUtils.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.outlined.SignalWifiStatusbarNull import androidx.compose.material.icons.outlined.StayCurrentLandscape import androidx.compose.material.icons.outlined.StayCurrentPortrait import androidx.compose.material.icons.outlined.StopCircle +import androidx.compose.material.icons.outlined.Timer import androidx.compose.material.icons.outlined.Wifi import androidx.compose.material.icons.outlined.WifiOff import androidx.compose.material.icons.rounded.Android @@ -78,6 +79,7 @@ object ConstraintUtils { ConstraintId.DISCHARGING -> ComposeIconInfo.Vector(Icons.Outlined.Battery2Bar) 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) } fun getTitleStringId(constraintId: ConstraintId): Int = when (constraintId) { @@ -114,5 +116,6 @@ object ConstraintUtils { ConstraintId.DISCHARGING -> R.string.constraint_discharging 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/app/src/main/java/io/github/sds100/keymapper/constraints/TimeConstraintBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/TimeConstraintBottomSheet.kt new file mode 100644 index 0000000000..f59740fddd --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/TimeConstraintBottomSheet.kt @@ -0,0 +1,296 @@ +package io.github.sds100.keymapper.constraints + +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.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.rounded.Timer +import androidx.compose.material.icons.rounded.TimerOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTimePickerState +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.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.util.TimeUtils +import io.github.sds100.keymapper.util.ui.compose.OptionsHeaderRow +import kotlinx.coroutines.launch +import java.time.format.FormatStyle + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimeConstraintBottomSheet(viewModel: ChooseConstraintViewModel) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (viewModel.timeConstraintState != null) { + TimeConstraintBottomSheet( + sheetState = sheetState, + onDismissRequest = { + viewModel.timeConstraintState = null + }, + state = viewModel.timeConstraintState!!, + onSelectStartTime = { hour, min -> + viewModel.timeConstraintState = viewModel.timeConstraintState?.copy( + startHour = hour, + startMinute = min, + ) + }, + onSelectEndTime = { hour, min -> + viewModel.timeConstraintState = viewModel.timeConstraintState?.copy( + endHour = hour, + endMinute = min, + ) + }, + onDoneClick = { + scope.launch { + sheetState.hide() + viewModel.onDoneConfigTimeConstraintClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TimeConstraintBottomSheet( + sheetState: SheetState, + onDismissRequest: () -> Unit, + state: Constraint.Time, + onSelectStartTime: (Int, Int) -> Unit = { _, _ -> }, + onSelectEndTime: (Int, Int) -> Unit = { _, _ -> }, + onDoneClick: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val formatter = remember { TimeUtils.localeDateFormatter(FormatStyle.SHORT) } + + val startTimePickerState = rememberTimePickerState() + var showStartTimePickerDialog by remember { mutableStateOf(false) } + + if (showStartTimePickerDialog) { + TimePickerDialog( + state = startTimePickerState, + onDismiss = { + showStartTimePickerDialog = false + }, + onConfirm = { + onSelectStartTime(startTimePickerState.hour, startTimePickerState.minute) + showStartTimePickerDialog = false + }, + ) + } + + val endTimePickerState = rememberTimePickerState() + var showEndTimePickerDialog by remember { mutableStateOf(false) } + + if (showEndTimePickerDialog) { + TimePickerDialog( + state = endTimePickerState, + onDismiss = { + showEndTimePickerDialog = false + }, + onConfirm = { + onSelectEndTime(endTimePickerState.hour, endTimePickerState.minute) + showEndTimePickerDialog = false + }, + ) + } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + ) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + textAlign = TextAlign.Center, + text = stringResource(R.string.constraint_time_bottom_sheet_title), + style = MaterialTheme.typography.headlineMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.Timer, + text = stringResource(R.string.constraint_time_bottom_sheet_start_time), + ) + + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatter.format(state.startTime), + style = MaterialTheme.typography.titleLarge, + ) + + IconButton( + modifier = Modifier.padding(start = 8.dp), + onClick = { + startTimePickerState.hour = state.startHour + startTimePickerState.minute = state.startMinute + + showStartTimePickerDialog = true + }, + ) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource(R.string.constraint_time_bottom_sheet_edit_start_time), + ) + } + } + + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.TimerOff, + text = stringResource(R.string.constraint_time_bottom_sheet_end_time), + ) + + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = formatter.format(state.endTime), + style = MaterialTheme.typography.titleLarge, + ) + + IconButton( + modifier = Modifier.padding(start = 8.dp), + onClick = { + endTimePickerState.hour = state.endHour + endTimePickerState.minute = state.endMinute + + showEndTimePickerDialog = true + }, + ) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource(R.string.constraint_time_bottom_sheet_edit_end_time), + ) + } + } + + 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) +@Composable +private fun TimePickerDialog( + state: TimePickerState, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + dismissButton = { + TextButton(onClick = { onDismiss() }) { + Text(stringResource(R.string.neg_cancel)) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm() }) { + Text(stringResource(R.string.pos_ok)) + } + }, + text = { + TimePicker(state = state) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = SheetValue.Expanded, + ) + + TimeConstraintBottomSheet( + sheetState = sheetState, + onDismissRequest = {}, + state = Constraint.Time( + startHour = 0, + startMinute = 0, + endHour = 23, + endMinute = 59, + ), + onSelectStartTime = { _, _ -> }, + onSelectEndTime = { _, _ -> }, + onDoneClick = {}, + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt index e3a933e747..bba74e2b66 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt @@ -9,6 +9,7 @@ import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import io.github.sds100.keymapper.data.db.AppDatabase.Companion.DATABASE_VERSION +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao import io.github.sds100.keymapper.data.db.dao.FingerprintMapDao import io.github.sds100.keymapper.data.db.dao.FloatingButtonDao import io.github.sds100.keymapper.data.db.dao.FloatingLayoutDao @@ -18,7 +19,9 @@ import io.github.sds100.keymapper.data.db.dao.LogEntryDao import io.github.sds100.keymapper.data.db.typeconverter.ActionListTypeConverter import io.github.sds100.keymapper.data.db.typeconverter.ConstraintListTypeConverter import io.github.sds100.keymapper.data.db.typeconverter.ExtraListTypeConverter +import io.github.sds100.keymapper.data.db.typeconverter.NodeInteractionTypeSetTypeConverter import io.github.sds100.keymapper.data.db.typeconverter.TriggerTypeConverter +import io.github.sds100.keymapper.data.entities.AccessibilityNodeEntity import io.github.sds100.keymapper.data.entities.FingerprintMapEntity import io.github.sds100.keymapper.data.entities.FloatingButtonEntity import io.github.sds100.keymapper.data.entities.FloatingLayoutEntity @@ -28,6 +31,8 @@ import io.github.sds100.keymapper.data.entities.LogEntryEntity import io.github.sds100.keymapper.data.migration.AutoMigration14To15 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.Migration10To11 import io.github.sds100.keymapper.data.migration.Migration11To12 import io.github.sds100.keymapper.data.migration.Migration13To14 @@ -44,7 +49,7 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 * Created by sds100 on 24/01/2020. */ @Database( - entities = [KeyMapEntity::class, FingerprintMapEntity::class, LogEntryEntity::class, FloatingLayoutEntity::class, FloatingButtonEntity::class, GroupEntity::class], + entities = [KeyMapEntity::class, FingerprintMapEntity::class, LogEntryEntity::class, FloatingLayoutEntity::class, FloatingButtonEntity::class, GroupEntity::class, AccessibilityNodeEntity::class], version = DATABASE_VERSION, exportSchema = true, autoMigrations = [ @@ -54,6 +59,10 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 AutoMigration(from = 15, to = 16, spec = AutoMigration15To16::class), // This adds last opened timestamp to groups AutoMigration(from = 16, to = 17, spec = AutoMigration16To17::class), + // Adds accessibility node table + 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), ], ) @TypeConverters( @@ -61,11 +70,12 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 ExtraListTypeConverter::class, TriggerTypeConverter::class, ConstraintListTypeConverter::class, + NodeInteractionTypeSetTypeConverter::class, ) abstract class AppDatabase : RoomDatabase() { companion object { const val DATABASE_NAME = "key_map_database" - const val DATABASE_VERSION = 18 + const val DATABASE_VERSION = 20 val MIGRATION_1_2 = object : Migration(1, 2) { @@ -162,4 +172,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun floatingLayoutDao(): FloatingLayoutDao abstract fun floatingButtonDao(): FloatingButtonDao abstract fun groupDao(): GroupDao + abstract fun accessibilityNodeDao(): AccessibilityNodeDao } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/dao/AccessibilityNodeDao.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/AccessibilityNodeDao.kt new file mode 100644 index 0000000000..1aa13584a5 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/dao/AccessibilityNodeDao.kt @@ -0,0 +1,38 @@ +package io.github.sds100.keymapper.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.github.sds100.keymapper.data.entities.AccessibilityNodeEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface AccessibilityNodeDao { + companion object { + const val TABLE_NAME = "accessibility_nodes" + const val KEY_ID = "id" + const val KEY_PACKAGE_NAME = "package_name" + const val KEY_TEXT = "text" + const val KEY_CONTENT_DESCRIPTION = "content_description" + const val KEY_CLASS_NAME = "class_name" + const val KEY_VIEW_RESOURCE_ID = "view_resource_id" + const val KEY_UNIQUE_ID = "unique_id" + const val KEY_ACTIONS = "actions" + const val KEY_INTERACTED = "interacted" + const val KEY_TOOLTIP = "tooltip" + const val KEY_HINT = "hint" + } + + @Query("SELECT * FROM $TABLE_NAME WHERE $KEY_ID = (:id)") + suspend fun getById(id: Long): AccessibilityNodeEntity? + + @Query("SELECT * FROM $TABLE_NAME") + fun getAll(): Flow> + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insert(vararg node: AccessibilityNodeEntity) + + @Query("DELETE FROM $TABLE_NAME") + suspend fun deleteAll() +} diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/NodeInteractionTypeSetTypeConverter.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/NodeInteractionTypeSetTypeConverter.kt new file mode 100644 index 0000000000..d4093d82d8 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/NodeInteractionTypeSetTypeConverter.kt @@ -0,0 +1,34 @@ +package io.github.sds100.keymapper.data.db.typeconverter + +import androidx.room.TypeConverter +import io.github.sds100.keymapper.actions.uielement.NodeInteractionType + +/** + * Created by sds100 on 05/09/2018. + */ + +class NodeInteractionTypeSetTypeConverter { + @TypeConverter + fun toSet(mask: Int): Set { + val interactionTypeSet = mutableSetOf() + + for (type in NodeInteractionType.entries) { + if (mask and type.accessibilityActionId == type.accessibilityActionId) { + interactionTypeSet.add(type) + } + } + + return interactionTypeSet + } + + @TypeConverter + fun toMask(set: Set): Int { + var nodeActionMask = 0 + + for (nodeAction in set) { + nodeActionMask = nodeActionMask or nodeAction.accessibilityActionId + } + + return nodeActionMask + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/AccessibilityNodeEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/AccessibilityNodeEntity.kt new file mode 100644 index 0000000000..f84769395c --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/AccessibilityNodeEntity.kt @@ -0,0 +1,63 @@ +package io.github.sds100.keymapper.data.entities + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.github.sds100.keymapper.actions.uielement.NodeInteractionType +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao.Companion.KEY_ACTIONS +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao.Companion.KEY_CLASS_NAME +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao.Companion.KEY_CONTENT_DESCRIPTION +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao.Companion.KEY_HINT +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao.Companion.KEY_ID +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao.Companion.KEY_INTERACTED +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao.Companion.KEY_PACKAGE_NAME +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao.Companion.KEY_TEXT +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao.Companion.KEY_TOOLTIP +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao.Companion.KEY_UNIQUE_ID +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao.Companion.KEY_VIEW_RESOURCE_ID +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao.Companion.TABLE_NAME +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +@Parcelize +@Entity(tableName = TABLE_NAME) +data class AccessibilityNodeEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = KEY_ID) + val id: Long = 0L, + + @ColumnInfo(name = KEY_PACKAGE_NAME) + val packageName: String, + + @ColumnInfo(name = KEY_TEXT) + val text: String?, + + @ColumnInfo(name = KEY_CONTENT_DESCRIPTION) + val contentDescription: String?, + + @ColumnInfo(name = KEY_CLASS_NAME) + val className: String?, + + @ColumnInfo(name = KEY_VIEW_RESOURCE_ID) + val viewResourceId: String?, + + @ColumnInfo(name = KEY_UNIQUE_ID) + val uniqueId: String?, + + @ColumnInfo(name = KEY_ACTIONS) + val actions: Set, + + /** + * Whether the user interacted with this node. + */ + @ColumnInfo(name = KEY_INTERACTED, defaultValue = false.toString()) + val interacted: Boolean, + + @ColumnInfo(name = KEY_TOOLTIP, defaultValue = "NULL") + val tooltip: String?, + + @ColumnInfo(name = KEY_HINT, defaultValue = "NULL") + val hint: String?, +) : Parcelable diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index e99db4de73..be62380df3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -90,6 +90,19 @@ data class ActionEntity( const val EXTRA_HTTP_DESCRIPTION = "extra_http_description" const val EXTRA_HTTP_AUTHORIZATION_HEADER = "extra_http_authorization_header" + // Accessibility node extras + const val EXTRA_ACCESSIBILITY_PACKAGE_NAME = "extra_accessibility_package_name" + const val EXTRA_ACCESSIBILITY_CONTENT_DESCRIPTION = + "extra_accessibility_content_description" + const val EXTRA_ACCESSIBILITY_TEXT = "extra_accessibility_text" + const val EXTRA_ACCESSIBILITY_TOOLTIP = "extra_accessibility_tooltip" + const val EXTRA_ACCESSIBILITY_HINT = "extra_accessibility_hint" + const val EXTRA_ACCESSIBILITY_CLASS_NAME = "extra_accessibility_class_name" + const val EXTRA_ACCESSIBILITY_VIEW_RESOURCE_ID = "extra_accessibility_view_resource_id" + const val EXTRA_ACCESSIBILITY_UNIQUE_ID = "extra_accessibility_unique_id" + const val EXTRA_ACCESSIBILITY_ACTIONS = "extra_accessibility_actions" + const val EXTRA_ACCESSIBILITY_NODE_ACTION = "extra_accessibility_node_action" + // DON'T CHANGE THESE. Used for JSON serialization and parsing. const val NAME_ACTION_TYPE = "type" const val NAME_DATA = "data" @@ -153,6 +166,7 @@ data class ActionEntity( INTENT, PHONE_CALL, SOUND, + INTERACT_UI_ELEMENT, } constructor( diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt index f35832c726..c79ea9cb66 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt @@ -85,6 +85,8 @@ data class ConstraintEntity( const val CHARGING = "charging" const val DISCHARGING = "discharging" + const val TIME = "time" + const val EXTRA_PACKAGE_NAME = "extra_package_name" const val EXTRA_BT_ADDRESS = "extra_bluetooth_device_address" const val EXTRA_BT_NAME = "extra_bluetooth_device_name" @@ -93,6 +95,12 @@ data class ConstraintEntity( const val EXTRA_IME_ID = "extra_ime_id" const val EXTRA_IME_LABEL = "extra_ime_label" + /** + * The time is stored in the following format: 20:25. + */ + const val EXTRA_START_TIME = "extra_start_time" + const val EXTRA_END_TIME = "extra_end_time" + val DESERIALIZER = jsonDeserializer { val type by it.json.byString(NAME_TYPE) diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration18To19.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration18To19.kt new file mode 100644 index 0000000000..6ee2b50840 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration18To19.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.data.migration + +import androidx.room.migration.AutoMigrationSpec + +class AutoMigration18To19 : AutoMigrationSpec diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration19To20.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration19To20.kt new file mode 100644 index 0000000000..edec936175 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration19To20.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.data.migration + +import androidx.room.migration.AutoMigrationSpec + +class AutoMigration19To20 : AutoMigrationSpec diff --git a/app/src/main/java/io/github/sds100/keymapper/data/repositories/AccessibilityNodeRepository.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/AccessibilityNodeRepository.kt new file mode 100644 index 0000000000..8783d66dd5 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/AccessibilityNodeRepository.kt @@ -0,0 +1,60 @@ +package io.github.sds100.keymapper.data.repositories + +import android.database.sqlite.SQLiteConstraintException +import io.github.sds100.keymapper.data.db.dao.AccessibilityNodeDao +import io.github.sds100.keymapper.data.entities.AccessibilityNodeEntity +import io.github.sds100.keymapper.util.State +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +interface AccessibilityNodeRepository { + val nodes: Flow>> + suspend fun get(id: Long): AccessibilityNodeEntity? + fun insert(vararg node: AccessibilityNodeEntity) + suspend fun deleteAll() +} + +class AccessibilityNodeRepositoryImpl( + private val coroutineScope: CoroutineScope, + private val dao: AccessibilityNodeDao, +) : AccessibilityNodeRepository { + + override val nodes: StateFlow>> = + dao.getAll() + .map { list -> + // Distinct by all fields except the ID. + State.Data(list.distinctBy { it.copy(id = 0) }) + } + .flowOn(Dispatchers.IO) + .stateIn(coroutineScope, SharingStarted.WhileSubscribed(10000), State.Loading) + + override fun insert(vararg node: AccessibilityNodeEntity) { + coroutineScope.launch(Dispatchers.IO) { + for (n in node) { + try { + dao.insert(n) + } catch (e: SQLiteConstraintException) { + // Do nothing if the node already exists. + } + } + } + } + + override suspend fun get(id: Long): AccessibilityNodeEntity? { + return dao.getById(id) + } + + override suspend fun deleteAll() { + withContext(Dispatchers.IO) { + dao.deleteAll() + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/logging/LogFragment.kt b/app/src/main/java/io/github/sds100/keymapper/logging/LogFragment.kt index 6e1effbd57..50760f51d0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/logging/LogFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/logging/LogFragment.kt @@ -44,7 +44,7 @@ class LogFragment : SimpleRecyclerViewFragment() { private val recyclerViewController by lazy { RecyclerViewController() } private val saveLogToFileLauncher = - registerForActivityResult(CreateDocument(FileUtils.MIME_TYPE_ZIP)) { + registerForActivityResult(CreateDocument(FileUtils.MIME_TYPE_TEXT)) { it ?: return@registerForActivityResult viewModel.onPickFileToSaveTo(it.toString()) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/PauseKeyMapsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/PauseKeyMapsUseCase.kt index 3214f13d19..82c4dc88fe 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/PauseKeyMapsUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/PauseKeyMapsUseCase.kt @@ -21,7 +21,7 @@ class PauseKeyMapsUseCaseImpl( override fun pause() { preferenceRepository.set(Keys.mappingsPaused, true) - mediaAdapter.stopMedia() + mediaAdapter.stopFileMedia() Timber.d("Pause mappings") } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt index 5f69f2bff7..842264bfea 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.mappings.keymaps +import android.database.sqlite.SQLiteConstraintException import io.github.sds100.keymapper.actions.Action import io.github.sds100.keymapper.actions.ActionData import io.github.sds100.keymapper.actions.RepeatMode @@ -851,7 +852,12 @@ class ConfigKeyMapUseCaseController( val keyMap = keyMap.value.dataOrNull() ?: return if (keyMap.dbId == null) { - keyMapRepository.insert(KeyMapEntityMapper.toEntity(keyMap, 0)) + val entity = KeyMapEntityMapper.toEntity(keyMap, 0) + try { + keyMapRepository.insert(entity) + } catch (e: SQLiteConstraintException) { + keyMapRepository.update(entity) + } } else { keyMapRepository.update(KeyMapEntityMapper.toEntity(keyMap, keyMap.dbId)) } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ShortcutRow.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ShortcutRow.kt index d8a147549a..6c157e7ace 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ShortcutRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ShortcutRow.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext 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 com.google.accompanist.drawablepainter.rememberDrawablePainter @@ -97,6 +98,8 @@ private fun ShortcutButton( text = text, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -140,3 +143,24 @@ private fun PreviewDrawable() { } } } + +@Preview +@Composable +private fun PreviewMultipleLines() { + val ctx = LocalContext.current + val icon = ctx.drawable(R.mipmap.ic_launcher_round) + + KeyMapperTheme { + Surface { + ShortcutRow( + shortcuts = setOf( + ShortcutModel( + icon = ComposeIconInfo.Drawable(icon), + text = "Line 1\nLine 2\nLine 3", + data = TriggerKeyShortcut.FINGERPRINT_GESTURE, + ), + ), + ) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectKeyMapsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectKeyMapsUseCase.kt index fafed46f55..b7db65f9c4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectKeyMapsUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectKeyMapsUseCase.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.mappings.keymaps.detection import android.accessibilityservice.AccessibilityService import android.os.SystemClock +import android.view.InputDevice import android.view.KeyEvent import io.github.sds100.keymapper.R import io.github.sds100.keymapper.constraints.ConstraintState @@ -193,6 +194,7 @@ class DetectKeyMapsUseCaseImpl( deviceId: Int, inputEventType: InputEventType, scanCode: Int, + source: Int, ) { val model = InputKeyModel( keyCode, @@ -200,6 +202,7 @@ class DetectKeyMapsUseCaseImpl( metaState, deviceId, scanCode, + source = source, ) if (permissionAdapter.isGranted(Permission.SHIZUKU)) { @@ -258,6 +261,7 @@ interface DetectKeyMapsUseCase { deviceId: Int = 0, inputEventType: InputEventType = InputEventType.DOWN_UP, scanCode: Int = 0, + source: Int = InputDevice.SOURCE_UNKNOWN, ) val isScreenOn: Flow diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectScreenOffKeyEventsController.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectScreenOffKeyEventsController.kt index beb9f826eb..600e9fab2f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectScreenOffKeyEventsController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DetectScreenOffKeyEventsController.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.mappings.keymaps.detection +import android.view.InputDevice import android.view.KeyEvent import io.github.sds100.keymapper.system.devices.DevicesAdapter import io.github.sds100.keymapper.system.devices.InputDeviceInfo @@ -90,6 +91,7 @@ class DetectScreenOffKeyEventsController( scanCode = 0, metaState = 0, repeatCount = 0, + source = InputDevice.SOURCE_UNKNOWN, ), ) } @@ -103,6 +105,7 @@ class DetectScreenOffKeyEventsController( scanCode = 0, metaState = 0, repeatCount = 0, + source = InputDevice.SOURCE_UNKNOWN, ), ) } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DpadMotionEventTracker.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DpadMotionEventTracker.kt index 202ba121ad..0266b50cfa 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DpadMotionEventTracker.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/DpadMotionEventTracker.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.mappings.keymaps.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 @@ -107,6 +108,7 @@ class DpadMotionEventTracker { scanCode = 0, device = event.device, repeatCount = 0, + source = InputDevice.SOURCE_DPAD, ) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyListItem.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyListItem.kt index 46f27f7661..4b44d2ea23 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyListItem.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyListItem.kt @@ -42,10 +42,10 @@ 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.R -import io.github.sds100.keymapper.compose.draggable.DragDropState import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.FingerprintGestureType import io.github.sds100.keymapper.util.ui.LinkType +import io.github.sds100.keymapper.util.ui.compose.DragDropState @Composable fun TriggerKeyListItem( diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt index c56c550109..f20a2ad284 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerScreen.kt @@ -39,15 +39,15 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.window.core.layout.WindowHeightSizeClass import io.github.sds100.keymapper.R import io.github.sds100.keymapper.compose.KeyMapperTheme -import io.github.sds100.keymapper.compose.draggable.DraggableItem -import io.github.sds100.keymapper.compose.draggable.rememberDragDropState import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.keymaps.ShortcutModel import io.github.sds100.keymapper.mappings.keymaps.ShortcutRow import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.ui.LinkType import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.util.ui.compose.DraggableItem import io.github.sds100.keymapper.util.ui.compose.RadioButtonText +import io.github.sds100.keymapper.util.ui.compose.rememberDragDropState @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/io/github/sds100/keymapper/shizuku/ShizukuInputEventInjector.kt b/app/src/main/java/io/github/sds100/keymapper/shizuku/ShizukuInputEventInjector.kt index 9608b58f1c..2b812d21c0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/shizuku/ShizukuInputEventInjector.kt +++ b/app/src/main/java/io/github/sds100/keymapper/shizuku/ShizukuInputEventInjector.kt @@ -8,7 +8,6 @@ import android.view.KeyEvent import io.github.sds100.keymapper.system.inputevents.InputEventInjector import io.github.sds100.keymapper.system.inputmethod.InputKeyModel import io.github.sds100.keymapper.util.InputEventType -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import rikka.shizuku.ShizukuBinderWrapper @@ -16,7 +15,7 @@ import rikka.shizuku.SystemServiceHelper import timber.log.Timber @SuppressLint("PrivateApi") -class ShizukuInputEventInjector(private val coroutineScope: CoroutineScope) : InputEventInjector { +class ShizukuInputEventInjector : InputEventInjector { companion object { // private const val INJECT_INPUT_EVENT_MODE_ASYNC = 0 @@ -40,16 +39,7 @@ class ShizukuInputEventInjector(private val coroutineScope: CoroutineScope) : In val eventTime = SystemClock.uptimeMillis() - val keyEvent = KeyEvent( - eventTime, - eventTime, - action, - model.keyCode, - model.repeat, - model.metaState, - model.deviceId, - model.scanCode, - ) + val keyEvent = createInjectedKeyEvent(eventTime, action, model) withContext(Dispatchers.IO) { // MUST wait for the application to finish processing the event before sending the next one. diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/SortBottomSheetContent.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/SortBottomSheetContent.kt index fb80600a7d..fbfedd964e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/sorting/SortBottomSheetContent.kt +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/SortBottomSheetContent.kt @@ -70,8 +70,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.sds100.keymapper.R import io.github.sds100.keymapper.compose.KeyMapperTheme -import io.github.sds100.keymapper.compose.draggable.DraggableItem -import io.github.sds100.keymapper.compose.draggable.rememberDragDropState +import io.github.sds100.keymapper.util.ui.compose.DraggableItem +import io.github.sds100.keymapper.util.ui.compose.rememberDragDropState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch diff --git a/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapConstraintsComparator.kt b/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapConstraintsComparator.kt index 156a9a0061..7ba746f11e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapConstraintsComparator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/sorting/comparators/KeyMapConstraintsComparator.kt @@ -7,6 +7,8 @@ import io.github.sds100.keymapper.util.Result import io.github.sds100.keymapper.util.Success import io.github.sds100.keymapper.util.then import io.github.sds100.keymapper.util.valueOrNull +import java.time.LocalDate +import java.time.ZoneOffset class KeyMapConstraintsComparator( private val displayConstraints: DisplayConstraintUseCase, @@ -125,6 +127,11 @@ class KeyMapConstraintsComparator( is Constraint.WifiOn -> Success("") is Constraint.LockScreenNotShowing -> Success("") is Constraint.LockScreenShowing -> Success("") + is Constraint.Time -> Success( + constraint.startTime + .toEpochSecond(LocalDate.now(), ZoneOffset.UTC) + .toString(), + ) } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityEventModel.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityEventModel.kt deleted file mode 100644 index 3e0675d83d..0000000000 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityEventModel.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.github.sds100.keymapper.system.accessibility - -/** - * Created by sds100 on 27/07/2021. - */ -data class AccessibilityEventModel(val eventTime: Long, val eventType: Int) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityNodeModel.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityNodeModel.kt index 934c0ace2f..08f5a14e04 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityNodeModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityNodeModel.kt @@ -1,8 +1,13 @@ package io.github.sds100.keymapper.system.accessibility +import android.os.Build +import androidx.annotation.RequiresApi +import kotlinx.serialization.Serializable + /** * Created by sds100 on 21/04/2021. */ +@Serializable data class AccessibilityNodeModel( val packageName: String?, val contentDescription: String?, @@ -11,4 +16,14 @@ data class AccessibilityNodeModel( val textSelectionStart: Int, val textSelectionEnd: Int, val isEditable: Boolean, + val className: String?, + val viewResourceId: String?, + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + val uniqueId: String?, + + /** + * A list of the allowed accessibility node actions. + */ + val actions: List, ) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityNodeRecorder.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityNodeRecorder.kt new file mode 100644 index 0000000000..becc2fcf78 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityNodeRecorder.kt @@ -0,0 +1,152 @@ +package io.github.sds100.keymapper.system.accessibility + +import android.accessibilityservice.AccessibilityService +import android.os.Build +import android.os.CountDownTimer +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import io.github.sds100.keymapper.actions.uielement.NodeInteractionType +import io.github.sds100.keymapper.data.entities.AccessibilityNodeEntity +import io.github.sds100.keymapper.data.repositories.AccessibilityNodeRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class AccessibilityNodeRecorder( + private val nodeRepository: AccessibilityNodeRepository, + private val service: AccessibilityService, +) { + companion object { + private const val RECORD_DURATION = 60000L + } + + private val timerLock = Any() + private var timer: CountDownTimer? = null + private val _recordState: MutableStateFlow = + MutableStateFlow(RecordAccessibilityNodeState.Idle) + val recordState = _recordState.asStateFlow() + + fun startRecording() { + synchronized(timerLock) { + timer?.cancel() + timer = object : CountDownTimer(RECORD_DURATION, 1000) { + + override fun onTick(millisUntilFinished: Long) { + _recordState.update { + RecordAccessibilityNodeState.CountingDown( + timeLeft = (millisUntilFinished / 1000).toInt(), + ) + } + } + + override fun onFinish() { + _recordState.update { RecordAccessibilityNodeState.Idle } + } + } + + timer!!.start() + } + } + + fun stopRecording() { + synchronized(timerLock) { + timer?.cancel() + timer = null + _recordState.update { RecordAccessibilityNodeState.Idle } + } + } + + fun onAccessibilityEvent(event: AccessibilityEvent) { + if (_recordState.value is RecordAccessibilityNodeState.Idle) { + return + } + + if (event.eventType == AccessibilityEvent.TYPE_VIEW_CLICKED || + event.eventType == AccessibilityEvent.TYPE_VIEW_FOCUSED + ) { + val source = event.source ?: return + + buildNodeEntity(source, interacted = true)?.also { nodeRepository.insert(it) } + } else if (event.eventType == AccessibilityEvent.TYPE_WINDOWS_CHANGED) { + // Only dump the whole window when a window is added because there can be + // many windows changed events sent in rapid succession. + val windowRoot: AccessibilityNodeInfo = service.rootInActiveWindow ?: return + + // This searches for all nodes that are within the bounds of the source of the + // AccessibilityEvent because the source is not necessarily the element + // the user wants to tap. + val entities = getNodesRecursively(windowRoot).toTypedArray() + nodeRepository.insert(*entities) + } + } + + private fun getNodesRecursively( + node: AccessibilityNodeInfo, + ): Set { + val set = mutableSetOf() + + val entity = buildNodeEntity(node, interacted = false) + + if (entity != null) { + set.add(entity) + } + + if (node.childCount > 0) { + for (i in 0 until node.childCount) { + val child = node.getChild(i) ?: continue + + set.addAll(getNodesRecursively(child)) + } + } + + return set + } + + /** + * @param interacted Whether the user interacted with this node. + */ + private fun buildNodeEntity( + source: AccessibilityNodeInfo, + interacted: Boolean, + ): AccessibilityNodeEntity? { + val interactionTypes = source.actionList.mapNotNull { action -> + NodeInteractionType.entries.find { it.accessibilityActionId == action.id } + }.distinct() + + if (interactionTypes.isEmpty()) { + return null + } + + return AccessibilityNodeEntity( + packageName = source.packageName.toString(), + text = source.text?.toString(), + contentDescription = source.contentDescription?.toString(), + className = source.className?.toString(), + viewResourceId = source.viewIdResourceName, + uniqueId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + source.uniqueId + } else { + null + }, + actions = interactionTypes.toSet(), + interacted = interacted, + tooltip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + source.tooltipText?.toString() + } else { + null + }, + hint = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + source.hintText?.toString() + } else { + null + }, + ) + } + + fun teardown() { + synchronized(timerLock) { + timer?.cancel() + timer = null + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityUtils.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityUtils.kt index 1f16e4da93..773486c357 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityUtils.kt @@ -1,6 +1,6 @@ package io.github.sds100.keymapper.system.accessibility -import android.view.accessibility.AccessibilityEvent +import android.os.Build import android.view.accessibility.AccessibilityNodeInfo /** @@ -38,6 +38,12 @@ fun AccessibilityNodeInfo.toModel(): AccessibilityNodeModel = AccessibilityNodeM textSelectionEnd = textSelectionEnd, text = text?.toString(), isEditable = isEditable, + className = className?.toString(), + uniqueId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + uniqueId + } else { + null + }, + viewResourceId = viewIdResourceName, + actions = actionList.map { it.id }, ) - -fun AccessibilityEvent.toModel(): AccessibilityEventModel = AccessibilityEventModel(eventTime, eventType) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt index f29c038ee8..cd1a7ef200 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt @@ -11,6 +11,7 @@ import io.github.sds100.keymapper.actions.PerformActionsUseCase import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.PreferenceDefaults +import io.github.sds100.keymapper.data.repositories.AccessibilityNodeRepository import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.FingerprintGestureType import io.github.sds100.keymapper.mappings.FingerprintGesturesSupportedUseCase @@ -40,6 +41,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop @@ -47,6 +50,7 @@ import kotlinx.coroutines.flow.launchIn 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 @@ -60,7 +64,7 @@ import timber.log.Timber */ abstract class BaseAccessibilityServiceController( private val coroutineScope: CoroutineScope, - private val accessibilityService: IAccessibilityService, + private val service: MyAccessibilityService, private val inputEvents: SharedFlow, private val outputEvents: MutableSharedFlow, private val detectConstraintsUseCase: DetectConstraintsUseCase, @@ -73,6 +77,7 @@ abstract class BaseAccessibilityServiceController( private val suAdapter: SuAdapter, private val inputMethodAdapter: InputMethodAdapter, private val settingsRepository: PreferenceRepository, + private val nodeRepository: AccessibilityNodeRepository, ) { companion object { @@ -80,6 +85,8 @@ abstract class BaseAccessibilityServiceController( * 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 val triggerKeyMapFromOtherAppsController = TriggerKeyMapFromOtherAppsController( @@ -101,6 +108,9 @@ abstract class BaseAccessibilityServiceController( rerouteKeyEventsUseCase, ) + private val accessibilityNodeRecorder: AccessibilityNodeRecorder = + AccessibilityNodeRecorder(nodeRepository, service) + private var recordingTriggerJob: Job? = null private val recordingTrigger: Boolean get() = recordingTriggerJob != null && recordingTriggerJob?.isActive == true @@ -114,7 +124,7 @@ abstract class BaseAccessibilityServiceController( detectKeyMapsUseCase.detectScreenOffTriggers .stateIn(coroutineScope, SharingStarted.Eagerly, false) - private val changeImeOnInputFocus: StateFlow = + private val changeImeOnInputFocusFlow: StateFlow = settingsRepository .get(Keys.changeImeOnInputFocus) .map { it ?: PreferenceDefaults.CHANGE_IME_ON_INPUT_FOCUS } @@ -145,6 +155,7 @@ abstract class BaseAccessibilityServiceController( // This is required for receive TYPE_WINDOWS_CHANGED events so can // 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) @@ -170,25 +181,36 @@ abstract class BaseAccessibilityServiceController( val serviceEventTypes: MutableStateFlow = MutableStateFlow(AccessibilityEvent.TYPE_WINDOWS_CHANGED) + private val serviceNotificationTimeout: MutableStateFlow = + MutableStateFlow(DEFAULT_NOTIFICATION_TIMEOUT) + init { + serviceFlags.onEach { flags -> // check that it isn't null because this can only be called once the service is bound - if (accessibilityService.serviceFlags != null) { - accessibilityService.serviceFlags = flags + if (service.serviceFlags != null) { + service.serviceFlags = flags } }.launchIn(coroutineScope) serviceFeedbackType.onEach { feedbackType -> // check that it isn't null because this can only be called once the service is bound - if (accessibilityService.serviceFeedbackType != null) { - accessibilityService.serviceFeedbackType = feedbackType + if (service.serviceFeedbackType != null) { + service.serviceFeedbackType = feedbackType } }.launchIn(coroutineScope) serviceEventTypes.onEach { eventTypes -> // check that it isn't null because this can only be called once the service is bound - if (accessibilityService.serviceEventTypes != null) { - accessibilityService.serviceEventTypes = eventTypes + if (service.serviceEventTypes != null) { + service.serviceEventTypes = eventTypes + } + }.launchIn(coroutineScope) + + serviceNotificationTimeout.onEach { timeout -> + // check that it isn't null because this can only be called once the service is bound + if (service.notificationTimeout != null) { + service.notificationTimeout = timeout } }.launchIn(coroutineScope) @@ -224,7 +246,7 @@ abstract class BaseAccessibilityServiceController( onEventFromUi(it) }.launchIn(coroutineScope) - accessibilityService.isKeyboardHidden + service.isKeyboardHidden .drop(1) // Don't send it when collecting initially .onEach { isHidden -> if (isHidden) { @@ -255,23 +277,59 @@ abstract class BaseAccessibilityServiceController( } }.launchIn(coroutineScope) - changeImeOnInputFocus.onEach { changeImeOnInputFocus -> - if (changeImeOnInputFocus) { - serviceEventTypes.value = serviceEventTypes.value - .withFlag(AccessibilityEvent.TYPE_VIEW_FOCUSED) - .withFlag(AccessibilityEvent.TYPE_VIEW_CLICKED) - } else { - serviceEventTypes.value = serviceEventTypes.value - .minusFlag(AccessibilityEvent.TYPE_VIEW_FOCUSED) - .minusFlag(AccessibilityEvent.TYPE_VIEW_CLICKED) + coroutineScope.launch { + accessibilityNodeRecorder.recordState.collectLatest { state -> + outputEvents.emit(ServiceEvent.OnRecordNodeStateChanged(state)) } - }.launchIn(coroutineScope) + } + + val imeInputFocusEvents = + AccessibilityEvent.TYPE_VIEW_FOCUSED or AccessibilityEvent.TYPE_VIEW_CLICKED + + val recordNodeEvents = + AccessibilityEvent.TYPE_VIEW_FOCUSED or AccessibilityEvent.TYPE_VIEW_CLICKED + + coroutineScope.launch { + combine( + changeImeOnInputFocusFlow, + accessibilityNodeRecorder.recordState, + ) { changeImeOnInputFocus, recordState -> + + serviceEventTypes.update { eventTypes -> + var newEventTypes = eventTypes + + if (!changeImeOnInputFocus && recordState == RecordAccessibilityNodeState.Idle) { + newEventTypes = + newEventTypes and (imeInputFocusEvents or recordNodeEvents).inv() + } else { + if (changeImeOnInputFocus) { + newEventTypes = newEventTypes or imeInputFocusEvents + } + + if (recordState is RecordAccessibilityNodeState.CountingDown) { + newEventTypes = newEventTypes or recordNodeEvents + } + } + + newEventTypes + } + + serviceNotificationTimeout.update { + if (recordState is RecordAccessibilityNodeState.CountingDown) { + 0L + } else { + DEFAULT_NOTIFICATION_TIMEOUT + } + } + }.collect() + } } open fun onServiceConnected() { - accessibilityService.serviceFlags = serviceFlags.value - accessibilityService.serviceFeedbackType = serviceFeedbackType.value - accessibilityService.serviceEventTypes = serviceEventTypes.value + service.serviceFlags = serviceFlags.value + service.serviceFeedbackType = serviceFeedbackType.value + service.serviceEventTypes = serviceEventTypes.value + service.notificationTimeout = serviceNotificationTimeout.value // check if fingerprint gestures are supported if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -284,7 +342,7 @@ abstract class BaseAccessibilityServiceController( * used while this is called. */ if (fingerprintGesturesSupported.isSupported.firstBlocking() != true) { fingerprintGesturesSupported.setSupported( - accessibilityService.isFingerprintGestureDetectionAvailable, + service.isFingerprintGestureDetectionAvailable, ) } @@ -294,6 +352,10 @@ abstract class BaseAccessibilityServiceController( } } + open fun onDestroy() { + accessibilityNodeRecorder.teardown() + } + open fun onConfigurationChanged(newConfig: Configuration) { } @@ -441,10 +503,12 @@ abstract class BaseAccessibilityServiceController( } } - open fun onAccessibilityEvent(event: AccessibilityEventModel) { - if (changeImeOnInputFocus.value) { + open fun onAccessibilityEvent(event: AccessibilityEvent) { + accessibilityNodeRecorder.onAccessibilityEvent(event) + + if (changeImeOnInputFocusFlow.value) { val focussedNode = - accessibilityService.findFocussedNode(AccessibilityNodeInfo.FOCUS_INPUT) + service.findFocussedNode(AccessibilityNodeInfo.FOCUS_INPUT) if (focussedNode?.isEditable == true && focussedNode.isFocused) { Timber.d("Got input focus") @@ -501,17 +565,25 @@ abstract class BaseAccessibilityServiceController( outputEvents.emit(ServiceEvent.Pong(event.key)) } - is ServiceEvent.HideKeyboard -> accessibilityService.hideKeyboard() - is ServiceEvent.ShowKeyboard -> accessibilityService.showKeyboard() - is ServiceEvent.ChangeIme -> accessibilityService.switchIme(event.imeId) + is ServiceEvent.HideKeyboard -> service.hideKeyboard() + is ServiceEvent.ShowKeyboard -> service.showKeyboard() + is ServiceEvent.ChangeIme -> service.switchIme(event.imeId) is ServiceEvent.DisableService -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - accessibilityService.disableSelf() + service.disableSelf() } is ServiceEvent.TriggerKeyMap -> triggerKeyMapFromIntent(event.uid) is ServiceEvent.EnableInputMethod -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - accessibilityService.setInputMethodEnabled(event.imeId, true) + service.setInputMethodEnabled(event.imeId, true) + } + + is ServiceEvent.StartRecordingNodes -> { + accessibilityNodeRecorder.startRecording() + } + + is ServiceEvent.StopRecordingNodes -> { + accessibilityNodeRecorder.stopRecording() } else -> Unit diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/IAccessibilityService.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/IAccessibilityService.kt index 59d9ac5e42..4e45865eb9 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/IAccessibilityService.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/IAccessibilityService.kt @@ -40,6 +40,7 @@ interface IAccessibilityService { var serviceFlags: Int? var serviceFeedbackType: Int? var serviceEventTypes: Int? + var notificationTimeout: Long? fun performActionOnNode( findNode: (node: AccessibilityNodeModel) -> Boolean, 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 2fdae08f4f..81871a3575 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 @@ -22,7 +22,9 @@ import androidx.lifecycle.LifecycleRegistry import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner +import io.github.sds100.keymapper.Constants import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.ServiceLocator import io.github.sds100.keymapper.actions.pinchscreen.PinchScreenType import io.github.sds100.keymapper.api.IKeyEventRelayServiceCallback import io.github.sds100.keymapper.api.KeyEventRelayService @@ -38,6 +40,7 @@ import io.github.sds100.keymapper.util.InputEventType import io.github.sds100.keymapper.util.MathUtils import io.github.sds100.keymapper.util.Result import io.github.sds100.keymapper.util.Success +import io.github.sds100.keymapper.util.onSuccess import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -121,6 +124,16 @@ class MyAccessibilityService : } } + override var notificationTimeout: Long? + get() = serviceInfo?.notificationTimeout + set(value) { + if (serviceInfo != null && value != null) { + serviceInfo = serviceInfo.apply { + notificationTimeout = value + } + } + } + private val relayServiceCallback: IKeyEventRelayServiceCallback = object : IKeyEventRelayServiceCallback.Stub() { override fun onKeyEvent(event: KeyEvent?): Boolean { @@ -137,6 +150,7 @@ class MyAccessibilityService : scanCode = event.scanCode, device = device, repeatCount = event.repeatCount, + source = event.source, ), ) } @@ -188,6 +202,14 @@ class MyAccessibilityService : override fun onServiceConnected() { super.onServiceConnected() + val inputMethodAdapter = ServiceLocator.inputMethodAdapter(this) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + inputMethodAdapter.getInfoByPackageName(Constants.PACKAGE_NAME).onSuccess { + softKeyboardController.setInputMethodEnabled(it.id, true) + softKeyboardController.switchToInputMethod(it.id) + } + } Timber.i("Accessibility service: onServiceConnected") lifecycleRegistry.currentState = Lifecycle.State.STARTED @@ -244,6 +266,7 @@ class MyAccessibilityService : override fun onInterrupt() {} override fun onDestroy() { + controller?.onDestroy() controller = null lifecycleRegistry.currentState = Lifecycle.State.DESTROYED @@ -282,7 +305,7 @@ class MyAccessibilityService : _activeWindowPackage.update { rootInActiveWindow?.packageName?.toString() } } - controller?.onAccessibilityEvent(event.toModel()) + controller?.onAccessibilityEvent(event) } override fun onKeyEvent(event: KeyEvent?): Boolean { @@ -303,6 +326,7 @@ class MyAccessibilityService : scanCode = event.scanCode, device = device, repeatCount = event.repeatCount, + source = event.source, ), KeyEventDetectionSource.ACCESSIBILITY_SERVICE, ) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/RecordAccessibilityNodeState.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/RecordAccessibilityNodeState.kt new file mode 100644 index 0000000000..ea1e26d091 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/RecordAccessibilityNodeState.kt @@ -0,0 +1,15 @@ +package io.github.sds100.keymapper.system.accessibility + +import kotlinx.serialization.Serializable + +@Serializable +sealed class RecordAccessibilityNodeState { + data object Idle : RecordAccessibilityNodeState() + + data class CountingDown( + /** + * The time left in seconds + */ + val timeLeft: Int, + ) : RecordAccessibilityNodeState() +} diff --git a/app/src/main/java/io/github/sds100/keymapper/system/apps/ChooseAppScreen.kt b/app/src/main/java/io/github/sds100/keymapper/system/apps/ChooseAppScreen.kt new file mode 100644 index 0000000000..ca05260be4 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/system/apps/ChooseAppScreen.kt @@ -0,0 +1,216 @@ +package io.github.sds100.keymapper.system.apps + +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.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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.util.State +import io.github.sds100.keymapper.util.drawable +import io.github.sds100.keymapper.util.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.util.ui.compose.SearchAppBarActions +import io.github.sds100.keymapper.util.ui.compose.SimpleListItem +import io.github.sds100.keymapper.util.ui.compose.SimpleListItemModel + +@Composable +fun ChooseAppScreen( + modifier: Modifier = Modifier, + title: String, + state: State>, + query: String? = null, + onQueryChange: (String) -> Unit = {}, + onCloseSearch: () -> Unit = {}, + onNavigateBack: () -> Unit = {}, + onClickApp: (String) -> Unit = {}, +) { + Scaffold( + modifier.displayCutoutPadding(), + bottomBar = { + BottomAppBar( + modifier = Modifier.imePadding(), + actions = { + SearchAppBarActions( + onCloseSearch = onCloseSearch, + onNavigateBack = onNavigateBack, + onQueryChange = onQueryChange, + enabled = state is State.Data, + query = query, + ) + }, + ) + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + Column { + Text( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = 8.dp, + ), + text = title, + style = MaterialTheme.typography.titleLarge, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + when (state) { + State.Loading -> LoadingScreen(modifier = Modifier.fillMaxSize()) + is State.Data -> { + val items = state.data + + if (items.isEmpty()) { + EmptyScreen(modifier = Modifier.fillMaxSize()) + } else { + ListScreen( + modifier = Modifier.fillMaxSize(), + listItems = items, + onClick = onClickApp, + ) + } + } + } + } + } + } +} + +@Composable +private fun LoadingScreen(modifier: Modifier = Modifier) { + Box(modifier) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } +} + +@Composable +private fun EmptyScreen(modifier: Modifier = Modifier) { + Box(modifier) { + val shrug = stringResource(R.string.shrug) + val text = stringResource(R.string.app_list_empty) + Text( + modifier = Modifier.align(Alignment.Center), + text = buildAnnotatedString { + withStyle(MaterialTheme.typography.headlineLarge.toSpanStyle()) { + append(shrug) + } + appendLine() + appendLine() + withStyle(MaterialTheme.typography.bodyLarge.toSpanStyle()) { + append(text) + } + }, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun ListScreen( + modifier: Modifier = Modifier, + listItems: List, + onClick: (String) -> Unit, +) { + LazyColumn( + modifier = modifier, + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(listItems) { model -> + SimpleListItem( + modifier = Modifier.fillMaxWidth(), + model = model, + onClick = { onClick(model.id) }, + ) + } + } +} + +@Preview +@Composable +private fun Empty() { + KeyMapperTheme { + ChooseAppScreen(title = "Choose app", state = State.Data(emptyList())) + } +} + +@Preview +@Composable +private fun Loading() { + KeyMapperTheme { + ChooseAppScreen(title = "Choose app", state = State.Loading) + } +} + +@Preview +@Composable +private fun Loaded() { + val icon = LocalContext.current.drawable(R.mipmap.ic_launcher_round) + + KeyMapperTheme { + ChooseAppScreen( + title = "Choose app", + state = State.Data( + listOf( + SimpleListItemModel( + id = "1", + title = "Key Mapper", + icon = ComposeIconInfo.Drawable(icon), + ), + SimpleListItemModel( + id = "2", + title = "Key Mapper", + icon = ComposeIconInfo.Drawable(icon), + ), + SimpleListItemModel( + id = "3", + title = "Key Mapper", + icon = ComposeIconInfo.Drawable(icon), + ), + ), + ), + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/system/devices/AndroidDevicesAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/devices/AndroidDevicesAdapter.kt index daa1799a9a..f593683c97 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/devices/AndroidDevicesAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/devices/AndroidDevicesAdapter.kt @@ -125,8 +125,8 @@ class AndroidDevicesAdapter( } override fun getInputDeviceName(descriptor: String): Result { - InputDevice.getDeviceIds().forEach { - val device = InputDevice.getDevice(it) ?: return@forEach + for (id in InputDevice.getDeviceIds()) { + val device = InputDevice.getDevice(id) ?: continue if (device.descriptor == descriptor) { return Success(device.name) @@ -139,8 +139,8 @@ class AndroidDevicesAdapter( private fun updateInputDevices() { val devices = mutableListOf() - InputDevice.getDeviceIds().forEach { - val device = InputDevice.getDevice(it) ?: return@forEach + for (id in InputDevice.getDeviceIds()) { + val device = InputDevice.getDevice(id) ?: continue devices.add(InputDeviceUtils.createInputDeviceInfo(device)) } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/files/FileUtils.kt b/app/src/main/java/io/github/sds100/keymapper/system/files/FileUtils.kt index d2a4b14628..d870339f02 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/files/FileUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/files/FileUtils.kt @@ -26,6 +26,7 @@ object FileUtils { const val MIME_TYPE_AUDIO = "audio/*" const val MIME_TYPE_ZIP = "application/zip" const val MIME_TYPE_JSON = "text/json" + const val MIME_TYPE_TEXT = "text/plain" @SuppressLint("SimpleDateFormat") fun createFileDate(): String { diff --git a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventInjector.kt b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventInjector.kt index a126bac316..5a680f9893 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventInjector.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventInjector.kt @@ -1,7 +1,27 @@ 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/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt index d5b336eabc..a42c1949fa 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/InputEventUtils.kt @@ -830,4 +830,43 @@ object InputEventUtils { 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, + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BUTTON_C, + KeyEvent.KEYCODE_BUTTON_X, + KeyEvent.KEYCODE_BUTTON_Y, + KeyEvent.KEYCODE_BUTTON_Z, + KeyEvent.KEYCODE_BUTTON_L1, + KeyEvent.KEYCODE_BUTTON_R1, + KeyEvent.KEYCODE_BUTTON_L2, + KeyEvent.KEYCODE_BUTTON_R2, + KeyEvent.KEYCODE_BUTTON_THUMBL, + KeyEvent.KEYCODE_BUTTON_THUMBR, + KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_BUTTON_SELECT, + KeyEvent.KEYCODE_BUTTON_MODE, + KeyEvent.KEYCODE_BUTTON_1, + KeyEvent.KEYCODE_BUTTON_2, + KeyEvent.KEYCODE_BUTTON_3, + KeyEvent.KEYCODE_BUTTON_4, + KeyEvent.KEYCODE_BUTTON_5, + KeyEvent.KEYCODE_BUTTON_6, + KeyEvent.KEYCODE_BUTTON_7, + KeyEvent.KEYCODE_BUTTON_8, + KeyEvent.KEYCODE_BUTTON_9, + KeyEvent.KEYCODE_BUTTON_10, + KeyEvent.KEYCODE_BUTTON_11, + KeyEvent.KEYCODE_BUTTON_12, + KeyEvent.KEYCODE_BUTTON_13, + KeyEvent.KEYCODE_BUTTON_14, + KeyEvent.KEYCODE_BUTTON_15, + KeyEvent.KEYCODE_BUTTON_16, + -> true + + else -> false + } + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/MyKeyEvent.kt b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/MyKeyEvent.kt index 64384acaac..6517eefa26 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/inputevents/MyKeyEvent.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/inputevents/MyKeyEvent.kt @@ -9,4 +9,5 @@ data class MyKeyEvent( val scanCode: Int, val device: InputDeviceInfo?, val repeatCount: Int, + val source: Int, ) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/ImeInputEventInjector.kt b/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/ImeInputEventInjector.kt index 756acde8df..e5ce73abdd 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/ImeInputEventInjector.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/ImeInputEventInjector.kt @@ -82,7 +82,7 @@ class ImeInputEventInjectorImpl( val eventTime = SystemClock.uptimeMillis() - val keyEvent = createKeyEvent(eventTime, action, model) + val keyEvent = createInjectedKeyEvent(eventTime, action, model) putExtra(KEY_MAPPER_INPUT_METHOD_EXTRA_KEY_EVENT, keyEvent) @@ -90,34 +90,19 @@ class ImeInputEventInjectorImpl( } } - private fun createKeyEvent( - eventTime: Long, - action: Int, - model: InputKeyModel, - ): KeyEvent = KeyEvent( - eventTime, - eventTime, - action, - model.keyCode, - model.repeat, - model.metaState, - model.deviceId, - model.scanCode, - ) - private fun inputKeyEventRelayService(model: InputKeyModel, imePackageName: String) { val eventTime = SystemClock.uptimeMillis() when (model.inputType) { InputEventType.DOWN_UP -> { - val downKeyEvent = createKeyEvent(eventTime, KeyEvent.ACTION_DOWN, model) + val downKeyEvent = createInjectedKeyEvent(eventTime, KeyEvent.ACTION_DOWN, model) keyEventRelayService.sendKeyEvent( downKeyEvent, imePackageName, KeyEventRelayService.CALLBACK_ID_INPUT_METHOD, ) - val upKeyEvent = createKeyEvent(eventTime, KeyEvent.ACTION_UP, model) + val upKeyEvent = createInjectedKeyEvent(eventTime, KeyEvent.ACTION_UP, model) keyEventRelayService.sendKeyEvent( upKeyEvent, imePackageName, @@ -126,7 +111,7 @@ class ImeInputEventInjectorImpl( } InputEventType.DOWN -> { - val downKeyEvent = createKeyEvent(eventTime, KeyEvent.ACTION_DOWN, model) + val downKeyEvent = createInjectedKeyEvent(eventTime, KeyEvent.ACTION_DOWN, model) keyEventRelayService.sendKeyEvent( downKeyEvent, imePackageName, @@ -135,7 +120,7 @@ class ImeInputEventInjectorImpl( } InputEventType.UP -> { - val upKeyEvent = createKeyEvent(eventTime, KeyEvent.ACTION_UP, model) + val upKeyEvent = createInjectedKeyEvent(eventTime, KeyEvent.ACTION_UP, model) keyEventRelayService.sendKeyEvent( upKeyEvent, imePackageName, diff --git a/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputKeyModel.kt b/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputKeyModel.kt index ec2942ec0e..17e4a53871 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputKeyModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputKeyModel.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.system.inputmethod +import android.view.InputDevice import io.github.sds100.keymapper.util.InputEventType /** @@ -12,4 +13,5 @@ data class InputKeyModel( val deviceId: Int = 0, val scanCode: Int = 0, val repeat: Int = 0, + val source: Int = InputDevice.SOURCE_UNKNOWN, ) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/media/AndroidMediaAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/media/AndroidMediaAdapter.kt index 7fc9bebaf7..3886863a38 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/media/AndroidMediaAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/media/AndroidMediaAdapter.kt @@ -82,6 +82,34 @@ class AndroidMediaAdapter(context: Context, coroutineScope: CoroutineScope) : Me override fun nextTrack(packageName: String?): Result<*> = sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_NEXT, packageName) + override fun stop(packageName: String?): Result<*> = sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_STOP, packageName) + + override fun stopFileMedia(): Result<*> { + synchronized(mediaPlayerLock) { + mediaPlayer?.stop() + mediaPlayer?.release() + mediaPlayer = null + } + + return Success(Unit) + } + + override fun stepForward(packageName: String?): Result<*> { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_STEP_FORWARD, packageName) + } else { + return Error.SdkVersionTooLow(Build.VERSION_CODES.M) + } + } + + override fun stepBackward(packageName: String?): Result<*> { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_STEP_BACKWARD, packageName) + } else { + return Error.SdkVersionTooLow(Build.VERSION_CODES.M) + } + } + override fun getActiveMediaSessionPackages(): List { return activeMediaSessions.value .filter { it.playbackState?.state == PlaybackState.STATE_PLAYING } @@ -105,7 +133,7 @@ class AndroidMediaAdapter(context: Context, coroutineScope: CoroutineScope) : Me return audioVolumeControlStreams } - override fun playSoundFile(uri: String, stream: VolumeStream): Result<*> { + override fun playFile(uri: String, stream: VolumeStream): Result<*> { try { synchronized(mediaPlayerLock) { mediaPlayer?.stop() @@ -147,16 +175,6 @@ class AndroidMediaAdapter(context: Context, coroutineScope: CoroutineScope) : Me } } - override fun stopMedia(): Result<*> { - synchronized(mediaPlayerLock) { - mediaPlayer?.stop() - mediaPlayer?.release() - mediaPlayer = null - } - - return Success(Unit) - } - fun onActiveMediaSessionChange(mediaSessions: List) { activeMediaSessions.update { mediaSessions } } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/media/MediaAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/media/MediaAdapter.kt index d990c9afe5..c0a4858e81 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/media/MediaAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/media/MediaAdapter.kt @@ -35,7 +35,10 @@ interface MediaAdapter { fun playPause(packageName: String? = null): Result<*> fun previousTrack(packageName: String? = null): Result<*> fun nextTrack(packageName: String? = null): Result<*> + fun stop(packageName: String? = null): Result<*> + fun stepForward(packageName: String? = null): Result<*> + fun stepBackward(packageName: String? = null): Result<*> - fun playSoundFile(uri: String, stream: VolumeStream): Result<*> - fun stopMedia(): Result<*> + fun playFile(uri: String, stream: VolumeStream): Result<*> + fun stopFileMedia(): Result<*> } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt b/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt index 4aa7e57171..8a047e25b0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt @@ -162,6 +162,7 @@ fun Error.getFullMessage(resourceProvider: ResourceProvider): String = when (thi Error.DpadTriggerImeNotSelected -> resourceProvider.getString(R.string.trigger_error_dpad_ime_not_selected) Error.InvalidBackup -> resourceProvider.getString(R.string.error_invalid_backup) Error.MalformedUrl -> resourceProvider.getString(R.string.error_malformed_url) + Error.UiElementNotFound -> resourceProvider.getString(R.string.error_ui_element_not_found) } val Error.isFixable: Boolean diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt index 41a4f69d47..6bb6786364 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt @@ -15,6 +15,7 @@ import io.github.sds100.keymapper.actions.sound.ChooseSoundFileUseCaseImpl import io.github.sds100.keymapper.actions.sound.ChooseSoundFileViewModel import io.github.sds100.keymapper.actions.swipescreen.SwipePickDisplayCoordinateViewModel import io.github.sds100.keymapper.actions.tapscreen.PickDisplayCoordinateViewModel +import io.github.sds100.keymapper.actions.uielement.InteractUiElementViewModel import io.github.sds100.keymapper.api.KeyEventRelayServiceWrapper import io.github.sds100.keymapper.backup.BackupRestoreMappingsUseCaseImpl import io.github.sds100.keymapper.constraints.ChooseConstraintViewModel @@ -229,6 +230,7 @@ object Inject { ), inputMethodAdapter = ServiceLocator.inputMethodAdapter(service), settingsRepository = ServiceLocator.settingsRepository(service), + nodeRepository = ServiceLocator.accessibilityNodeRepository(service), ) fun chooseBluetoothDeviceViewModel(ctx: Context): ChooseBluetoothDeviceViewModel.Factory = ChooseBluetoothDeviceViewModel.Factory( @@ -248,4 +250,11 @@ object Inject { ), ServiceLocator.resourceProvider(ctx), ) + + fun interactUiElementViewModel( + ctx: Context, + ): InteractUiElementViewModel.Factory = InteractUiElementViewModel.Factory( + (ctx.applicationContext as KeyMapperApp).interactUiElementController, + resourceProvider = ServiceLocator.resourceProvider(ctx), + ) } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Result.kt b/app/src/main/java/io/github/sds100/keymapper/util/Result.kt index 0d904a6685..8fe8251769 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Result.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Result.kt @@ -145,6 +145,8 @@ sealed class Error : Result() { */ data object DpadTriggerImeNotSelected : Error() data object MalformedUrl : Error() + + data object UiElementNotFound : Error() } inline fun Result.onSuccess(f: (T) -> Unit): Result { diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ServiceEvent.kt b/app/src/main/java/io/github/sds100/keymapper/util/ServiceEvent.kt index 054dd384fa..4bacac2040 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ServiceEvent.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ServiceEvent.kt @@ -3,6 +3,7 @@ package io.github.sds100.keymapper.util import android.os.Parcelable import io.github.sds100.keymapper.actions.ActionData import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyEventDetectionSource +import io.github.sds100.keymapper.system.accessibility.RecordAccessibilityNodeState import io.github.sds100.keymapper.system.devices.InputDeviceInfo import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -72,4 +73,13 @@ sealed class ServiceEvent { @Serializable data class EnableInputMethod(val imeId: String) : ServiceEvent() + + @Serializable + data object StartRecordingNodes : ServiceEvent() + + @Serializable + data object StopRecordingNodes : ServiceEvent() + + @Serializable + data class OnRecordNodeStateChanged(val state: RecordAccessibilityNodeState) : ServiceEvent() } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/TimeUtils.kt b/app/src/main/java/io/github/sds100/keymapper/util/TimeUtils.kt new file mode 100644 index 0000000000..633e084da4 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/util/TimeUtils.kt @@ -0,0 +1,11 @@ +package io.github.sds100.keymapper.util + +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + +object TimeUtils { + fun localeDateFormatter(style: FormatStyle): DateTimeFormatter { + return DateTimeFormatter.ofLocalizedTime(style).withLocale(Locale.getDefault()) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/util/TreeNode.kt b/app/src/main/java/io/github/sds100/keymapper/util/TreeNode.kt new file mode 100644 index 0000000000..52e63d0e30 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/util/TreeNode.kt @@ -0,0 +1,16 @@ +package io.github.sds100.keymapper.util + +data class TreeNode(val value: T, val children: MutableList> = mutableListOf()) + +inline fun TreeNode.breadFirstTraversal( + action: (T) -> Unit, +) { + val queue = ArrayDeque>() + queue.add(this) + + while (queue.isNotEmpty()) { + val currentNode = queue.removeFirst() + action(currentNode.value) + queue.addAll(currentNode.children) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt index 4e3ede84f8..de4cb1bea8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt @@ -36,6 +36,7 @@ sealed class NavDestination { const val ID_CONFIG_KEY_MAP = "config_key_map" const val ID_SHIZUKU_SETTINGS = "shizuku_settings" const val ID_CONFIG_FLOATING_BUTTON = "config_floating_button" + const val ID_INTERACT_UI_ELEMENT_ACTION = "interact_ui_element_action" } data class ChooseApp( @@ -123,4 +124,8 @@ sealed class NavDestination { data class ConfigFloatingButton(val buttonUid: String?) : NavDestination() { override val id: String = ID_CONFIG_FLOATING_BUTTON } + + data class InteractUiElement(val action: ActionData.InteractUiElement?) : NavDestination() { + override val id: String = ID_INTERACT_UI_ELEMENT_ACTION + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt index eaf76d4b9d..fc9c239439 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt @@ -20,6 +20,7 @@ import io.github.sds100.keymapper.actions.swipescreen.SwipePickCoordinateResult import io.github.sds100.keymapper.actions.swipescreen.SwipePickDisplayCoordinateFragment import io.github.sds100.keymapper.actions.tapscreen.PickCoordinateResult import io.github.sds100.keymapper.actions.tapscreen.PickDisplayCoordinateFragment +import io.github.sds100.keymapper.actions.uielement.InteractUiElementFragment import io.github.sds100.keymapper.constraints.ChooseConstraintFragment import io.github.sds100.keymapper.constraints.Constraint import io.github.sds100.keymapper.system.apps.ActivityInfo @@ -42,7 +43,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.runBlocking -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json /** @@ -225,6 +225,11 @@ fun NavigationViewModel.setupNavigation(fragment: Fragment) { is NavDestination.ConfigFloatingButton -> NavAppDirections.toConfigFloatingButton( destination.buttonUid, ) + + is NavDestination.InteractUiElement -> NavAppDirections.interactUiElement( + requestKey = requestKey, + action = destination.action?.let { Json.encodeToString(destination.action) }, + ) } fragment.findNavController().navigate(direction) @@ -326,5 +331,11 @@ fun NavigationViewModel.sendNavResultFromBundle( onNavResult(NavResult(requestKey, BluetoothDeviceInfo(address, name))) } + + NavDestination.ID_INTERACT_UI_ELEMENT_ACTION -> { + val json = bundle.getString(InteractUiElementFragment.EXTRA_ACTION)!! + val result = Json.decodeFromString(json) + onNavResult(NavResult(requestKey, result)) + } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/compose/draggable/DragDropState.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/DragDropState.kt similarity index 99% rename from app/src/main/java/io/github/sds100/keymapper/compose/draggable/DragDropState.kt rename to app/src/main/java/io/github/sds100/keymapper/util/ui/compose/DragDropState.kt index 8d6d6a345a..2dfb597db2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/compose/draggable/DragDropState.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/DragDropState.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.compose.draggable +package io.github.sds100.keymapper.util.ui.compose import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring diff --git a/app/src/main/java/io/github/sds100/keymapper/compose/draggable/DraggableItem.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/DraggableItem.kt similarity index 95% rename from app/src/main/java/io/github/sds100/keymapper/compose/draggable/DraggableItem.kt rename to app/src/main/java/io/github/sds100/keymapper/util/ui/compose/DraggableItem.kt index 3bdf4f3fd4..b0653c9fb4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/compose/draggable/DraggableItem.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/DraggableItem.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.compose.draggable +package io.github.sds100.keymapper.util.ui.compose import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/KeyMapperDropdownMenu.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/KeyMapperDropdownMenu.kt index f293b15968..eafba75c5d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/KeyMapperDropdownMenu.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/KeyMapperDropdownMenu.kt @@ -10,18 +10,17 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import io.github.sds100.keymapper.R -import io.github.sds100.keymapper.system.network.HttpMethod @Composable @OptIn(ExperimentalMaterial3Api::class) -fun KeyMapperDropdownMenu( +fun KeyMapperDropdownMenu( modifier: Modifier = Modifier, expanded: Boolean, onExpandedChange: (Boolean) -> Unit = {}, - value: String, - onValueChanged: (String) -> Unit = {}, + label: (@Composable () -> Unit)? = null, + selectedValue: T, + values: List>, + onValueChanged: (T) -> Unit = {}, ) { ExposedDropdownMenuBox( modifier = modifier, @@ -30,28 +29,31 @@ fun KeyMapperDropdownMenu( ) { TextField( modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable), - value = value, - onValueChange = onValueChanged, + value = values.find { it.first == selectedValue }?.second ?: values.first().second, + onValueChange = { newValue -> + onValueChanged(values.single { it.second == newValue }.first) + }, readOnly = true, - label = { Text(stringResource(R.string.action_http_request_method_label)) }, + label = label, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, colors = ExposedDropdownMenuDefaults.textFieldColors(), ) + ExposedDropdownMenu( matchTextFieldWidth = true, expanded = expanded, onDismissRequest = { onExpandedChange(false) }, ) { - for (method in HttpMethod.entries) { + for ((value, valueText) in values) { DropdownMenuItem( text = { Text( - method.toString(), + valueText, style = MaterialTheme.typography.bodyLarge, ) }, onClick = { - onValueChanged(method.toString()) + onValueChanged(value) onExpandedChange(false) }, contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/SearchAppBarActions.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/SearchAppBarActions.kt new file mode 100644 index 0000000000..5ee078990d --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/SearchAppBarActions.kt @@ -0,0 +1,90 @@ +package io.github.sds100.keymapper.util.ui.compose + +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.DockedSearchBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.github.sds100.keymapper.R + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun RowScope.SearchAppBarActions( + onCloseSearch: () -> Unit, + onNavigateBack: () -> Unit, + onQueryChange: (String) -> Unit, + enabled: Boolean, + query: String?, +) { + var isExpanded: Boolean by rememberSaveable { mutableStateOf(false) } + + IconButton(onClick = { + if (isExpanded) { + onCloseSearch() + isExpanded = false + } else { + onNavigateBack() + } + }) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.bottom_app_bar_back_content_description), + ) + } + + DockedSearchBar( + modifier = Modifier.Companion.align(Alignment.Companion.CenterVertically), + inputField = { + SearchBarDefaults.InputField( + modifier = Modifier.Companion.align(Alignment.Companion.CenterVertically), + onSearch = { + onQueryChange(it) + isExpanded = false + }, + leadingIcon = { + Icon( + Icons.Rounded.Search, + contentDescription = null, + ) + }, + enabled = enabled, + placeholder = { Text(stringResource(R.string.search_placeholder)) }, + query = query ?: "", + onQueryChange = onQueryChange, + expanded = isExpanded, + onExpandedChange = { expanded -> + if (expanded) { + isExpanded = true + } else { + onCloseSearch() + isExpanded = false + } + }, + ) + }, + // This is false to prevent an empty "content" showing underneath. + expanded = isExpanded, + onExpandedChange = { expanded -> + if (expanded) { + isExpanded = true + } else { + onCloseSearch() + isExpanded = false + } + }, + content = {}, + ) +} diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/SimpleListItem.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/SimpleListItem.kt index d3a31360bc..0c758c99e7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/SimpleListItem.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/SimpleListItem.kt @@ -1,11 +1,14 @@ package io.github.sds100.keymapper.util.ui.compose +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -13,11 +16,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Wifi import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -46,58 +51,86 @@ fun SimpleListItemHeader( } @Composable -fun SimpleListItem( +fun SimpleListItemFixedHeight( modifier: Modifier = Modifier, model: SimpleListItemModel, onClick: () -> Unit = {}, ) { - OutlinedCard(modifier = modifier.height(48.dp), onClick = onClick, enabled = model.isEnabled) { - Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Modifier.width(16.dp)) + SimpleListItem( + modifier = modifier.height(56.dp), + model = model, + onClick = onClick, + ) +} - when (model.icon) { - is ComposeIconInfo.Vector -> Icon( - modifier = Modifier.size(26.dp), - imageVector = model.icon.imageVector, - contentDescription = null, - tint = LocalContentColor.current, - ) +@Composable +fun SimpleListItem( + modifier: Modifier = Modifier, + model: SimpleListItemModel, + onClick: () -> Unit = {}, +) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + OutlinedCard( + modifier = modifier.height(IntrinsicSize.Min), + onClick = onClick, + enabled = model.isEnabled, + ) { + Row( + modifier = Modifier + .fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.width(16.dp)) - is ComposeIconInfo.Drawable -> { - val painter = rememberDrawablePainter(model.icon.drawable) - Icon( + when (model.icon) { + is ComposeIconInfo.Vector -> Icon( modifier = Modifier.size(26.dp), - painter = painter, + imageVector = model.icon.imageVector, contentDescription = null, - tint = Color.Unspecified, + tint = LocalContentColor.current, ) - } - } - Spacer(modifier = Modifier.width(16.dp)) + is ComposeIconInfo.Drawable -> { + val painter = rememberDrawablePainter(model.icon.drawable) + Icon( + modifier = Modifier.size(26.dp), + painter = painter, + contentDescription = null, + tint = Color.Unspecified, + ) + } + } - Column( - modifier = Modifier.padding(end = 16.dp), - ) { - Text( - text = model.title, - style = MaterialTheme.typography.bodyMedium, - maxLines = if (model.subtitle == null) { - 2 - } else { - 1 - }, - overflow = TextOverflow.Ellipsis, - ) + Spacer(modifier = Modifier.width(16.dp)) - if (model.subtitle != null) { + Column( + modifier = Modifier + .padding(end = 16.dp) + .heightIn(min = 36.dp), + verticalArrangement = Arrangement.Center, + ) { Text( - text = model.subtitle, - color = if (model.isSubtitleError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, + text = model.title, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (model.subtitle == null) { + 2 + } else { + 1 + }, overflow = TextOverflow.Ellipsis, ) + + if (model.subtitle != null) { + Text( + text = model.subtitle, + color = if (model.isSubtitleError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/icons/AdGroup.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/icons/AdGroup.kt new file mode 100644 index 0000000000..05f0f4db8b --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/icons/AdGroup.kt @@ -0,0 +1,78 @@ +package io.github.sds100.keymapper.util.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.AdGroup: ImageVector + get() { + if (_AdGroup != null) { + return _AdGroup!! + } + _AdGroup = ImageVector.Builder( + name = "AdGroup", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color(0xFF000000))) { + moveTo(320f, 640f) + lineTo(800f, 640f) + quadTo(800f, 640f, 800f, 640f) + quadTo(800f, 640f, 800f, 640f) + lineTo(800f, 240f) + lineTo(320f, 240f) + lineTo(320f, 640f) + quadTo(320f, 640f, 320f, 640f) + quadTo(320f, 640f, 320f, 640f) + close() + moveTo(320f, 720f) + quadTo(287f, 720f, 263.5f, 696.5f) + quadTo(240f, 673f, 240f, 640f) + lineTo(240f, 160f) + quadTo(240f, 127f, 263.5f, 103.5f) + quadTo(287f, 80f, 320f, 80f) + lineTo(800f, 80f) + quadTo(833f, 80f, 856.5f, 103.5f) + quadTo(880f, 127f, 880f, 160f) + lineTo(880f, 640f) + quadTo(880f, 673f, 856.5f, 696.5f) + quadTo(833f, 720f, 800f, 720f) + lineTo(320f, 720f) + close() + moveTo(160f, 880f) + quadTo(127f, 880f, 103.5f, 856.5f) + quadTo(80f, 833f, 80f, 800f) + lineTo(80f, 240f) + lineTo(160f, 240f) + lineTo(160f, 800f) + quadTo(160f, 800f, 160f, 800f) + quadTo(160f, 800f, 160f, 800f) + lineTo(720f, 800f) + lineTo(720f, 880f) + lineTo(160f, 880f) + close() + moveTo(320f, 160f) + quadTo(320f, 160f, 320f, 160f) + quadTo(320f, 160f, 320f, 160f) + lineTo(320f, 640f) + quadTo(320f, 640f, 320f, 640f) + quadTo(320f, 640f, 320f, 640f) + lineTo(320f, 640f) + quadTo(320f, 640f, 320f, 640f) + quadTo(320f, 640f, 320f, 640f) + lineTo(320f, 160f) + quadTo(320f, 160f, 320f, 160f) + quadTo(320f, 160f, 320f, 160f) + close() + } + }.build() + + return _AdGroup!! + } + +@Suppress("ObjectPropertyName") +private var _AdGroup: ImageVector? = null diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/icons/JumpToElement.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/icons/JumpToElement.kt new file mode 100644 index 0000000000..498d3bc295 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/compose/icons/JumpToElement.kt @@ -0,0 +1,115 @@ +package io.github.sds100.keymapper.util.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.JumpToElement: ImageVector + get() { + if (_JumpToElement != null) { + return _JumpToElement!! + } + _JumpToElement = ImageVector.Builder( + name = "JumpToElement", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color(0xFF000000))) { + moveTo(520f, 440f) + lineTo(560f, 440f) + quadTo(577f, 440f, 588.5f, 451.5f) + quadTo(600f, 463f, 600f, 480f) + quadTo(600f, 497f, 588.5f, 508.5f) + quadTo(577f, 520f, 560f, 520f) + lineTo(480f, 520f) + quadTo(463f, 520f, 451.5f, 508.5f) + quadTo(440f, 497f, 440f, 480f) + lineTo(440f, 400f) + quadTo(440f, 383f, 451.5f, 371.5f) + quadTo(463f, 360f, 480f, 360f) + quadTo(497f, 360f, 508.5f, 371.5f) + quadTo(520f, 383f, 520f, 400f) + lineTo(520f, 440f) + close() + moveTo(800f, 440f) + lineTo(800f, 400f) + quadTo(800f, 383f, 811.5f, 371.5f) + quadTo(823f, 360f, 840f, 360f) + quadTo(857f, 360f, 868.5f, 371.5f) + quadTo(880f, 383f, 880f, 400f) + lineTo(880f, 480f) + quadTo(880f, 497f, 868.5f, 508.5f) + quadTo(857f, 520f, 840f, 520f) + lineTo(760f, 520f) + quadTo(743f, 520f, 731.5f, 508.5f) + quadTo(720f, 497f, 720f, 480f) + quadTo(720f, 463f, 731.5f, 451.5f) + quadTo(743f, 440f, 760f, 440f) + lineTo(800f, 440f) + close() + moveTo(520f, 160f) + lineTo(520f, 200f) + quadTo(520f, 217f, 508.5f, 228.5f) + quadTo(497f, 240f, 480f, 240f) + quadTo(463f, 240f, 451.5f, 228.5f) + quadTo(440f, 217f, 440f, 200f) + lineTo(440f, 120f) + quadTo(440f, 103f, 451.5f, 91.5f) + quadTo(463f, 80f, 480f, 80f) + lineTo(560f, 80f) + quadTo(577f, 80f, 588.5f, 91.5f) + quadTo(600f, 103f, 600f, 120f) + quadTo(600f, 137f, 588.5f, 148.5f) + quadTo(577f, 160f, 560f, 160f) + lineTo(520f, 160f) + close() + moveTo(800f, 160f) + lineTo(760f, 160f) + quadTo(743f, 160f, 731.5f, 148.5f) + quadTo(720f, 137f, 720f, 120f) + quadTo(720f, 103f, 731.5f, 91.5f) + quadTo(743f, 80f, 760f, 80f) + lineTo(840f, 80f) + quadTo(857f, 80f, 868.5f, 91.5f) + quadTo(880f, 103f, 880f, 120f) + lineTo(880f, 200f) + quadTo(880f, 217f, 868.5f, 228.5f) + quadTo(857f, 240f, 840f, 240f) + quadTo(823f, 240f, 811.5f, 228.5f) + quadTo(800f, 217f, 800f, 200f) + lineTo(800f, 160f) + close() + moveTo(360f, 656f) + lineTo(164f, 852f) + quadTo(153f, 863f, 136f, 863f) + quadTo(119f, 863f, 108f, 852f) + quadTo(97f, 841f, 97f, 824f) + quadTo(97f, 807f, 108f, 796f) + lineTo(304f, 600f) + lineTo(160f, 600f) + quadTo(143f, 600f, 131.5f, 588.5f) + quadTo(120f, 577f, 120f, 560f) + quadTo(120f, 543f, 131.5f, 531.5f) + quadTo(143f, 520f, 160f, 520f) + lineTo(400f, 520f) + quadTo(417f, 520f, 428.5f, 531.5f) + quadTo(440f, 543f, 440f, 560f) + lineTo(440f, 800f) + quadTo(440f, 817f, 428.5f, 828.5f) + quadTo(417f, 840f, 400f, 840f) + quadTo(383f, 840f, 371.5f, 828.5f) + quadTo(360f, 817f, 360f, 800f) + lineTo(360f, 656f) + close() + } + }.build() + + return _JumpToElement!! + } + +@Suppress("ObjectPropertyName") +private var _JumpToElement: ImageVector? = null diff --git a/app/src/main/res/layout/fragment_config_key_event.xml b/app/src/main/res/layout/fragment_config_key_event.xml index 87b7853f12..41011c8334 100644 --- a/app/src/main/res/layout/fragment_config_key_event.xml +++ b/app/src/main/res/layout/fragment_config_key_event.xml @@ -28,7 +28,9 @@ android:id="@+id/scrollView" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_marginBottom="@dimen/bottom_app_bar_height"> + android:layout_marginBottom="@dimen/bottom_app_bar_height" + app:layout_anchor="@+id/scrollView" + app:layout_anchorGravity="center"> @@ -121,25 +123,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="none" - android:text="@{viewModel.uiState.chosenDeviceName}" tools:ignore="LabelFor" /> - - + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml new file mode 100644 index 0000000000..1d669e715e --- /dev/null +++ b/app/src/main/res/values-id/strings.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 1d669e715e..90b10fd127 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1,74 +1,1249 @@ + Tuşlarınızı özgür bırakın! + Key Mapper, uygulama dışında olduğunuzda düğme basışlarınızı algılayıp değiştirebilmesi için bir erişilebilirlik servisine ihtiyaç duyar. Tuş eşlemeleriniz, erişilebilirlik servisini etkinleştirdiğinizde çalışır. Tetikleyici oluşturmak ve eylemleri test etmek için de bu servisin açık olması gerekir. + 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ı + Açık + Kapalı + Sistemi takip et + Bu cihaz + Herhangi bir cihaz + Varsayılan + Erişilebilirlik servisini etkinleştir + Erişilebilirlik servisini yeniden başlat + Paylaş + Burada hiçbir şey yok! + Tekrarlamayı durdur… + Tetikleyici bırakıldığında + Tetikleyici tekrar basıldığında + Sınır ulaşıldığında + Tetikleyici bırakıldığında + Tetikleyici tekrar basıldığında + Gizli uygulamaları göster + Değiştiriciler + ÖNEMLİ!!! Bu koordinatlar yalnızca ekranınız ekran görüntüsüyle aynı yönde olduğunda doğrudur! Bu eylem, ekranda yaptığınız tüm dokunma veya hareketleri iptal eder.\n\nEkranınızdaki bir noktanın koordinatlarını bulmakta yardıma ihtiyacınız varsa, bir ekran görüntüsü alın ve ardından bu eylemin basmasını istediğiniz yere ekran görüntüsüne dokunun. + Not: \"İçeri sıkıştırma\" kullanırken X ve Y BİTİŞ koordinatlarıdır, \"Dışarı sıkıştırma\" kullanırken X ve Y BAŞLANGIÇ koordinatlarıdır. + Eylemleri düzeltmek için dokunun! + Kısıtlamaları düzeltmek için dokunun! + Eylemleri gerçekleştir + Tetikleyici şu olana kadar basılı tut… + Cihaz yok + ¯\\_(ツ)_/¯\n\nEkstra yok! + Tuş olayı eylemini yapılandırma tamamlandı + Koordinat seçimi tamamlandı + Key Mapper günlüğü + Neler Yeni + Ses dosyası, Key Mapper’ın özel veri klasörüne kopyalanacak; bu, dosya taşınsa veya silinse bile eylemlerinizin çalışmaya devam edeceği anlamına gelir. Ayrıca, tuş eşlemelerinizle birlikte zip klasöründe yedeklenecektir. + Kaydedilen ses dosyalarını ayarlardan silebilirsiniz. + Eşleştirilmiş cihaz bulunamadı. Bluetooth açık mı? + Kısayol olarak kullanmak için bir tuş eşlemesine dokunun. + Tuş eşleme kısayolu oluştur + Etkin + Devre dışı + Sıfırla + Cihaz yöneticisini etkinleştirdikten sonra, Key Mapper’ı kaldırmak istiyorsanız bunu DEVRE DIŞI BIRAKMANIZ gerekir. + %sms bekle + Etkinlik başlat: %s + 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. + Android sınırlamaları nedeniyle hareket süresi çok yüksek. + DPAD tetikleyicilerinin çalışması için bir Key Mapper klavyesi kullanmalısınız! + Tuş eşlemeleriniz rastgele duracak! + Tuş eşlemeleriniz duraklatıldı! + Devam ettir + Tuş eşlemelerinizin çalışması için erişilebilirlik servisinin açık olması gerekir! + Telefonunuz, Key Mapper’ı arka planda çalışırken kapattı veya çöktü! + Erişilebilirlik servisi etkin! Tuş eşlemeleriniz çalışmalı. + Ekstra günlük kaydı açık! Bir sorunu düzeltmeye çalışmıyorsanız bunu kapatın. + Kapat + Hakkında + %s uygulamasını aç + ‘%s’ yaz + %s%s gir + Kabuk üzerinden %s gir + %s cihazından %s%s gir + %s aç + Ekrana dokun (%d, %d) + Ekrana dokun (%s) + %d parmakla %d/%d koordinatlarından %d/%d koordinatlarına %dms içinde kaydır + %d parmakla %d/%d koordinatlarından %d/%d koordinatlarına %dms içinde kaydır (%s) + %s, %d parmakla %d/%d koordinatlarında %dpx sıkıştırma mesafesiyle %dms içinde + %s, %d parmakla %d/%d koordinatlarında %dpx sıkıştırma mesafesiyle %dms içinde (%s) + %s numarayı ara + Ses çal: %s + Seçenekler: + Eylemler: + Tetikleyici: + Kısıtlamalar: + Yukarı kaydır + Aşağı kaydır + Sola kaydır + Sağa kaydır + Ekstralar + Başlangıç X + Başlangıç Y + Bitiş X + Bitiş Y + Sıkıştırma mesafesi (px) + Sıkıştırma türü + İçeri sıkıştırma + Dışarı sıkıştırma + Tuş kodu + Cihazdan + Kısayol adı + Koordinat açıklaması (isteğe bağlı) + Girilecek metin + Açılacak URL + Aranacak telefon numarası + Eylem + Kategoriler + Veri + Paket + Sınıf + Ad + Değer (%s) + Key Mapper için açıklama (gerekli) + Bayraklar + Ses dosyası açıklaması + WiFi ağ SSID’si + Aynı anda + Sırayla + VE + VEYA + Kısa basış + Uzun basış + Çift basış + Doğru + Yanlış + Etkinlik + Servis + Yayın alıcısı + Tetikleyici ve eylemler + Kısıtlamalar ve daha fazlası + Tetikleyici + Eylemler + Kısıtlamalar + Seçenekler + %s seçildi + Yedekleme başarılı! + Yedekleme başarısız! + Geri yükleme başarılı! + Geri yükleme başarısız! + Otomatik yedekleme başarılı! + Otomatik yedekleme başarısız! + Ekran görüntüsü alındı + Ekran görüntüsü çözünürlüğü bu cihazın çözünürlüğüyle eşleşmiyor! + Tuş eşleme UUID’si panoya kopyalandı + Bir tuş eşlemesini tetiklediniz + Günlük kopyalandı + Kaydedilmiş ses dosyanız yok! + Key Mapper, Shizuku kullanarak kendine WRITE_SECURE_SETTINGS izni verdi + Key Mapper, Root kullanarak kendine WRITE_SECURE_SETTINGS izni verdi + Sıra tetikleyici zaman aşımı + Uzun basış gecikmesi + Çift basış zaman aşımı + Tekrarlama gecikmesi + Tekrarlama sınırı + Her… tekrar et + Titreşim süresi + Kaç kez + Her tekrar için kaç kez + Sonraki eylemden önceki gecikme + Basılı tutma süresi + Kaydırma süresi (ms) + Parmak sayısı + Ekran görüntüsüyle ayarlanacak koordinatlar + Başlangıç + Bitiş + Sıkıştırma süresi (ms) + Parmak sayısı + %s ön planda + %s ön planda değil + %s medya oynatıyor + %s medya oynatmıyor + %s bağlı + %s bağlantısı kesildi + Ekran açık + Ekran kapalı + Flaş kapalı + Flaş açık + Ön flaş kapalı + Ön flaş açık + VE + VEYA + Ön plandaki uygulama + Ön planda olmayan uygulama + Bluetooth cihazı bağlı + Bluetooth cihazı bağlantısı kesildi + Ekran açık + Ekran kapalı + Dikey (0°) + Yatay (90°) + Dikey (180°) + Yatay (270°) + Dikey (herhangi) + Yatay (herhangi) + Medya oynatan uygulama + Medya oynatmayan uygulama + Medya oynuyor + Medya oynatmıyor + Flaş açık + Flaş kapalı + WiFi açık + WiFi kapalı + Bir WiFi ağına bağlı + Bir WiFi ağından bağlantı kesildi + Android 10 ve daha yeni sürümlerde uygulamaların bilinen WiFi ağlarının listesini sorgulamasına izin verilmez, bu nedenle SSID’yi manuel olarak yazmanız gerekecek. + + Herhangi bir WiFi ağının eşleşmesi gerekiyorsa boş bırakın. + Herhangi biri + %s WiFi’ye bağlı + %s WiFi’den bağlantı kesildi + Herhangi bir WiFi’ye bağlı + Hiçbir WiFi’ye bağlı değil + Giriş yöntemi seçildi + %s seçildi + Giriş yöntemi seçilmedi + %s seçilmedi + Cihaz kilitli + Cihaz kilidi açık + Kilit ekranı gösteriliyor + Kilit ekranı gösterilmiyor + Telefon görüşmesinde + Telefon görüşmesinde değil + Telefon çalıyor + Şarj oluyor + Şarj olmuyor + Dikey (0°) + Yatay (90°) + Dikey (180°) + Yatay (270°) + Zaman + %s ve %s arasındaki süre + Zaman sınırlaması + Başlangıç zamanı + Başlangıç zamanını düzenle + Bitiş zamanı + Bitiş zamanını düzenle + Uzun basış + Çift basış + Uygulama kapatma özelliğini kapat + Telefonunuzdaki tüm uygulama kapatma \"özelliklerini\" nasıl kapatacağınızı gösteren dontkillmyapp.com adresindeki harika kılavuzu takip edin. + + \n\nKılavuzu okuduktan sonra bir sonraki slayta geçmeniz ve erişilebilirlik servisini yeniden başlatmanız gerekecek. + Kılavuzu aç + Erişilebilirlik servisini yeniden başlat + Erişilebilirlik servisi yeniden başlatılmalıdır. Kapatıp açın. + Hata raporu oluştur + \"Rapor oluştur\" seçeneğine dokunarak hata raporunu kaydedeceğiniz bir konum seçin. Bir sonraki slayt, bunu geliştiriciye nasıl göndereceğinizi açıklayacak. + Rapor oluştur + Raporu paylaş + Hata raporunu geliştiriciyle paylaşmanın 2 yolu var. Discord sunucusuna katılabilir veya GitHub’da bir sorun oluşturabilirsiniz. Mesajınıza hata raporunu eklemeyi unutmayın! + Discord + GitHub + Ayarlar + Hakkında + Ara + Yardım + Hata bildir + Giriş yöntemi seçiciyi göster + Kaydet + Geri yükle + Her şeyi yedekle + Dokunarak duraklat + Dokunarak devam ettir + Kaydet + Kısa mesajları aç/kapat + Kopyala + Temizle + Eylem ekle + Tetikleyici kaydet + Gelişmiş tetikleyiciler + YENİ! + Tamam + Düzelt + Kaydediyor (%d…) + Kısıtlama ekle + Tuş kodu seç + Ekstra ekle + Başlatıcıda kısayol oluştur + Kısayolu manuel olarak oluştur + Intent kılavuzu + Yardım + Ekran görüntüsü seç (isteğe bağlı) + Etkinlik seç + Bayrakları ayarla + Sınır yok + Ses dosyası seç + Eylemi düzenle + Eylemi değiştir + Root izni gerekli! + 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. + 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. + 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. + Bağlı harici cihaz yok. + Key Mapper GUI Klavyesini Yükle + Bu şiddetle tavsiye edilir! Bu, Key Mapper ile kullanabileceğiniz uygun bir klavyedir. Key Mapper’a dahili olan (Temel Giriş Yöntemi) klavyede ekran klavyesi yoktur. Nereden yüklemek istediğinizi seçin. + Key Mapper Leanback Klavyesini Yükle + Bu şiddetle tavsiye edilir! Bu, Key Mapper ile kullanabileceğiniz Android TV için uygun bir klavyedir. Key Mapper’a dahili olan (Temel Giriş Yöntemi) klavyede ekran klavyesi yoktur. Nereden yüklemek istediğinizi seçin. + Key Mapper GUI Klavyesini Yükle + Nereden indirmek istediğinizi seçin. + Key Mapper Leanback Klavyesini Yükle + Nereden indirmek istediğinizi seçin. + Bu eylem için ek kurulum gerekiyor + Bu eylemi kullanmak için cihazınızı ayarlamanın 3 yolu var. Her birinin avantajları ve dezavantajları şunlardır: + + \n\n1. Shizuku’yu indirin (önerilen). Şu anda kullandığınız ekran klavyesini değiştirmeniz gerekmez, ancak cihazınızı her yeniden başlattığınızda bir dakikalık kurulum gerektirir. + + \n\n2. Key Mapper GUI Klavyesini indirin. Bu, Key Mapper ile kullanabileceğiniz bir ekran klavyesidir, ancak şu anda kullandığınız klavyeyi (örneğin Gboard) kullanamazsınız. + + \n\n3. Hiçbir şey yapmayın ve dahili Key Mapper klavyesini kullanın. Bu önerilmez çünkü Key Mapper’ı kullandığınızda hiçbir ekran klavyeniz olmaz! Hiçbir avantajı yoktur. + Bu eylem için ek kurulum gerekiyor + Bu eylemi kullanmak için cihazınızı ayarlamanın 3 yolu var. Her birinin avantajları ve dezavantajları şunlardır: + + \n\n1. Shizuku’yu indirin (önerilen). Şu anda kullandığınız ekran klavyesini değiştirmeniz gerekmez, ancak cihazınızı her yeniden başlattığınızda bir dakikalık kurulum gerektirir. + + \n\n2. Key Mapper Leanback Klavyesini indirin. Bu, Key Mapper ile kullanabileceğiniz Android TV için optimize edilmiş bir ekran klavyesidir, ancak şu anda kullandığınız klavyeyi (örneğin Gboard) kullanamazsınız. + + \n\n3. Hiçbir şey yapmayın ve dahili Key Mapper klavyesini kullanın. Bu önerilmez çünkü Key Mapper’ı kullandığınızda hiçbir ekran klavyeniz olmaz! Hiçbir avantajı yoktur. + Pil optimizasyonunu devre dışı bırak + HEPSİNİ okumanız ZORUNLU, aksi takdirde ileride sinir bozucu sorunlar yaşarsınız!\n\n“Kısmen düzelt” seçeneğine dokunmak, Android’in uygulamayı arka planda durdurmasını belki engelleyebilir.\n\nBu YETERLİ DEĞİL. MIUI veya Samsung Experience gibi OEM arayüzünüzde başka uygulama kapatma özellikleri olabilir, bu nedenle dontkillmyapp.com’daki çevrimiçi kılavuzu takip ederek Key Mapper için bunları da kapatmalısınız. + Erişilebilirlik servisini kapatıp açarak yeniden başlatın. + Bu tetikleyiciyi kullanmak, cihazınızın ayarlarındaki ekran sabitleme ayarını kullandıktan sonra cihazınızın kilidini açtığınızda siyah bir ekran oluşmasına neden olabilir. Bu, bir yeniden başlatma ile düzeltilebilir. Bu durum tüm cihazlarda olmaz, bu yüzden dikkatli olun ve sorun yaşarsanız ayarı kapatın! + Key Mapper kesintiye uğradı + Key Mapper arka planda çalışmaya çalıştı ancak sistem tarafından durduruldu.\nBu, pil veya bellek optimizasyonu açık olduğunda olabilir.\n\nBunu düzeltmek için çevrimiçi bir kılavuzu takip edebilirsiniz. İşiniz bittiğinde servisi de yeniden başlatmalısınız. + Devam et + Yoksay + Hata raporu oluşturulamadı + Hatayı düzelt + Key Mapper için dosya oluşturmanıza izin veren bir dosya uygulamanız yüklü değil. Lütfen bir dosya yöneticisi yükleyin. + Key Mapper için dosya seçmenize izin veren bir dosya uygulamanız yüklü değil. Lütfen bir dosya yöneticisi yükleyin. + Erişilebilirlik servisi etkinleştirilmeli + @string/accessibility_service_explanation + Rahatsız Etmeyin erişimini ver + Cihazınızın hangi uygulamaların Rahatsız Etmeyin durumunu değiştirebileceğini yönetebileceğiniz ayarlar sayfasına yönlendirileceksiniz. Bu bazı cihazlarda mevcut değildir, bu yüzden listede Key Mapper’ı görmüyorsanız “tekrar gösterme” seçeneğine dokunun. + Bilmenizde fayda var! + Bir tetikleyici tuşunun yanında bu sembolü (⌨) görüyorsanız, algılanması için bir Key Mapper klavyesi KULLANMALISINIZ. Bu, Android’deki bir kısıtlamadır ve yalnızca bazı düğmeler için gereklidir. + Önemli! + Key Mapper GUI Klavyesini, bu Key Mapper sürümüyle uyumlu olacak şekilde güncellemelisiniz. Güncelleme yapana kadar bazı tuş eşlemeleri çalışmayabilir! + Şimdi güncelle + Yoksay + Şuna göre sırala + Öncelikleri ayarlamak için tutamaçları sürükleyin. En üstteki öğe en önemlisidir. Ayrıca herhangi bir öğeye dokunarak sıralama sırasını tersine çevirebilirsiniz. + Ö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. + Tamam + Kılavuz + Kılavuz + Değiştir + Kısmen düzelt + Tamam + Yeniden başlat + Tekrar gösterme + Uygula + Değişiklikleri iptal et + Kaydet + Anladım + Kapat + İptal + Tekrar gösterme + Düzenlemeye devam et + Gizle + Çevrimiçi kılavuz + Ayarlar + Belgeler + Değişiklik günlüğü + Shizuku + Key Mapper GUI Klavyesi + Key Mapper Leanback Klavyesi + Hiçbir şey yapma + 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 + Duraklatıldı + Key Mapper’ı açmak için dokunun. + Devam ettir + Kapat + Yeniden başlat + Erişilebilirlik servisi devre dışı + Erişilebilirlik servisini başlatmak için dokunun. + Erişilebilirlik servisi yeniden başlatılmalı! + Erişilebilirlik servisi çöktü! Telefonunuz bunu agresif bir şekilde kapatmış olabilir! Erişilebilirlik servisini yeniden başlatmak için dokunun. + Servisi durdur + Klavye gizli! + Klavyeyi tekrar göstermeye başlamak için ‘klavyeyi göster’e dokunun. + Key Mapper klavyesini aç/kapat + Key Mapper klavyesine ve klavyenizden geçiş yapmak için ‘aç/kapat’a dokunun. + Aç/Kapat + Varsayılan uzun basış gecikmesi (ms) + 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) + 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) + 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) + 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) + 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) + 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. + 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. + Tema seç + Açık ve koyu temalar mevcut + Bildirime dokunduğunuzda Key Mapper klavyesi ile varsayılan klavyeniz arasında geçiş yapın. + Key Mapper klavyesini aç/kapat bildirimi + 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. + Ekstra günlüğü etkinleştir + Günlüğü görüntüle ve paylaş + Sorun bildir + Ses dosyalarını sil + Ses eylemi için kullanılabilecek ses dosyalarını sil. + İzin ver + İzin verildi + 1. Shizuku yüklü değil! Shizuku uygulamasını indirmek için dokunun. + 1. Shizuku yüklü. + 2. Shizuku başlatılmadı! Shizuku uygulamasını açmak için dokunun ve ardından nasıl başlatılacağına dair talimatlarını okuyun. + 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. + 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. + Evet, sıfırla + 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. + Klavyeyi otomatik olarak değiştir + Bunlar gerçekten kullanışlı ayarlar ve kontrol etmeniz önerilir! + 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. + 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 + her %dms’de + tekrar basılana kadar + bırakılana kadar + Tekrarla + Basılı tut + Tekrar basılana kadar basılı tut + Yeniden eşleme yapma + Bu tuş eşlemesini diğer uygulamaların tetiklemesine izin ver + Tuş eşleme kimliğini kopyala + + Erişilebilirlik + Alarm + DTMF + Müzik + Bildirimler + Zil + Sistem + Sesli arama + Normal + Titreşim + Sessiz + Ön + Arka + Alarmlar + Öncelik + Hiçbir şey + Tuş eşlemelerini duraklat + Tuş eşlemelerini devam ettir + Duraklatıldı + Çalışıyor + Servis Devre Dışı + Key Mapper erişilebilirlik servisi devre dışı + Key Mapper klavyesini aç/kapat + Ctrl + Sol Ctrl + Sağ Ctrl + Alt + Sol Alt + Sağ Alt + Shift + Sol Shift + Sağ Shift + Meta + Sol Meta + Sağ Meta + Sym + Func + Caps Lock + Num Lock + Scroll Lock + Bu eylemin çalışması için Key Mapper klavyelerinden birini kullanıyor olmanız gerekiyor! + %s paket adına sahip uygulama yüklü değil! + %s uygulaması devre dışı! + Key Mapper\'a sistem ayarlarını değiştirme izni vermeniz gerekiyor. + Bu işlem root izni gerektiriyor! + Bu eylem kamera izni gerektiriyor! + Android %s veya daha yeni bir sürüm gerektiriyor + Android %s veya daha eski bir sürüm gerektiriyor + Cihazınızda kamera bulunmuyor. + Cihazınız NFC\'yi desteklemiyor. + Cihazınızda parmak izi okuyucu bulunmuyor. + Cihazınız WiFi\'yi desteklemiyor. + Cihazınız Bluetooth\'u desteklemiyor. + Cihazınız cihaz politikası uygulamasını desteklemiyor. + Cihazınızda kamera flaşı bulunmuyor. + Cihazınızda telefon özellikleri bulunmuyor. + Klavye ayarları sayfası bulunamadı! + Key Mapper\'ın cihaz yöneticisi olması gerekiyor! + Key Mapper\'ın bu kısayolu kullanma izni yok + Uygulamanın Rahatsız Etmeyin durumunu değiştirmek için izne ihtiyacı var! + Bu eylem telefon durumunu okuma izni gerektiriyor! + WRITE_SETTINGS izin sayfası bulunamadı! + Bu uygulama kısayolu açılırken hata oluştu + Rahatsız Etmeyin erişim izni ayarları bulunamadı! + Key Mapper\'ın WRITE_SECURE_SETTINGS iznine ihtiyacı var. + Bu telefon aramasını başlatabilecek bir uygulama yok + Kamera kullanımda! + Kamera bağlantısı kesildi! + Kamera devre dışı! + Kamera hatası! + Maksimum kamera kullanımda! + Ön flaş yok + Arka flaş yok + Değişken flaş ışığı gücü desteklenmiyor + Erişilebilirlik servisinin etkinleştirilmesi gerekiyor! + Erişilebilirlik servisinin yeniden başlatılması gerekiyor! + Başlatıcınız kısayolları desteklemiyor. + Bir Key Mapper klavyesinin etkinleştirilmesi gerekiyor! + %s giriş yöntemi bulunamadı + Giriş yöntemi seçici gösterilemiyor! + Erişilebilirlik düğümü bulunamadı! + %s genel eylemi gerçekleştirilemedi! + Pil optimizasyon ayarları bulunamadı! Varsa, manuel olarak açın. + Ekstra (%s) bulunamadı! + Aynı kısıtlamaya iki kez sahip olamazsınız! + Boş olamaz! + Cihaz bulunamadı! + Boş JSON dosyası! + Dosya erişimi reddedildi! %s + Bilinmeyen G/Ç hatası! + İptal edildi! + Geçersiz numara! + En az %s olmalı! + En fazla %s olmalı! + Pil optimizasyonu açık! Key Mapper\'ın rastgele durmasını engellemek için bunu kapatın. + Bildirim erişim izni reddedildi! + Geçersiz! + Telefon araması başlatma izni reddedildi! + Bu yedeği kullanmak için Key Mapper\'ı en son sürüme güncellemeniz gerekiyor. + Sesli asistan yüklü değil! + Yetersiz izinler + Yalnızca Key Mapper klavyeleri yüklü! + Medya oynatan bir uygulama yok! + Kaynak dosya bulunamadı! %s + Hedef dosya bulunamadı! %s + Hareket girişi başarısız! + Sistem ayarı %s değiştirilemedi! + %s etkinleştirilmeli! + Giriş yöntemi değiştirilemedi! + Cihazınızda kamera uygulaması yok! + Cihazınızda asistan yok! + Cihazınızda ayarlar uygulaması yok! + Bu URL\'yi açabilecek bir uygulama yok! + Klasör değil! %s + Dosya değil! %s + Dizin bulunamadı! %s + Ses dosyası bulunamadı! + Depolama izni reddedildi! + Kaynak ve hedef aynı olamaz! + Hedefte yer kalmadı! %s + Shizuku izni reddedildi! + Shizuku başlatılmadı! + Bu dosyanın adı yok! + Geçersiz dosya. Key Mapper\'dan dışa aktarılmış bir zip olmalı. + Key Mapper\'a eşleştirilmiş Bluetooth cihazlarını görme izni vermelisiniz. + Hatalı URL. http:// kısmını unuttunuz mu? + Hassas konum okuma izni reddedildi! + Telefon aramalarını yanıtlama ve sonlandırma izni reddedildi! + Eşleştirilmiş Bluetooth cihazlarını görme izni reddedildi! + Bildirim gösterme izni reddedildi! + 2 veya daha fazla olmalı! + %d veya daha az olmalı! + 0\'dan büyük olmalı! + 0\'dan büyük olmalı! + 0\'dan büyük olmalı! + 0\'dan büyük olmalı! + %d veya daha az olmalı! + UI öğesi bulunamadı! + WiFi\'yi aç/kapat + WiFi\'yi aç + WiFi\'yi kapat + Bluetooth\'u aç/kapat + Bluetooth\'u aç + Bluetooth\'u kapat + Sesi artır + Sesi azalt + Sesi kapat + Sesi aç/kapat + Sesi aç + Ses kontrol panelini göster + Akışı artır + %s akışını artır + Akışı azalt + %s akışını azalt + Zil modlarını değiştir (Normal, Titreşim, Sessiz) + Zil modlarını değiştir (Normal, Titreşim) + Zil modunu değiştir + %s moduna geç + Rahatsız Etmeyin modunu aç/kapat + Yalnızca %s için Rahatsız Etmeyin modunu aç/kapat + Rahatsız Etmeyin modunu aç + Yalnızca %s için Rahatsız Etmeyin modunu aç + Rahatsız Etmeyin modunu kapat + Otomatik döndürmeyi aç + Otomatik döndürmeyi kapat + Otomatik döndürmeyi aç/kapat + Dikey mod + Yatay mod + Yönü değiştir + Dönüşleri sırayla değiştir + %s dönüşlerini sırayla değiştir + Mobil veriyi aç/kapat + Mobil veriyi aç + Mobil veriyi kapat + Otomatik parlaklığı aç/kapat + Otomatik parlaklığı kapat + Otomatik parlaklığı aç + Ekran parlaklığını artır + Ekran parlaklığını azalt + Bildirim panelini aç + Bildirim panelini aç/kapat + Hızlı ayarları aç + Hızlı ayarlar panelini aç/kapat + Durum çubuğunu kapat + Medya oynatmayı duraklat + Bir uygulama için medya oynatmayı duraklat + %s için medyayı duraklat + Medya oynatmayı devam ettir + Bir uygulama için medya oynatmayı devam ettir + %s için medyayı devam ettir + Medya oynatmayı oynat/duraklat + Bir uygulama için medya oynatmayı oynat/duraklat + %s için medyayı oynat/duraklat + Sonraki parça + Bir uygulama için sonraki parça + %s için sonraki parça + Önceki parça + Bir uygulama için önceki parça + %s için önceki parça + Hızlı ileri sar + Bir uygulama için hızlı ileri sar + %s için hızlı ileri sar + Tüm medya uygulamaları hızlı ileri sarmayı desteklemez. Örn. Google Play Music. + Geri sar + Bir uygulama için geri sar + %s için geri sar + Tüm medya uygulamaları geri sarmayı desteklemez. Örn. Google Play Music. + Geri dön + Ana ekrana git + Son uygulamaları aç + Menüyü aç + Bölünmüş ekranı aç/kapat + Son uygulamaya git (Son uygulamalara çift bas) + Flaş ışığını aç/kapat + Flaş ışığını aç + Flaş ışığını kapat + Flaş ışığını aç/kapat + Flaş ışığını aç/kapat (%s) + Flaş ışığını aç + Flaş ışığını aç (%s) + Flaş ışığını kapat + Flaş ışığı parlaklığını değiştir + Flaş ışığını %s artır + Flaş ışığını %s azalt + Ön flaş ışığını aç/kapat + Ön flaş ışığını aç/kapat (%s) + Ön flaş ışığını aç + Ön flaş ışığını aç (%s) + Ön flaş ışığını kapat + Ön flaş ışığı parlaklığını değiştir + Ön flaş ışığını %s artır + Ön flaş ışığını %s azalt + NFC\'yi aç + NFC\'yi kapat + NFC\'yi aç/kapat + Ekran görüntüsü al + Sesli asistanı başlat + Cihaz asistanını başlat + Kamerayı aç + Cihazı kilitle + 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 sona taşı + Bu eylem bazı uygulamalarda beklendiği gibi çalışmayabilir. + Klavyeyi aç/kapat + Bu eylem yalnızca klavyenin gösterilmesi gereken bir giriş alanına dokunduğunuzda çalışır. + Klavyeyi göster + Klavyeyi gizle + Klavye seçiciyi göster + Klavyeyi değiştir + %s klavyesine geç + Kes + Kopyala + Yapıştır + İmlecin olduğu kelimeyi seç + Ayarları aç + Güç menüsünü göster + Uçak modunu aç/kapat + Uçak modunu aç + Uçak modunu devre dışı bırak + Uygulamayı başlat + Bazı cihazlar, uygulamaların arka planda başka uygulamaları başlatabilmesi için izne ihtiyaç duyar. Web sitemizdeki talimatları görmek için \"Daha fazla oku\"ya dokunun. + Daha fazla oku + Yoksay + Uyarı! + Uygulama kısayolunu başlat + Tuş kodunu gir + Tuş olayını gir + Ekrana dokun + Ekranı kaydır + Ekranı sıkıştır + Metin gir + URL aç + Intent gönder + Telefon araması başlat + Telefon aramasını cevapla + Telefon aramasını sonlandır + Ses çal + En son bildirimi kaldır + Tüm bildirimleri kaldır + Cihaz kontrol ekranı + HTTP isteği + HTTP Yöntemi + Açıklama + Boş bırakılamaz! + URL + Boş bırakılamaz! + Hatalı URL. http:// kısmını unuttunuz mu? + İstek gövdesi (opsiyonel) + Yetkilendirme başlığı (opsiyonel) + Gerekirse \'Bearer\' ön ekini kullanın + Uygulama öğesiyle etkileşime geç + Key Mapper, menüler, sekmeler, düğmeler ve onay kutuları gibi uygulama öğelerini algılayabilir ve bunlarla etkileşime girebilir. Key Mapper\'ın ne yapmak istediğinizi bilmesi için, uygulama öğesiyle etkileşiminizi kaydetmeniz gerekmektedir. + Kaydetmeye başla + Kaydetmeyi durdur (%s dakika kaldı) + + %d öğe seçildi + %d öğe seçildi + + Tekrar kaydet + Uygulama öğesini seçin + Tuş haritanızın etkileşime geçmesini istediğiniz öğeyi seçin. + Aradığınızı bulamıyor musunuz? + Tüm uygulamalar uyumlu değildir. Uyumlu olmayan uygulamalar için bunun yerine Ekrana Dokun eylemini deneyebilirsiniz. + Olası etkileşimler + UI öğesiyle nasıl etkileşim kurmak istediğinizi seçin. + Etkileşim türünü filtrele + Herhangi biri + Dokun + Dokun ve basılı tut + Odakla + İleri kaydır + Geriye kaydır + Genişlet + Daralt + Bilinmeyen: %d + Etkileşim detayları + Açıklama + Uygulama + Sınıf adı + Kaynak kimliğini görüntüle + Özgün kimlik + Etkileşim türü + Navigasyon + Ses + Medya + Klavye + Uygulamalar + Giriş + Kamera ve Ses + Bağlantı + İçerik + Arayüz + Telefon + Ekran + Bildirimler + Boolean + Boolean dizisi + Integer + Tamsayı dizisi + Dize + Dize dizisi + Uzun + Uzun dizi + Bayt + Bayt dizisi + Çift + Çift dizi + Karakter + Karakter dizisi + Kayan Nokta + Kayan Nokta dizisi + Kısa + Kısa dizi + Yalnızca \"true\" veya \"false\" olabilir + \"true\" ve \"false\" içeren virgülle ayrılmış bir liste. Örn. true,false,true + Java programlama dilinde geçerli bir Tamsayı. + Java programlama dilinde geçerli Tamsayıların virgülle ayrılmış bir listesi. Örn. 100,399 + Virgülle ayrılmış bir liste. Örn. kategori1,kategori2 + Herhangi bir metin. + Dizelerin virgülle ayrılmış bir listesi. Örn. dize1,dize2 + Java programlama dilinde geçerli bir Uzun. + Java programlama dilinde geçerli Uzunların virgülle ayrılmış bir listesi. Örn. 102302234234234,399083423234429 + Java programlama dilinde geçerli bir Bayt. + Java programlama dilinde geçerli Baytların virgülle ayrılmış bir listesi. Örn. 123,3 + Java programlama dilinde geçerli bir Çift. + Java programlama dilinde geçerli Çiftlerin virgülle ayrılmış bir listesi. Örn. 1.0,3.234 + Java programlama dilinde geçerli bir Karakter. Örn. \'a\' veya \'b\' + Java programlama dilinde geçerli Karakterlerin virgülle ayrılmış bir listesi. Örn. a,b,c + Java programlama dilinde geçerli bir Kayan Nokta. Örn. 3.145 + Java programlama dilinde geçerli Kayan Noktaların virgülle ayrılmış bir listesi. Örn. 1241.123 + Java programlama dilinde geçerli bir Kısa. Örn. 2342 + Java programlama dilinde geçerli Kısa sayıların virgülle ayrılmış bir listesi. Örn. 3242,12354 + Intent bayrakları bit bayrakları olarak saklanır. Bu bayraklar Intent\'in nasıl işleneceğini değiştirir. Bir Etkinlik Intent\'i için bu alan boş bırakılırsa, Key Mapper varsayılan olarak FLAG_ACTIVITY_NEW_TASK kullanır. Daha fazla bilgi için Android geliştirici belgelerini görmek üzere \'dokümanlar\'a dokunun. + Hızlı Başlangıç Kılavuzu + Eğer takılırsanız Hızlı Başlangıç Kılavuzu\'na göz atın. + GitHub + Web Sitesi + Çeviriler + Sürüm %s + Oyla + Değişiklik Günlüğü + Discord + Sıkıcı şeyler + Lisans + Bu uygulamanın açık kaynak lisansı. + Gizlilik Politikası + Kişisel bilgi toplamıyoruz ama işte bunu belirten bir gizlilik politikası. + Ekibimiz + Geliştirici + Kullanıcı Deneyimi Tasarımcısı + Çevirmen (Lehçe) + Çevirmen (Çekçe) + Çevirmen (İspanyolca) + Key Mapper: Yan Tuş + Herhangi bir asistan + Yan tuş/güç düğmesi + Sesli asistan + Gelişmiş tetikleyiciler + Geliştirici, reklamların sürdürülebilir veya kullanıcı dostu bir gelir modeli olduğuna inanmıyor, bu nedenle bu ücretli tetikleyiciler geliştirmeyi desteklemeye yardımcı oluyor ❤️. Ayrıca öncelikli destek de alacaksınız. + Yan tuş ve Asistan tetikleyicisi + Yan tuşunuzu, güç düğmenizi veya cihaz asistanınızı yeniden eşleyebileceğinizi biliyor muydunuz? Asistanı veya güç menüsünü başlatmak yerine, cihazınız seçtiğiniz bir eylemi gerçekleştirebilir. Ekran kapalıyken bile çalışır! + Yan tuş tetikleyici özelliğini satın almanız gerekiyor. + Daha fazla bilgi + 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 + Yükleniyor… + Fiyatı tekrar almayı dene + Satın alma iptal edildi. + Bu, yalnızca Google Play\'den Key Mapper indirilerek satın alınabilen ücretli bir özellik gerektiriyor. + Ağ hatası oluştu. İnternet bağlantınız var mı? + Bu ürün bulunamadı. + Google Play bir hata ile karşılaştı. + Google Play\'den satın alımın başarılı olduğuna dair onay bekliyoruz. Kartınız başarıyla ücretlendirildiğinde Key Mapper uygulamasını yeniden açın. + Geçersiz satın alma. + Ödeme bekleniyor + Bir şeyler ters gitti 😕 + Tekrar dene + Geliştiriciyle iletişime geç + 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. + 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 + DPAD düğmelerini yeniden eşlemek ister misiniz? + Aşağıdaki adımları izleyerek Key Mapper GUI Klavyesini ayarlamanız gerekiyor. + 1. Klavye uygulamasını yükle + Yükle + Yüklendi + 2. Klavyeyi etkinleştir + Etkinleştir + Etkinleştirildi + 3. Klavyeyi kullan + Klavyeyi değiştir + Klavye seçildi + Kurulum tamamlandı! \'Bitti\'ye dokunun ve DPAD tetikleyiciniz çalışmalı. + Düğme algılanmadı mı? + Erişilebilirlik servisi yerine tetikleyicinizi kaydetmek için Key Mapper GUI Klavye uygulamasını deneyebilirsiniz. + Kurulum tamamlandı! \'Bitti\'ye dokunun ve tetikleyicinizi tekrar kaydetmeyi deneyin. Eğer çalışmazsa Android bunu yeniden eşlemeye izin vermiyor demektir 🫤. + Dokun + bu menüyü göster/gizle. + Düğmeleri herhangi bir uygulamada, hatta kilit ekranında bile yerleştirebilirsiniz. + Kapat + Yardım + Düzeni değiştir + Düğme ekle + Geri dön + Düğmeleri gizle + Düğmeleri göster + Sil + Yapılandır + Tetikleyici olarak kullan + Sil + Düğme metni (İpucu: emoji kullanın) + Düğme boyutu: + Kenar opaklığı: + Arka plan opaklığı: + İptal + Bitti + Düğmenin metni olmalı! + Kayan düğmeler + Kayan düğmeler istediğiniz uygulamaların üzerinde görünür. Gerçek düğmeler gibi çalışırlar ve onları istediğiniz gibi yerleştirebilir, stil verebilir ve eşleyebilirsiniz. + Kayan düğme %s (%s) + Silinen kayan düğme + Kısıtlamalar + Bu düğmenin yalnızca bazı uygulamalarda ekranda olmasını ister misiniz? Bu tuş eşlemesi için “Kısıtlamalar” sekmesinde bir “Ön plandaki uygulama” kısıtlaması ekleyin. + Bir düzen seçin + Geri + Yardım + Yardım mı lazım? + Düğme oluştur + Düğme seç + Çık + Tetikleyici olarak kullanmadan önce bir kayan düğme oluşturmanız gerekiyor. + Tetikleyici olarak kullanmak için bir kayan düğme seçmeniz gerekiyor. + Düğmeyi yapılandır + Düzeni düzenle + Android 11 veya daha yeni bir sürüm gerektiriyor. + Yeterince düğmeniz yok mu? Artık kendi düğmelerinizi yapabilirsiniz! + Kayan düğmeler istediğiniz uygulamaların üzerinde görünür. Gerçek düğmeler gibi çalışırlar ve onları istediğiniz gibi yerleştirebilir, stil verebilir ve eşleyebilirsiniz. + Tetikleyici + Eylemler + Kısıtlamalar + Seçenekler + Tuş eşlemeleri + Kayan düğmeler + Yeni tuş eşlemesi + Yeni düzen + Bir tuş eşlemesini yapılandırmak için dokunun.\nDaha fazla seçenek için uzun basın. + Bir tuş eşlemesi oluşturun! + Yeterince düğmeniz yok mu? + Kendi düğmelerinizi yapın! + Kayan düğmeler istediğiniz uygulamaların üzerinde görünür. Gerçek düğmeler gibi çalışırlar ve onları istediğiniz gibi yerleştirebilir, stil verebilir ve eşleyebilirsiniz. + Bu sayfayı gizle + Key Mapper\'ı desteklediğiniz için teşekkürler ❤️! + İlk düzeninizi oluşturun! + Düzenler, kayan düğmeleri gruplar halinde organize etmenizi sağlar. Hangi düğmelerin gösterilebileceğini etkilemez. Herhangi bir düzendeki herhangi bir düğme aynı anda gösterilebilir. + %s düzeninin adını değiştir + %s düzenini sil + Kayan düğmeler + Düğme yok + Düğmeleri düzenle + Düğme ekle + Düzen adını değiştir + Kaydet + Ad boş olamaz! + Ad benzersiz olmalı! + %s sil + Bu kayan düğme düzenini silmek istediğinizden emin misiniz? + Evet, sil + İptal + Kayan düzenleri gizle + Kayan düğmeleri, bir tetikleyici oluştururken Gelişmiş Tetikleyiciler düğmesinde bulabilirsiniz. + Kayan düğmeler + Menü + Sırala + Daha fazla + Yardım + Tümünü seç + Tüm seçimi kaldır + Seçimi durdur + Bir grup yukarı çık + Duraklatıldı + + 1 uyarı + %d uyarı + + Çalışıyor + Ayarlar + Grubu sil + Hakkında + Tümünü dışa aktar + İçe aktar + Klavye seç + İçe aktarılıyor… + İçe aktarma başarılı! + Dışa aktarılıyor… + Başarısız: %s + Dosya yükleniyor… + İçe aktarma başarılı! + İçe aktarılıyor… + Hata + Uygulamayı aç + + 1 tuş eşlemesi içe aktarılıyor + %d tuş eşlemesi içe aktarılıyor + + Mevcut tuş eşlemelerinizin tümünü değiştirmek mi yoksa listeye eklemek mi istiyorsunuz? + İptal + Kapat + Ekle + Değiştir + Çoğalt + Sil + Dışa aktar + Etkin + Devre dışı + Karışık + Gruba taşı + + 1 tuş eşlemesini sil + %d tuş eşlemesini sil + + Bu tuş eşlemelerini silmek istediğinizden emin misiniz? + Evet, sil + İptal + Dosyalara kaydet + Yeni grup + Yeni alt grup + Tümünü görüntüle + Gizle + Grup kısıtlamaları + Yeni kısıtlama + Grup kısıtlamasını sil + Bu grup + Kaldır + Düzenle + Tetikleyici tuş seçenekleri + Cihaz + Asistan türü + Parmak izi hareket türü + Tıklama türü + Kayan düğme kullan + Parmak izi hareketi kullan + Yan tuş tetikleyicisi kullan + Kayan düğme + Yan tuş tetikleyicisi + Parmak izi hareketi + Kayan düğme: %s + Parmak izi okuyucuda yukarı kaydır + Parmak izi okuyucuda aşağı kaydır + Parmak izi okuyucuda sola kaydır + Parmak izi okuyucuda sağa kaydır + Gelişmiş tetikleyiciler + Kaldır + Düzenle + Test et + Tetiklendiğinde tuş eşlemesinin ne yapacağını ayarlamak için eylemler ekleyin. + Son kullanılan eylemler + Eylem seçenekleri + Sıfırla + Varsayılan: %s + Bir eylem seçin + Ara… + Burada hiçbir şey yok! + Eylemi sil + Bu eylemi silmek istediğinizden emin misiniz? + Evet, sil + İptal + Tuş eşlemesi tetiklendiğinde bu eylemleri çalıştır: + Taraf seç + Parlaklık + Minimum + Yarı + Maksimum + Test et + Android 13 veya daha yeni bir sürüm gerektiriyor. + Bu cihaz parlaklık değişikliğine izin vermiyor. + Parlaklık değişikliği + Desteklenmiyor + Tuş eşlemelerinin yalnızca belirli durumlarda çalışmasını istiyorsanız kısıtlamalar ekleyin. + Son kullanılan kısıtlamalar + Kaldır + Kısıtlamayı sil + Bu kısıtlamayı silmek istediğinizden emin misiniz? + Evet, sil + İptal + Mantık modu + Bir kısıtlama seçin + Bu tuş eşlemesi yalnızca şu durumlarda çalışır: + Adsız grup + Grup adını düzenle + Grup adını kaydet + Ad benzersiz olmalı! + Ana Sayfa + Grubu sil + %s grubunu sil + Bu grubu silmek istediğinizden emin misiniz? Bu gruptaki ve alt gruplarındaki tüm tuş eşlemeleri de silinecek! + Evet, sil + İptal + + +%d devralınan kısıtlamalar + +%d devralınan kısıtlamalar + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a95ad25d6f..20ee36a04c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,7 +24,8 @@ Enable accessibility service Restart accessibility service Share - + Nothing here! + Key Mapper did not detect any interactions. Try showing additional elements. Stop repeating when… Trigger is released Trigger is pressed again @@ -98,8 +99,8 @@ Input %s through shell Input %s%s from %s Open %s - Tap coordinates %d, %d - Tap coordinates %d, %d (%s) + Tap screen (%d, %d) + Tap screen (%s) Swipe with %d finger(s) from coordinates %d/%d to %d/%d in %dms Swipe with %d finger(s) from coordinates %d/%d to %d/%d in %dms (%s) %s with %d finger(s) on coordinates %d/%d with a pinch distance of %dpx in %dms @@ -288,6 +289,14 @@ Landscape (90°) Portrait (180°) Landscape (270°) + + Time + Time between %s and %s + Time constraint + Start time + Edit start time + End time + Edit end time @@ -900,6 +909,8 @@ Must be greater than 0! Must be greater than 0! Must be %d or less! + + UI element not found! @@ -970,11 +981,11 @@ Next track Next track for an app - Next track for %s> + Next track for %s Previous track Previous track for an app - Previous track for %s> + Previous track for %s Fast forward Fast forward for an app @@ -983,9 +994,21 @@ Rewind Rewind for an app - Rewind for %s> + Rewind for %s Not all media apps support rewinding. E.g Google Play Music. + Stop media + Stop media for an app + Stop media for %s + + Step media forward + Step media forward for an app + Step media forward for %s + + Step media backward + Step media backward for an app + Step media backward for %s + Go back Go home Open recents @@ -1040,6 +1063,7 @@ This action will only work if you have tapped on an input field where the keyboard is supposed to be shown. Show keyboard Hide keyboard + Show keyboard picker Switch keyboard @@ -1053,9 +1077,9 @@ Open settings Show power menu - Toggle Airplane mode - Enable Airplane mode - Disable Airplane mode + Toggle airplane mode + Enable airplane mode + Disable airplane mode Launch app Some devices require apps to have permission before they can launch apps in the background. Tap \"Read more\" to view the instructions on our website. @@ -1090,6 +1114,45 @@ Request body (optional) Authorization header (optional) You must prepend \'Bearer\' if necessary + + 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 + Stop recording (%s min left) + + %d element detected + %d elements detected + + Choose the app to interact with + Record again + Choose app element + Choose the element you want your key map to interact with. + Can\'t find what you’re looking for? + Not all apps are compatible. For incompatible apps you can try the Tap Screen action instead. + Possible interactions + Select how you want to interact with the UI element. + Filter interaction type + Show additional elements + + Any + Tap + Tap and hold + Focus + Scroll forward + Scroll backward + Expand + Collapse + Unknown: %d + + Interaction details + Description + App + Text/content description + Class name + Tooltip/hint + View resource ID + Unique ID + Interaction types @@ -1220,6 +1283,7 @@ Unlock (%s) Use Loading… + Purchased! Retry fetching price Purchase cancelled. This requires a paid feature that can only be bought by downloading Key Mapper from Google Play. diff --git a/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt b/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt index a76df90b1a..287e83fde1 100644 --- a/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt @@ -136,6 +136,61 @@ class BackupManagerTest { Dispatchers.resetMain() } + /** + * 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, + ) + + val parentGroup2 = GroupEntity( + uid = "parent_group_2_uid", + name = "parent_group_2_name", + parentUid = null, + lastOpenedDate = 0L, + ) + + 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 = Constants.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. @@ -184,10 +239,10 @@ class BackupManagerTest { currentTime = 0L, ) + verify(mockGroupRepository).insert(parentGroup2) verify(mockGroupRepository).insert(parentGroup1) verify(mockGroupRepository).insert(childGroup) verify(mockGroupRepository).insert(grandChildGroup) - verify(mockGroupRepository).insert(parentGroup2) verify(mockGroupRepository, never()).update(any()) } } diff --git a/app/src/test/java/io/github/sds100/keymapper/actions/PerformActionsUseCaseTest.kt b/app/src/test/java/io/github/sds100/keymapper/actions/PerformActionsUseCaseTest.kt index b338b65d2c..69b71487f1 100644 --- a/app/src/test/java/io/github/sds100/keymapper/actions/PerformActionsUseCaseTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/actions/PerformActionsUseCaseTest.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.actions +import android.view.InputDevice import android.view.KeyEvent import io.github.sds100.keymapper.system.accessibility.IAccessibilityService import io.github.sds100.keymapper.system.devices.FakeDevicesAdapter @@ -142,6 +143,7 @@ class PerformActionsUseCaseTest { deviceId = fakeGamePad.id, scanCode = 0, repeat = 0, + source = InputDevice.SOURCE_GAMEPAD, ) verify(mockImeInputEventInjector, times(1)).inputKeyEvent(expectedInputKeyModel) @@ -171,6 +173,7 @@ class PerformActionsUseCaseTest { deviceId = 0, scanCode = 0, repeat = 0, + source = InputDevice.SOURCE_GAMEPAD, ) verify(mockImeInputEventInjector, times(1)).inputKeyEvent(expectedInputKeyModel) @@ -220,6 +223,7 @@ class PerformActionsUseCaseTest { deviceId = fakeKeyboard.id, scanCode = 0, repeat = 0, + source = InputDevice.SOURCE_GAMEPAD, ) verify(mockImeInputEventInjector, times(1)).inputKeyEvent(expectedInputKeyModel) @@ -278,6 +282,7 @@ class PerformActionsUseCaseTest { deviceId = 11, scanCode = 0, repeat = 0, + source = InputDevice.SOURCE_KEYBOARD, ), ) } @@ -318,6 +323,7 @@ class PerformActionsUseCaseTest { deviceId = 10, scanCode = 0, repeat = 0, + source = InputDevice.SOURCE_KEYBOARD, ), ) } diff --git a/app/src/test/java/io/github/sds100/keymapper/actions/keyevents/ConfigKeyServiceEventActionViewModelTest.kt b/app/src/test/java/io/github/sds100/keymapper/actions/keyevents/ConfigKeyServiceEventActionViewModelTest.kt index e28c7f0db1..298c1dc130 100644 --- a/app/src/test/java/io/github/sds100/keymapper/actions/keyevents/ConfigKeyServiceEventActionViewModelTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/actions/keyevents/ConfigKeyServiceEventActionViewModelTest.kt @@ -46,7 +46,7 @@ class ConfigKeyServiceEventActionViewModelTest { @Before fun init() { Dispatchers.setMain(testDispatcher) - inputDevices = MutableStateFlow(emptyList()) + inputDevices = MutableStateFlow(emptyList()) mockUseCase = mock { on { showDeviceDescriptors }.then { MutableStateFlow(false) } diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/DpadMotionEventTrackerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/DpadMotionEventTrackerTest.kt index b323630108..492a3d788a 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/DpadMotionEventTrackerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/DpadMotionEventTrackerTest.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.mappings.keymaps +import android.view.InputDevice import android.view.KeyEvent import io.github.sds100.keymapper.mappings.keymaps.detection.DpadMotionEventTracker import io.github.sds100.keymapper.system.devices.InputDeviceInfo @@ -70,6 +71,7 @@ class DpadMotionEventTrackerTest { scanCode = 0, device = CONTROLLER_1_DEVICE, repeatCount = 0, + source = InputDevice.SOURCE_DPAD, ), ), ) @@ -83,6 +85,7 @@ class DpadMotionEventTrackerTest { scanCode = 0, device = CONTROLLER_1_DEVICE, repeatCount = 0, + source = InputDevice.SOURCE_DPAD, ), ), ) @@ -277,6 +280,7 @@ class DpadMotionEventTrackerTest { scanCode = 0, device = device, repeatCount = 0, + source = 0, ) } @@ -288,6 +292,7 @@ class DpadMotionEventTrackerTest { scanCode = 0, device = device, repeatCount = 0, + source = 0, ) } } diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt index 149d0d7522..7a0fee13b6 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt @@ -1166,6 +1166,7 @@ class KeyMapControllerTest { any(), any(), any(), + any(), ) // If both triggers are detected @@ -1180,6 +1181,7 @@ class KeyMapControllerTest { any(), any(), any(), + any(), ) // If no triggers are detected @@ -1194,6 +1196,7 @@ class KeyMapControllerTest { any(), any(), any(), + any(), ) } } @@ -2665,6 +2668,7 @@ class KeyMapControllerTest { any(), any(), any(), + any(), ) verify(detectKeyMapsUseCase, times(1)).imitateButtonPress( @@ -2673,6 +2677,7 @@ class KeyMapControllerTest { any(), any(), any(), + any(), ) } } @@ -3068,6 +3073,7 @@ class KeyMapControllerTest { any(), any(), any(), + any(), ) } @@ -3099,6 +3105,7 @@ class KeyMapControllerTest { any(), any(), any(), + any(), ) } @@ -3126,6 +3133,7 @@ class KeyMapControllerTest { any(), any(), any(), + any(), ) verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) } @@ -4137,6 +4145,7 @@ class KeyMapControllerTest { scanCode = scanCode, device = device, repeatCount = repeatCount, + source = 0, ), ) diff --git a/app/src/test/java/io/github/sds100/keymapper/util/TestConstraintSnapshot.kt b/app/src/test/java/io/github/sds100/keymapper/util/TestConstraintSnapshot.kt index ada802c501..6e2d46ec34 100644 --- a/app/src/test/java/io/github/sds100/keymapper/util/TestConstraintSnapshot.kt +++ b/app/src/test/java/io/github/sds100/keymapper/util/TestConstraintSnapshot.kt @@ -7,6 +7,7 @@ import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.display.Orientation import io.github.sds100.keymapper.system.phone.CallState import timber.log.Timber +import java.time.LocalTime class TestConstraintSnapshot( val appInForeground: String? = null, @@ -23,6 +24,7 @@ class TestConstraintSnapshot( val isBackFlashlightOn: Boolean = false, val isFrontFlashlightOn: Boolean = false, val isLockscreenShowing: Boolean = false, + val localTime: LocalTime = LocalTime.now(), ) : ConstraintSnapshot { override fun isSatisfied(constraint: Constraint): Boolean { @@ -94,6 +96,16 @@ class TestConstraintSnapshot( is Constraint.Discharging -> !isCharging is Constraint.LockScreenShowing -> isLockscreenShowing is Constraint.LockScreenNotShowing -> !isLockscreenShowing + is Constraint.Time -> { + val startTime = constraint.startTime + val endTime = constraint.endTime + + if (startTime.isAfter(endTime)) { + localTime.isAfter(startTime) || localTime.isBefore(endTime) + } else { + localTime.isAfter(startTime) && localTime.isBefore(endTime) + } + } } if (isSatisfied) { diff --git a/app/version.properties b/app/version.properties index aa81264386..2beb635ef1 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ -VERSION_NAME=3.0.1 -VERSION_CODE=103 +VERSION_NAME=3.1.0 +VERSION_CODE=115 VERSION_NUM=0 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt index 4efb85f341..9810cafe1e 100644 --- a/fastlane/metadata/android/en-US/title.txt +++ b/fastlane/metadata/android/en-US/title.txt @@ -1 +1 @@ -Key Mapper & Floating buttons \ No newline at end of file +Key Mapper & Floating Buttons \ No newline at end of file diff --git a/fastlane/metadata/android/tr_TR/full_description.txt b/fastlane/metadata/android/tr_TR/full_description.txt new file mode 100644 index 0000000000..b5be52ef5e --- /dev/null +++ b/fastlane/metadata/android/tr_TR/full_description.txt @@ -0,0 +1,55 @@ +# Klavyenizde veya oyun kumandanızda özel makrolar oluşturun, herhangi bir uygulamada ekran üstü düğmeler yapın ve ses düğmelerinizden yeni işlevler açın! + +Key Mapper, çok çeşitli düğme ve tuşları destekler*: + +- TÜM telefon düğmeleriniz (ses VE yan tuş) +- Oyun kumandaları (D-pad, ABXY ve çoğu diğer tuşlar) +- Klavyeler +- Kulaklık setleri ve kulaklıklar +- Parmak izi sensörü + +Yeterli tuş yok mu? Kendi ekran üstü düğme düzenlerinizi tasarlayın ve bunları gerçek tuşlar gibi yeniden atayın! + + +## Ne tür kısayollar oluşturabilirim? +-------------------------- + +100'den fazla bireysel eylemle, sınır gökyüzüdür. +Ekran dokunuşları ve hareketleri, klavye girişleri, uygulama açma, medya kontrolü ve hatta diğer uygulamalara doğrudan intent gönderme ile karmaşık makrolar oluşturun. + + +## Ne kadar kontrole sahibim? +--------------------------- + +TETİKLEYİCİLER: Bir tuş haritasını nasıl tetikleyeceğinize siz karar verirsiniz. Uzun basma, çift basma, istediğiniz kadar basma! Farklı cihazlardaki tuşları birleştirin ve hatta ekran üstü düğmelerinizi de dahil edin. + +EYLEMLER: Yapmak istediğiniz şey için özel makrolar tasarlayın. 100'den fazla eylemi birleştirin ve her biri arasındaki gecikmeyi seçin. Yavaş görevleri otomatikleştirmek ve hızlandırmak için tekrarlayan eylemler ayarlayın. + +KISITLAMALAR: Tuş haritalarının ne zaman çalışacağını ve ne zaman çalışmayacağını siz seçersiniz. Sadece belirli bir uygulamada mı gerekli? Ya da medya oynatılırken mi? Kilit ekranınızda mı? Maksimum kontrol için tuş haritalarınızı kısıtlayın. + +* Çoğu cihaz zaten desteklenmektedir ve zamanla yeni cihazlar eklenmektedir. Sizin için çalışmıyorsa bize bildirin, cihazınıza öncelik verebiliriz. + +Şu anda desteklenmeyen: + - Fare düğmeleri + - Oyun kumandalarındaki joystick ve tetikler (LT, RT) + + +Güvenlik ve erişilebilirlik hizmetleri +--------------------------- + +Bu uygulama, odaktaki uygulamayı algılamak ve tuş basımlarını kullanıcı tarafından tanımlanan tuş haritalarına uyarlamak için Android Erişilebilirlik API’sini kullanan Key Mapper Erişilebilirlik hizmetimizi içermektedir. Ayrıca, diğer uygulamaların üzerinde yardımcı Floating Button (Yüzen Düğme) katmanları çizmek için de kullanılmaktadır. + +Erişilebilirlik hizmetini çalıştırmayı kabul ettiğinizde, uygulama cihazınızı kullanırken tuş vuruşlarını izleyebilecektir. Ayrıca, uygulamada bu hareketleri kullanıyorsanız, kaydırma ve yakınlaştırma/daraltma hareketlerini de taklit edecektir. + +Herhangi bir kullanıcı verisi toplamayacak veya herhangi bir veriyi göndermek üzere internete bağlanmayacaktır. + +Erişilebilirlik hizmetimiz, yalnızca kullanıcı cihazındaki fiziksel bir tuşa bastığında tetiklenir. Kullanıcı, sistem erişilebilirlik ayarlarından bu hizmeti istediği zaman kapatabilir. + +Discord topluluğumuza gelip merhaba deyin! +www.keymapper.club + +Kodu kendiniz görün! (Açık kaynak) +code.keymapper.club + +Belgeleri okuyun: +docs.keymapper.club \ No newline at end of file diff --git a/fastlane/metadata/android/tr_TR/short_description.txt b/fastlane/metadata/android/tr_TR/short_description.txt new file mode 100644 index 0000000000..9dbca48bd4 --- /dev/null +++ b/fastlane/metadata/android/tr_TR/short_description.txt @@ -0,0 +1 @@ +HER ŞEY için kısayollar oluşturun! Ses, güç, klavye veya kayan düğmeleri yeniden atayın! \ No newline at end of file diff --git a/fastlane/metadata/android/tr_TR/title.txt b/fastlane/metadata/android/tr_TR/title.txt new file mode 100644 index 0000000000..9810cafe1e --- /dev/null +++ b/fastlane/metadata/android/tr_TR/title.txt @@ -0,0 +1 @@ +Key Mapper & Floating Buttons \ No newline at end of file