diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 1acb88303f..c3d0cecbad 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -10,11 +10,11 @@ jobs: steps: - uses: actions/checkout@v4 - - name: set up JDK 17 - uses: actions/setup-java@v3 + - name: set up JDK 21 + uses: actions/setup-java@v4 with: distribution: 'oracle' - java-version: 17 + java-version: 21 cache: 'gradle' - name: Unit tests @@ -36,11 +36,11 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: set up JDK 17 - uses: actions/setup-java@v3 + - name: set up JDK 21 + uses: actions/setup-java@v4 with: distribution: 'oracle' - java-version: 17 + java-version: 21 cache: 'gradle' - name: Ktlint check @@ -101,11 +101,11 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: set up JDK 17 - uses: actions/setup-java@v3 + - name: set up JDK 21 + uses: actions/setup-java@v4 with: distribution: 'oracle' - java-version: 17 + java-version: 21 cache: 'gradle' - name: Setup Android SDK diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 5fc9e345b2..ab8c9d4025 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -16,11 +16,11 @@ jobs: steps: - uses: actions/checkout@v4 - - name: set up JDK 17 - uses: actions/setup-java@v3 + - name: set up JDK 21 + uses: actions/setup-java@v4 with: distribution: 'oracle' - java-version: 17 + java-version: 21 cache: 'gradle' - name: Unit tests @@ -42,11 +42,11 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: set up JDK 17 - uses: actions/setup-java@v3 + - name: set up JDK 21 + uses: actions/setup-java@v4 with: distribution: 'oracle' - java-version: 17 + java-version: 21 cache: 'gradle' - name: Ktlint check @@ -113,11 +113,11 @@ jobs: property: VERSION_NUM value: ${{ github.run_number }} - - name: set up JDK 17 - uses: actions/setup-java@v3 + - name: set up JDK 21 + uses: actions/setup-java@v4 with: distribution: 'oracle' - java-version: 17 + java-version: 21 cache: 'gradle' - name: Setup Android SDK diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e70b59cba..e5dac9df9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,54 @@ +## [4.4.0](https://github.com/sds100/KeyMapper/releases/tag/v4.3.2) + +#### 12 September 2026 + +## Added + +- #2238 Add a "Reduce app killing" card to Expert Mode on Xiaomi/Redmi/Poco devices with steps to whitelist the app from battery optimisation, disable MIUI optimization, enable autostart, and adjust battery saver settings. +- Target Android 17 SDK. +- [#2227](https://github.com/keymapperorg/KeyMapper/issues/2227) Add a step to the Expert Mode setup wizard to grant local network access permission, required for ADB on Android 17+. +- #2223 Add one-finger and two-finger double-tap gestures to the TalkBack gesture action. +- [#2211](https://github.com/keymapperorg/KeyMapper/issues/2211) Add an "Enabled" field to sort the key map list by whether key maps are enabled or disabled. +- [#2234](https://github.com/keymapperorg/KeyMapper/issues/2234) Add a "Cycle keyboard language" action to switch between the enabled languages of the current keyboard, requiring the WRITE_SECURE_SETTINGS permission. +- [#2235](https://github.com/keymapperorg/KeyMapper/issues/2235) Show a tip suggesting a dedicated action when a Key Code action is added for a key that has one, such as volume, power, home, back, screenshot, media, microphone, and more, with a button to replace it immediately. +- [#2239](https://github.com/keymapperorg/KeyMapper/issues/2239) Add an option to set a custom seek amount for the Step media forward/backward actions, instead of the fixed 30 seconds. + +## Fixed + +- #2212 The warnings, group chips, breadcrumbs and group constraints on the home screen now scroll with the key map list instead of being pinned under the app bar, so there is much more room for key maps on small screens and in landscape. +- [#2231](https://github.com/keymapperorg/KeyMapper/issues/2231) Key Mapper no longer crashes when the system bridge starter files are copied while the device storage is temporarily unavailable, and the system bridge is no longer blocked by the auto start cooldown afterwards. +- [#2160](https://github.com/keymapperorg/KeyMapper/issues/2160) edits to the activity in a send intent action are no longer discarded when the screen is recreated (for example on a configuration change) before saving. +- [#2099](https://github.com/keymapperorg/KeyMapper/issues/2099) do not spam notifications that Expert mode failed to start on WiFi disconnection or the ADB pairing is broken. +- [#2097](https://github.com/keymapperorg/KeyMapper/issues/2097) potentially fix activities from some apps being missing from the Send Intent action's activity picker. +- [#2220](https://github.com/keymapperorg/KeyMapper/issues/2220) make invisible floating buttons more visible when editing. +- [#2209](https://github.com/keymapperorg/KeyMapper/issues/2209) multi-line shell command actions no longer fail with a syntax error when the script is pasted with Windows line endings. +- Expert mode works on 16KB page size systems. +- Launching Wireless Debugging screen for Expert Mode setup works on Android 17+. +- [#2219](https://github.com/keymapperorg/KeyMapper/issues/2219) fix: scale floating buttons when screen resolution changes. +- [#2210](https://github.com/keymapperorg/KeyMapper/issues/2210) Android TV DPAD center button behaves normally when accessibility service enabled. +- [#2070](https://github.com/keymapperorg/KeyMapper/issues/2070) long pressing a movable floating button now activates its long press key map instead of only being possible to drag. +- Floating Buttons immediately respond to toggling locked position. +- [#2232](https://github.com/keymapperorg/KeyMapper/issues/2232) Floating button options to show over keyboard and status bar apply immediately. +- [#2098](https://github.com/keymapperorg/KeyMapper/issues/2098) [#2233](https://github.com/keymapperorg/KeyMapper/issues/2233) export and import key maps on Android TV, where there is no usable system file picker. Export now saves directly to the Downloads folder, and import lets you choose from backups found there (in the F-Droid build, you can optionally grant "All files access" to see backups copied in from other devices; this is not requested in the Play Store build). +- [#2199](https://github.com/keymapperorg/KeyMapper/issues/2199) the "Fix" button on the battery optimisation warning no longer fails silently on devices without the per-app exemption screen (such as some Android TV builds). It now falls back to opening the general apps settings list. +- Actions screen scrolls when empty and tip does not fill up screen on small displays. +- Request notification listener permission for step media actions. +- Slider does not jiggle when sliding over the default value. +- #2217 tap, swipe and pinch screen actions now scale to the current display resolution, including + ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) #### 11 August 2026 ## Added -- #2207 Restore purchases button on the paywalls and the thank you screen so purchases can be - recovered - on another device or after reinstalling. -- #2207 Show the RevenueCat customer ID on the About screen. Tap it to copy it. -- #2203 Add an Expert Mode setting to turn off the power button emergency stop for people whose - faulty power button stops Expert Mode by accident. +- [#2207](https://github.com/keymapperorg/KeyMapper/issues/2207) Restore purchases button on the paywalls and the thank you screen so purchases can be recovered on another device or after reinstalling. +- [#2207](https://github.com/keymapperorg/KeyMapper/issues/2207) Show the RevenueCat customer ID on the About screen. Tap it to copy it. +- [#2203](https://github.com/keymapperorg/KeyMapper/issues/2203) Add an Expert Mode setting to turn off the power button emergency stop for people whose faulty power button stops Expert Mode by accident. ## Fixed -- #1929 Dismiss all notifications stops working after a while. +- [#1929](https://github.com/keymapperorg/KeyMapper/issues/1929) Dismiss all notifications stops working after a while. ## [4.3.0](https://github.com/sds100/KeyMapper/releases/tag/v4.3.0) @@ -21,14 +56,13 @@ ## Added -- #2184 Add a display resolution constraint. Pick from the display's supported resolutions or enter a - custom width and height. -- #2163 Add ringer mode constraints (Ring, Vibrate, Silent). -- #2174 Add "Do not remap by default" preference to the default options settings page. +- [#2184](https://github.com/keymapperorg/KeyMapper/issues/2184) Add a display resolution constraint. Pick from the display's supported resolutions or enter a custom width and height. +- [#2163](https://github.com/keymapperorg/KeyMapper/issues/2163) Add ringer mode constraints (Ring, Vibrate, Silent). +- [#2174](https://github.com/keymapperorg/KeyMapper/issues/2174) Add "Do not remap by default" preference to the default options settings page. ## Fixed -- #2187 App constraints do not work with floating buttons +- [#2187](https://github.com/keymapperorg/KeyMapper/issues/2187) App constraints do not work with floating buttons ## [4.2.1](https://github.com/sds100/KeyMapper/releases/tag/v4.2.1) @@ -36,19 +70,15 @@ ## Added -- #2140 Add monochrome app icon layer for themed icon support on Android 13+. +- [#2140](https://github.com/keymapperorg/KeyMapper/issues/2140) Add monochrome app icon layer for themed icon support on Android 13+. ## Fixed -- #2154 The expert mode debug screen is now only accessible after the expert mode warning has been - acknowledged. -- #2156 Do not throw an error when the Talkback application can not be found because there are many - different package names out there. -- #2153 Prevent Direct Boot startup from initializing credential-encrypted app storage before the - user unlocks. -- #2157 The "choose setting" screen now uses `settings list` via the system bridge when expert mode - is active, surfacing all device settings instead of only those visible through the - ContentProvider. +- [#2154](https://github.com/keymapperorg/KeyMapper/issues/2154) The expert mode debug screen is now only accessible after the expert mode warning has been acknowledged. +- [#2156](https://github.com/keymapperorg/KeyMapper/issues/2156) Do not throw an error when the Talkback application can not be found because there are many different package names out there. +- [#2153](https://github.com/keymapperorg/KeyMapper/issues/2153) Prevent Direct Boot startup from initializing credential-encrypted app storage before the user unlocks. +- [#2157](https://github.com/keymapperorg/KeyMapper/issues/2157) The "choose setting" screen now uses + `settings list` via the system bridge when expert mode is active, surfacing all device settings instead of only those visible through the ContentProvider. ## [4.2.0](https://github.com/sds100/KeyMapper/releases/tag/v4.2.0) @@ -56,16 +86,13 @@ ## Fixed -- #2074 Scrolling the action or trigger list no longer accidentally moves items; reordering by drag - now only activates from the drag handle or via long-press. +- [#2074](https://github.com/keymapperorg/KeyMapper/issues/2074) Scrolling the action or trigger list no longer accidentally moves items; reordering by drag now only activates from the drag handle or via long-press. ## Changed -- #1369 Add content descriptions to drag handles and custom "Move up"/"Move down" accessibility - actions for trigger and action list items, improving TalkBack support for reordering. -- #262 Add "TalkBack gesture" action to simulate TalkBack navigation gestures (swipes, multi-finger - taps, and multi-directional swipes). -- #2076 Use "any input device" as the default for triggers. +- [#1369](https://github.com/keymapperorg/KeyMapper/issues/1369) Add content descriptions to drag handles and custom "Move up"/"Move down" accessibility actions for trigger and action list items, improving TalkBack support for reordering. +- [#262](https://github.com/keymapperorg/KeyMapper/issues/262) Add "TalkBack gesture" action to simulate TalkBack navigation gestures (swipes, multi-finger taps, and multi-directional swipes). +- [#2076](https://github.com/keymapperorg/KeyMapper/issues/2076) Use "any input device" as the default for triggers. ## [4.1.0](https://github.com/sds100/KeyMapper/releases/tag/v4.1.0) @@ -73,15 +100,13 @@ ## Added -- #2067 add action to select all text in the focused field. -- #2045 add action to input on-screen keyboard enter/send button. -- #2106 disable the keyboard auto-switching setting when manually switching the keyboard in the Key - Mapper homescreen menu. -- #1029 add action to show a toast message. -- #2081 add getevent debug screen. -- #2087 small segmented button text is not readable in dark mode. -- #2077 rename "This device" and "any device" to "This Android device" and "Any input device" to - prevent confusion. +- [#2067](https://github.com/keymapperorg/KeyMapper/issues/2067) add action to select all text in the focused field. +- [#2045](https://github.com/keymapperorg/KeyMapper/issues/2045) add action to input on-screen keyboard enter/send button. +- [#2106](https://github.com/keymapperorg/KeyMapper/issues/2106) disable the keyboard auto-switching setting when manually switching the keyboard in the Key Mapper homescreen menu. +- [#1029](https://github.com/keymapperorg/KeyMapper/issues/1029) add action to show a toast message. +- [#2081](https://github.com/keymapperorg/KeyMapper/issues/2081) add getevent debug screen. +- [#2087](https://github.com/keymapperorg/KeyMapper/issues/2087) small segmented button text is not readable in dark mode. +- [#2077](https://github.com/keymapperorg/KeyMapper/issues/2077) rename "This device" and "any device" to "This Android device" and "Any input device" to prevent confusion. ## Changed @@ -89,8 +114,8 @@ ## Fixed -- #2091 show an error on the "open device assistant" action when no device assistant is installed. -- #2107 clarify the crashed accessibility service dialog text and keep only Cancel/Restart actions. +- [#2091](https://github.com/keymapperorg/KeyMapper/issues/2091) show an error on the "open device assistant" action when no device assistant is installed. +- [#2107](https://github.com/keymapperorg/KeyMapper/issues/2107) clarify the crashed accessibility service dialog text and keep only Cancel/Restart actions. ## [4.0.5](https://github.com/sds100/KeyMapper/releases/tag/v4.0.5) @@ -98,10 +123,10 @@ ## Fixed -- #2047 allow empty text in Text action. -- #2056 replace old "PRO" in triggers on home screen with "Expert". -- #2053 reduce latency when a lot of key maps with open app actions. -- #2054 fix "Fix key event action" bottom sheet done button being hidden on small screens. +- [#2047](https://github.com/keymapperorg/KeyMapper/issues/2047) allow empty text in Text action. +- [#2056](https://github.com/keymapperorg/KeyMapper/issues/2056) replace old "PRO" in triggers on home screen with "Expert". +- [#2053](https://github.com/keymapperorg/KeyMapper/issues/2053) reduce latency when a lot of key maps with open app actions. +- [#2054](https://github.com/keymapperorg/KeyMapper/issues/2054) fix "Fix key event action" bottom sheet done button being hidden on small screens. ## [4.0.4](https://github.com/sds100/KeyMapper/releases/tag/v4.0.4) @@ -109,18 +134,17 @@ ## Added -- #2024 support Expert mode on all Android versions supported by Key Mapper (8.0+). -- #2025 add report bug button to home screen menu. -- #2027 Make the key map sorting feature easier to understand. -- #2016 Show a warning when repeating a key code action less than 20 ms with expert mode triggers. +- [#2024](https://github.com/keymapperorg/KeyMapper/issues/2024) support Expert mode on all Android versions supported by Key Mapper (8.0+). +- [#2025](https://github.com/keymapperorg/KeyMapper/issues/2025) add report bug button to home screen menu. +- [#2027](https://github.com/keymapperorg/KeyMapper/issues/2027) Make the key map sorting feature easier to understand. +- [#2016](https://github.com/keymapperorg/KeyMapper/issues/2016) Show a warning when repeating a key code action less than 20 ms with expert mode triggers. - Show dialog if Expert mode fails to start after 60 seconds instead of waiting indefinitely. ## Fixed -- #2030 do not filter out unknown evdev key events. -- #2028 work around Shizuku bug on Mediatek devices that prevents Expert mode from starting. -- #2034 catch errors when injecting events with Expert mode on Xiaomi devices and show warning to - fix on home screen. +- [#2030](https://github.com/keymapperorg/KeyMapper/issues/2030) do not filter out unknown evdev key events. +- [#2028](https://github.com/keymapperorg/KeyMapper/issues/2028) work around Shizuku bug on Mediatek devices that prevents Expert mode from starting. +- [#2034](https://github.com/keymapperorg/KeyMapper/issues/2034) catch errors when injecting events with Expert mode on Xiaomi devices and show warning to fix on home screen. ## [4.0.3](https://github.com/sds100/KeyMapper/releases/tag/v4.0.3) @@ -128,10 +152,8 @@ ## Fixed -- [#2006](https://github.com/keymapperorg/KeyMapper/issues/2006) Actually fixed the bug with crash - with Locale switching -- [#2014](https://github.com/keymapperorg/KeyMapper/issues/2014) Reduce mouse latency when remapped - with Expert mode. +- [#2006](https://github.com/keymapperorg/KeyMapper/issues/2006) Actually fixed the bug with crash with Locale switching +- [#2014](https://github.com/keymapperorg/KeyMapper/issues/2014) Reduce mouse latency when remapped with Expert mode. ## [4.0.2](https://github.com/sds100/KeyMapper/releases/tag/v4.0.2) @@ -143,8 +165,7 @@ ## Removed -- [#1973](https://github.com/keymapperorg/KeyMapper/issues/1973) [#2006](https://github.com/keymapperorg/KeyMapper/issues/2006) - Removed language switching feature due to crash with no solution. +- [#1973](https://github.com/keymapperorg/KeyMapper/issues/1973) [#2006](https://github.com/keymapperorg/KeyMapper/issues/2006) Removed language switching feature due to crash with no solution. ## [4.0.1](https://github.com/sds100/KeyMapper/releases/tag/v4.0.1) @@ -152,13 +173,12 @@ ## Fixed -- #2007 Volume up/down action bottom sheet is cut off on some devices. -- #2004 Do not crash when launching wireless debugging screen on some devices. -- #2005 NPE in onSaveInstanceState. -- #2000 tell the user to tap OS version or build number. -- #1996 Check if Night Shift is supported on a device before activating to prevent the screen going - black when activated. -- #1999 Use a more reliable method to check whether Shell has GRANT_RUNTIME_PERMISSIONS permission. +- [#2007](https://github.com/keymapperorg/KeyMapper/issues/2007) Volume up/down action bottom sheet is cut off on some devices. +- [#2004](https://github.com/keymapperorg/KeyMapper/issues/2004) Do not crash when launching wireless debugging screen on some devices. +- [#2005](https://github.com/keymapperorg/KeyMapper/issues/2005) NPE in onSaveInstanceState. +- [#2000](https://github.com/keymapperorg/KeyMapper/issues/2000) tell the user to tap OS version or build number. +- [#1996](https://github.com/keymapperorg/KeyMapper/issues/1996) Check if Night Shift is supported on a device before activating to prevent the screen going black when activated. +- [#1999](https://github.com/keymapperorg/KeyMapper/issues/1999) Use a more reliable method to check whether Shell has GRANT_RUNTIME_PERMISSIONS permission. ## [4.0.0](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0) @@ -172,39 +192,24 @@ See the changes in the previous beta releases for everything new. There is _a lo ## Added -- [#1970](https://github.com/keymapperorg/KeyMapper/issues/1970) dynamically build the key code list - so key codes in new Android releases are automatically - included. -- [#1939](https://github.com/keymapperorg/KeyMapper/issues/1939) show notification when Expert Mode - fails to start due to be being disconnected from WiFi. -- [#1973](https://github.com/keymapperorg/KeyMapper/issues/1973) add setting to change app language - on Android 13+. +- [#1970](https://github.com/keymapperorg/KeyMapper/issues/1970) dynamically build the key code list so key codes in new Android releases are automatically included. +- [#1939](https://github.com/keymapperorg/KeyMapper/issues/1939) show notification when Expert Mode fails to start due to be being disconnected from WiFi. +- [#1973](https://github.com/keymapperorg/KeyMapper/issues/1973) add setting to change app language on Android 13+. ## Fixed -- [#1986](https://github.com/keymapperorg/KeyMapper/issues/1986) trigger screen is usable on - slightly rectangular screens with a low DPI. +- [#1986](https://github.com/keymapperorg/KeyMapper/issues/1986) trigger screen is usable on slightly rectangular screens with a low DPI. - [#1972](https://github.com/keymapperorg/KeyMapper/issues/1972) Expert Mode works on Android 10. -- [#1976](https://github.com/keymapperorg/KeyMapper/issues/1976) Panic in Rust system bridge code on - some devices. -- [#1971](https://github.com/keymapperorg/KeyMapper/issues/1971) Media actions work again in some - apps, like YouTube. -- [#1961](https://github.com/keymapperorg/KeyMapper/issues/1961) Disabling setup assistant shows a - notification asking for pairing code immediately. -- [#1983](https://github.com/keymapperorg/KeyMapper/issues/1983) Inputting a modifier key and - another key as actions through Expert mode applies the correct key character map. -- [#1990](https://github.com/keymapperorg/KeyMapper/issues/1990) Passthrough the device id of the - trigger to the key event action if one is not manually specified -- [#1982](https://github.com/keymapperorg/KeyMapper/issues/1982) Text action does not need Key - Mapper input method on Android 13+. -- [#1989](https://github.com/keymapperorg/KeyMapper/issues/1989) center the "Trigger and actions" - and "Constraint and more" tabs. -- [#1392](https://github.com/keymapperorg/KeyMapper/issues/1392) Add action to enable/disable/toggle - night shift. -- [#1675](https://github.com/keymapperorg/KeyMapper/issues/1675) Option to make floating buttons - movable. -- [#1949](https://github.com/keymapperorg/KeyMapper/issues/1949) Floating buttons are completely - invisible when pressed if background and border opacity is set to 0. +- [#1976](https://github.com/keymapperorg/KeyMapper/issues/1976) Panic in Rust system bridge code on some devices. +- [#1971](https://github.com/keymapperorg/KeyMapper/issues/1971) Media actions work again in some apps, like YouTube. +- [#1961](https://github.com/keymapperorg/KeyMapper/issues/1961) Disabling setup assistant shows a notification asking for pairing code immediately. +- [#1983](https://github.com/keymapperorg/KeyMapper/issues/1983) Inputting a modifier key and another key as actions through Expert mode applies the correct key character map. +- [#1990](https://github.com/keymapperorg/KeyMapper/issues/1990) Passthrough the device id of the trigger to the key event action if one is not manually specified +- [#1982](https://github.com/keymapperorg/KeyMapper/issues/1982) Text action does not need Key Mapper input method on Android 13+. +- [#1989](https://github.com/keymapperorg/KeyMapper/issues/1989) center the "Trigger and actions" and "Constraint and more" tabs. +- [#1392](https://github.com/keymapperorg/KeyMapper/issues/1392) Add action to enable/disable/toggle night shift. +- [#1675](https://github.com/keymapperorg/KeyMapper/issues/1675) Option to make floating buttons movable. +- [#1949](https://github.com/keymapperorg/KeyMapper/issues/1949) Floating buttons are completely invisible when pressed if background and border opacity is set to 0. ## [4.0.0 Beta 6](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.06) @@ -212,19 +217,13 @@ See the changes in the previous beta releases for everything new. There is _a lo ## Added -- [#1964](https://github.com/keymapperorg/KeyMapper/issues/1964) show the command to start Expert - Mode with a shell command. +- [#1964](https://github.com/keymapperorg/KeyMapper/issues/1964) show the command to start Expert Mode with a shell command. ## Bug fixes -- [#1968](https://github.com/keymapperorg/KeyMapper/issues/1968) Device controls action no longer - works on Android 16+ so it has been disabled on new Android - versions. -- [#1967](https://github.com/keymapperorg/KeyMapper/issues/1967) Still start system bridge if - granting WRITE_SECURE_SETTINGS fails. -- [#1965](https://github.com/keymapperorg/KeyMapper/issues/1965) Better system bridge support on - Xiaomi devices and ask to enable "USB debugging security - settings" in developer options. +- [#1968](https://github.com/keymapperorg/KeyMapper/issues/1968) Device controls action no longer works on Android 16+ so it has been disabled on new Android versions. +- [#1967](https://github.com/keymapperorg/KeyMapper/issues/1967) Still start system bridge if granting WRITE_SECURE_SETTINGS fails. +- [#1965](https://github.com/keymapperorg/KeyMapper/issues/1965) Better system bridge support on Xiaomi devices and ask to enable "USB debugging security settings" in developer options. ## [4.0.0 Beta 5](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.05) @@ -234,15 +233,12 @@ Happy new year! ## Added -- [#1947](https://github.com/keymapperorg/KeyMapper/issues/1947) show tip to use expert mode where - the old option for screen off remapping used to be +- [#1947](https://github.com/keymapperorg/KeyMapper/issues/1947) show tip to use expert mode where the old option for screen off remapping used to be ## Bug fixes -- [#1955](https://github.com/keymapperorg/KeyMapper/issues/1955) step forward and step backward - media actions support more apps. -- [#1940](https://github.com/keymapperorg/KeyMapper/issues/1940) improve reliability of clicking - pairing code button in Wireless Debugging settings. +- [#1955](https://github.com/keymapperorg/KeyMapper/issues/1955) step forward and step backward media actions support more apps. +- [#1940](https://github.com/keymapperorg/KeyMapper/issues/1940) improve reliability of clicking pairing code button in Wireless Debugging settings. ## [4.0.0 Beta 4](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.04) @@ -250,37 +246,24 @@ Happy new year! Merry Christmas from the Key Mapper team! 🎄 -Renamed PRO mode to Expert mode because it sounded like a paid premium feature even though it is -free. +Renamed PRO mode to Expert mode because it sounded like a paid premium feature even though it is free. ## Added -- [#1915](https://github.com/keymapperorg/KeyMapper/issues/1915) ask user to remove "adb shell" from - Shell command. -- [#1904](https://github.com/keymapperorg/KeyMapper/issues/1904) inform the user how to enable the - accessibility service with PRO mode or ADB. -- [#1911](https://github.com/keymapperorg/KeyMapper/issues/1911) constraint for physical device - orientation that ignores auto rotate setting. -- [#1918](https://github.com/keymapperorg/KeyMapper/issues/1918) improve how key event actions are - performed with system bridge. -- [#1905](https://github.com/keymapperorg/KeyMapper/issues/1905) system bridge log is now visible in - Key Mapper log. -- [#1941](https://github.com/keymapperorg/KeyMapper/issues/1941) show loading indicator when - starting system bridge. +- [#1915](https://github.com/keymapperorg/KeyMapper/issues/1915) ask user to remove "adb shell" from Shell command. +- [#1904](https://github.com/keymapperorg/KeyMapper/issues/1904) inform the user how to enable the accessibility service with PRO mode or ADB. +- [#1911](https://github.com/keymapperorg/KeyMapper/issues/1911) constraint for physical device orientation that ignores auto rotate setting. +- [#1918](https://github.com/keymapperorg/KeyMapper/issues/1918) improve how key event actions are performed with system bridge. +- [#1905](https://github.com/keymapperorg/KeyMapper/issues/1905) system bridge log is now visible in Key Mapper log. +- [#1941](https://github.com/keymapperorg/KeyMapper/issues/1941) show loading indicator when starting system bridge. ## Bug fixes -- [#1913](https://github.com/keymapperorg/KeyMapper/issues/1913) actually save the option to detect - with scan code -- [#1931](https://github.com/keymapperorg/KeyMapper/issues/1931) fix Close and Remove From Recents - action on some Android 13 revisions -- [#1926](https://github.com/keymapperorg/KeyMapper/issues/1926) PRO mode triggers for external - devices work when the device reconnects. -- [#1918](https://github.com/keymapperorg/KeyMapper/issues/1918) PRO mode key maps can input key - codes that aren't originally supported by the trigger - device. -- [#1934](https://github.com/keymapperorg/KeyMapper/issues/1934) hold down option for Tap Screen - action is added back. +- [#1913](https://github.com/keymapperorg/KeyMapper/issues/1913) actually save the option to detect with scan code +- [#1931](https://github.com/keymapperorg/KeyMapper/issues/1931) fix Close and Remove From Recents action on some Android 13 revisions +- [#1926](https://github.com/keymapperorg/KeyMapper/issues/1926) PRO mode triggers for external devices work when the device reconnects. +- [#1918](https://github.com/keymapperorg/KeyMapper/issues/1918) PRO mode key maps can input key codes that aren't originally supported by the trigger device. +- [#1934](https://github.com/keymapperorg/KeyMapper/issues/1934) hold down option for Tap Screen action is added back. - Log less verbose. ## [4.0.0 Beta 3](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.03) @@ -289,28 +272,18 @@ free. ## Added -- [#1871](https://github.com/keymapperorg/KeyMapper/issues/1871) action to modify any system - settings. -- [#1221](https://github.com/keymapperorg/KeyMapper/issues/1221) action to show a custom - notification. -- [#1491](https://github.com/keymapperorg/KeyMapper/issues/1491) action to toggle/enable/disable - hotspot. -- [#1414](https://github.com/keymapperorg/KeyMapper/issues/1414) constraint for when the keyboard is - showing. -- [#1900](https://github.com/keymapperorg/KeyMapper/issues/1900) log to logcat if extra logging is - enabled. -- [#1902](https://github.com/keymapperorg/KeyMapper/issues/1902) add toggle next to record trigger - button to use PRO mode. -- [#1909](https://github.com/keymapperorg/KeyMapper/issues/1909) categorise constraints similar to - actions. +- [#1871](https://github.com/keymapperorg/KeyMapper/issues/1871) action to modify any system settings. +- [#1221](https://github.com/keymapperorg/KeyMapper/issues/1221) action to show a custom notification. +- [#1491](https://github.com/keymapperorg/KeyMapper/issues/1491) action to toggle/enable/disable hotspot. +- [#1414](https://github.com/keymapperorg/KeyMapper/issues/1414) constraint for when the keyboard is showing. +- [#1900](https://github.com/keymapperorg/KeyMapper/issues/1900) log to logcat if extra logging is enabled. +- [#1902](https://github.com/keymapperorg/KeyMapper/issues/1902) add toggle next to record trigger button to use PRO mode. +- [#1909](https://github.com/keymapperorg/KeyMapper/issues/1909) categorise constraints similar to actions. ## Bug fixes -- [#1901](https://github.com/keymapperorg/KeyMapper/issues/1901) prompt user to set default USB - configuration to 'No data transfer' after starting pro mode. -- [#1898](https://github.com/keymapperorg/KeyMapper/issues/1898) do not launch directly into the - Wireless Debugging activity on Xiaomi devices due to a bug - they introduced. +- [#1901](https://github.com/keymapperorg/KeyMapper/issues/1901) prompt user to set default USB configuration to 'No data transfer' after starting pro mode. +- [#1898](https://github.com/keymapperorg/KeyMapper/issues/1898) do not launch directly into the Wireless Debugging activity on Xiaomi devices due to a bug they introduced. ## [4.0.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.02) @@ -318,18 +291,13 @@ free. ## Added -- [#1890](https://github.com/keymapperorg/KeyMapper/issues/1890) add button to save log to file and - share it. The clipboard button now cuts off older entries - and keeps newest ones. +- [#1890](https://github.com/keymapperorg/KeyMapper/issues/1890) add button to save log to file and share it. The clipboard button now cuts off older entries and keeps newest ones. ## Fixed -- Only autostart PRO mode with Shizuku if Shizuku permission is granted. Otherwise fallback to - method with Wireless Debugging and WRITE_SECURE_SETTINGS permission. -- Starting system bridge for the first time would be janky because granting READ_LOGS kills the app - process. Only grant for READ_LOGS when sharing logcat from settings. -- [#1886](https://github.com/keymapperorg/KeyMapper/issues/1886) mobile data actions work in PRO - mode. +- Only autostart PRO mode with Shizuku if Shizuku permission is granted. Otherwise fallback to method with Wireless Debugging and WRITE_SECURE_SETTINGS permission. +- Starting system bridge for the first time would be janky because granting READ_LOGS kills the app process. Only grant for READ_LOGS when sharing logcat from settings. +- [#1886](https://github.com/keymapperorg/KeyMapper/issues/1886) mobile data actions work in PRO mode. ## [4.0.0 Beta 1](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.01) @@ -337,63 +305,39 @@ free. ## Added -- [#761](https://github.com/keymapperorg/KeyMapper/issues/761) Detect keys with scancodes. Key - Mapper will do this automatically if the key code is unknown - or you record different physical keys from the same device with the same key code. +- [#761](https://github.com/keymapperorg/KeyMapper/issues/761) Detect keys with scancodes. Key Mapper will do this automatically if the key code is unknown or you record different physical keys from the same device with the same key code. - Redesign the Settings screen. - Shortcuts on the trigger screen that guide you how to set up different types of buttons. -- [#1788](https://github.com/keymapperorg/KeyMapper/issues/1788) dismiss lockscreen when launching - app action from lockscreen +- [#1788](https://github.com/keymapperorg/KeyMapper/issues/1788) dismiss lockscreen when launching app action from lockscreen - Show tips for parallel and sequence triggers, and constraints in the trigger screen -- [#397](https://github.com/keymapperorg/KeyMapper/issues/397) enable/disable all key maps in a - group -- [#1773](https://github.com/keymapperorg/KeyMapper/issues/1773) Option to show floating buttons on - top of keyboard or notification panel. -- [#1335](https://github.com/keymapperorg/KeyMapper/issues/1335) Intent API to enable/disable/toggle - a key map. -- [#114](https://github.com/keymapperorg/KeyMapper/issues/114) action to force stop app, and an - action to clear an app from recents -- [#727](https://github.com/keymapperorg/KeyMapper/issues/727) Actions to send SMS messages: "Send - SMS" and "Compose SMS" -- [#1819](https://github.com/keymapperorg/KeyMapper/issues/1819) Explain how to enable the - accessibility service restricted setting +- [#397](https://github.com/keymapperorg/KeyMapper/issues/397) enable/disable all key maps in a group +- [#1773](https://github.com/keymapperorg/KeyMapper/issues/1773) Option to show floating buttons on top of keyboard or notification panel. +- [#1335](https://github.com/keymapperorg/KeyMapper/issues/1335) Intent API to enable/disable/toggle a key map. +- [#114](https://github.com/keymapperorg/KeyMapper/issues/114) action to force stop app, and an action to clear an app from recents +- [#727](https://github.com/keymapperorg/KeyMapper/issues/727) Actions to send SMS messages: "Send SMS" and "Compose SMS" +- [#1819](https://github.com/keymapperorg/KeyMapper/issues/1819) Explain how to enable the accessibility service restricted setting - [#661](https://github.com/keymapperorg/KeyMapper/issues/661) Action to execute shell commands. -- [#991](https://github.com/keymapperorg/KeyMapper/issues/991) Consolidated volume and stream - actions. +- [#991](https://github.com/keymapperorg/KeyMapper/issues/991) Consolidated volume and stream actions. - [#1066](https://github.com/keymapperorg/KeyMapper/issues/1066) Action to mute/unmute microphone. -- [#985](https://github.com/keymapperorg/KeyMapper/issues/985) Constraints for foldable hinge being - open/closed. +- [#985](https://github.com/keymapperorg/KeyMapper/issues/985) Constraints for foldable hinge being open/closed. ## Removed -- The key event relay service is now also used on all Android versions below Android 14. The - broadcast receiver method is no longer used. -- Minimum supported Android version is now 8.0. Less than 1% of users are on older versions than - this and dropping support simplifies the codebase and maintenance. -- Dropped support for showing a keyboard picker notification and automatically showing it when a - device connects. This is only supported on Android 8.1 and is extra work to maintain it. -- Dropped support for rerouting key events on Android 11. This was a workaround for a specific bug - in Android 11 which fewer than 10% of users are using and less are probably using that feature. +- The key event relay service is now also used on all Android versions below Android 14. The broadcast receiver method is no longer used. +- Minimum supported Android version is now 8.0. Less than 1% of users are on older versions than this and dropping support simplifies the codebase and maintenance. +- Dropped support for showing a keyboard picker notification and automatically showing it when a device connects. This is only supported on Android 8.1 and is extra work to maintain it. +- Dropped support for rerouting key events on Android 11. This was a workaround for a specific bug in Android 11 which fewer than 10% of users are using and less are probably using that feature. ## Fixed - Restoring subgroups works and does not freeze Key Mapper. - Do not show duplicate constraint shortcuts. - Make WiFi connected constraints more reliable -- [#1818](https://github.com/keymapperorg/KeyMapper/issues/1818) auto switching of the Key Mapper - keyboard when typing is more reliable and quicker on - Android 13+ -- [#1818](https://github.com/keymapperorg/KeyMapper/issues/1818) auto switching of the Key Mapper - keyboard now requires Android 11+. On older versions it was - only possible with WRITE_SECURE_SETTINGS but very few users are on these old Android versions so - it is not worth the extra maintenance effort. -- [#1818](https://github.com/keymapperorg/KeyMapper/issues/1818) the Key Mapper GUI Keyboard is no - longer mentioned in the app. It still works but PRO mode - and the auto switching feature are the preferred way to work around the limitations of the Key - Mapper keyboard. +- [#1818](https://github.com/keymapperorg/KeyMapper/issues/1818) auto switching of the Key Mapper keyboard when typing is more reliable and quicker on Android 13+ +- [#1818](https://github.com/keymapperorg/KeyMapper/issues/1818) auto switching of the Key Mapper keyboard now requires Android 11+. On older versions it was only possible with WRITE_SECURE_SETTINGS but very few users are on these old Android versions so it is not worth the extra maintenance effort. +- [#1818](https://github.com/keymapperorg/KeyMapper/issues/1818) the Key Mapper GUI Keyboard is no longer mentioned in the app. It still works but PRO mode and the auto switching feature are the preferred way to work around the limitations of the Key Mapper keyboard. - Allow selecting notification and alarm sound and not just ringtones for Sound action. -- [#1064](https://github.com/keymapperorg/KeyMapper/issues/1064) wait for switch keyboard action to - complete before doing next action. +- [#1064](https://github.com/keymapperorg/KeyMapper/issues/1064) wait for switch keyboard action to complete before doing next action. ## [3.2.1](https://github.com/sds100/KeyMapper/releases/tag/v3.2.1) @@ -409,46 +353,29 @@ free. ## Added -- [#1466](https://github.com/keymapperorg/KeyMapper/issues/1466) show onboarding when creating a key - map for the first time +- [#1466](https://github.com/keymapperorg/KeyMapper/issues/1466) show onboarding when creating a key map for the first time - [#1729](https://github.com/keymapperorg/KeyMapper/issues/1729) target Android 16. -- [#1725](https://github.com/keymapperorg/KeyMapper/issues/1725) action to move cursor to - previous/next character, word, line, paragraph, or page. +- [#1725](https://github.com/keymapperorg/KeyMapper/issues/1725) action to move cursor to previous/next character, word, line, paragraph, or page. - Names for new key codes introduced in recent Android versions ## Changed -- [#1711](https://github.com/keymapperorg/KeyMapper/issues/1711) major refactoring of the entire - codebase into separate Gradle modules. -- [#1701](https://github.com/keymapperorg/KeyMapper/issues/1701) improve the order of the actions - and categories +- [#1711](https://github.com/keymapperorg/KeyMapper/issues/1711) major refactoring of the entire codebase into separate Gradle modules. +- [#1701](https://github.com/keymapperorg/KeyMapper/issues/1701) improve the order of the actions and categories - Key Mapper keyboard or Shizuku are no longer required for the action to move the cursor to the end ## Bug fixes -- [#1686](https://github.com/keymapperorg/KeyMapper/issues/1686) (more fixes) do not show some - screens behind system bars on the left/right side of the - device. -- [#1701](https://github.com/keymapperorg/KeyMapper/issues/1701) optimize the trigger screen for - smaller screens so elements are less cut off. -- [#1699](https://github.com/keymapperorg/KeyMapper/issues/1699) Do not highlight a floating button - as if it is pressed after triggering a key event action - from it. +- [#1686](https://github.com/keymapperorg/KeyMapper/issues/1686) (more fixes) do not show some screens behind system bars on the left/right side of the device. +- [#1701](https://github.com/keymapperorg/KeyMapper/issues/1701) optimize the trigger screen for smaller screens so elements are less cut off. +- [#1699](https://github.com/keymapperorg/KeyMapper/issues/1699) Do not highlight a floating button as if it is pressed after triggering a key event action from it. - Button to copy the key map UID to the clipboard is invisible on small screens. -- [#1709](https://github.com/keymapperorg/KeyMapper/issues/1709) Quick settings tiles were causing - crashes on Android 15 -- [#1714](https://github.com/keymapperorg/KeyMapper/issues/1714) Editing "interact with app element" - actions works. -- [#1707](https://github.com/keymapperorg/KeyMapper/issues/1707) do not back up sound files if no - key maps are using them -- [#797](https://github.com/keymapperorg/KeyMapper/issues/797) [#1719](https://github.com/keymapperorg/KeyMapper/issues/1719) - execute key maps that can fix themselves. E.g having an action to select the Key Mapper - Keyboard before a key code action. -- [#1735](https://github.com/keymapperorg/KeyMapper/issues/1735) Floating buttons no longer flash on - screen when the accessibility service restarts if they - are not supposed to be visible. -- [#1717](https://github.com/keymapperorg/KeyMapper/issues/1717) do not show floating buttons if - quick settings is expanded on the lockscreen. +- [#1709](https://github.com/keymapperorg/KeyMapper/issues/1709) Quick settings tiles were causing crashes on Android 15 +- [#1714](https://github.com/keymapperorg/KeyMapper/issues/1714) Editing "interact with app element" actions works. +- [#1707](https://github.com/keymapperorg/KeyMapper/issues/1707) do not back up sound files if no key maps are using them +- [#797](https://github.com/keymapperorg/KeyMapper/issues/797) [#1719](https://github.com/keymapperorg/KeyMapper/issues/1719) execute key maps that can fix themselves. E.g having an action to select the Key Mapper Keyboard before a key code action. +- [#1735](https://github.com/keymapperorg/KeyMapper/issues/1735) Floating buttons no longer flash on screen when the accessibility service restarts if they are not supposed to be visible. +- [#1717](https://github.com/keymapperorg/KeyMapper/issues/1717) do not show floating buttons if quick settings is expanded on the lockscreen. - Correctly show error that Airplane mode actions require root ## [3.1.1](https://github.com/sds100/KeyMapper/releases/tag/v3.1.1) @@ -457,16 +384,13 @@ free. ## Added -- [#1637](https://github.com/keymapperorg/KeyMapper/issues/1637) show a home screen error if - notification permission is not granted. -- [#1435](https://github.com/keymapperorg/KeyMapper/issues/1435) Pick system sounds/ringtones for - the Sound action. +- [#1637](https://github.com/keymapperorg/KeyMapper/issues/1637) show a home screen error if notification permission is not granted. +- [#1435](https://github.com/keymapperorg/KeyMapper/issues/1435) Pick system sounds/ringtones for the Sound action. ## Bug fixes - Do not automatically select the key mapper keyboard when the accessibility service starts. -- [#1686](https://github.com/keymapperorg/KeyMapper/issues/1686) do not show some screens behind - system bars on the left/right side of the device. +- [#1686](https://github.com/keymapperorg/KeyMapper/issues/1686) do not show some screens behind system bars on the left/right side of the device. - Use same sized list items when choosing a constraint. ## [3.1.0](https://github.com/sds100/KeyMapper/releases/tag/v3.1.0) @@ -476,12 +400,9 @@ free. ## Added - [#699](https://github.com/keymapperorg/KeyMapper/issues/699) Time constraints ⏰ -- [#257](https://github.com/keymapperorg/KeyMapper/issues/257) Action to interact with user - interface elements inside other apps. -- [#1663](https://github.com/keymapperorg/KeyMapper/issues/1663) Actions to stop, step forward, and - step backward playing media. -- [#1682](https://github.com/keymapperorg/KeyMapper/issues/1682) Show "Purchased!" text next to the - use button for advanced triggers. +- [#257](https://github.com/keymapperorg/KeyMapper/issues/257) Action to interact with user interface elements inside other apps. +- [#1663](https://github.com/keymapperorg/KeyMapper/issues/1663) Actions to stop, step forward, and step backward playing media. +- [#1682](https://github.com/keymapperorg/KeyMapper/issues/1682) Show "Purchased!" text next to the use button for advanced triggers. ## Changed @@ -489,14 +410,10 @@ free. ## Bug fixes -- [#1683](https://github.com/keymapperorg/KeyMapper/issues/1683) key event actions work in Minecraft - and other apps again. +- [#1683](https://github.com/keymapperorg/KeyMapper/issues/1683) key event actions work in Minecraft and other apps again. - Export log files as .txt instead of .zip files. -- [#1684](https://github.com/keymapperorg/KeyMapper/issues/1684) Removed the redundant and broken - refresh devices button when configuring a key event action - because they are automatically refreshed anyway. -- [#1687](https://github.com/keymapperorg/KeyMapper/issues/1687) restoring key map groups would - sometimes fail. +- [#1684](https://github.com/keymapperorg/KeyMapper/issues/1684) Removed the redundant and broken refresh devices button when configuring a key event action because they are automatically refreshed anyway. +- [#1687](https://github.com/keymapperorg/KeyMapper/issues/1687) restoring key map groups would sometimes fail. ## [3.0.1](https://github.com/sds100/KeyMapper/releases/tag/v3.0.1) @@ -504,44 +421,27 @@ free. ## Added -- [#1652](https://github.com/keymapperorg/KeyMapper/issues/1652) Bring back the menu button to show - input method picker. -- [#1657](https://github.com/keymapperorg/KeyMapper/issues/1657) Turn on repeat by default for - volume actions. +- [#1652](https://github.com/keymapperorg/KeyMapper/issues/1652) Bring back the menu button to show input method picker. +- [#1657](https://github.com/keymapperorg/KeyMapper/issues/1657) Turn on repeat by default for volume actions. ## Changed -- [#1654](https://github.com/keymapperorg/KeyMapper/issues/1654) The Key Mapper keyboard is now - required again for Text actions because the accessibility - service API does not work in all situations. -- [#1653](https://github.com/keymapperorg/KeyMapper/issues/1653) Hide the export/import menu buttons - in groups. -- [#1553](https://github.com/keymapperorg/KeyMapper/issues/1553) Hide double press option for side - key and fingerprint gesture triggers because it is - misleading. Double activations can be done with sequence triggers instead. +- [#1654](https://github.com/keymapperorg/KeyMapper/issues/1654) The Key Mapper keyboard is now required again for Text actions because the accessibility service API does not work in all situations. +- [#1653](https://github.com/keymapperorg/KeyMapper/issues/1653) Hide the export/import menu buttons in groups. +- [#1553](https://github.com/keymapperorg/KeyMapper/issues/1553) Hide double press option for side key and fingerprint gesture triggers because it is misleading. Double activations can be done with sequence triggers instead. - [#1669](https://github.com/keymapperorg/KeyMapper/issues/1669) Change quick settings tile text. ## Bug fixes -- Inputting key events with Shizuku does not crash the app if a Key Mapper keyboard is being used at - the same time. And latency when inputting key events has been improved in some apps. -- [#1646](https://github.com/keymapperorg/KeyMapper/issues/1646) disabling Bluetooth clears the list - of connected devices. -- [#1655](https://github.com/keymapperorg/KeyMapper/issues/1655) do not crash when restoring key map - groups. -- [#1649](https://github.com/keymapperorg/KeyMapper/issues/1649) show purchase verification failed - error if no network connection. -- [#1648](https://github.com/keymapperorg/KeyMapper/issues/1648) caching purchases works so you can - use floating buttons and assistant trigger without an - internet connection. -- [#1658](https://github.com/keymapperorg/KeyMapper/issues/1658) floating buttons appear in the - wrong place in portrait if saved in landscape. -- [#1659](https://github.com/keymapperorg/KeyMapper/issues/1659) Use trigger does not work if the - screen orientation changes when re-entering the app. -- [#1668](https://github.com/keymapperorg/KeyMapper/issues/1668) Crashes when floating menu does not - fit in the display height. -- [#1667](https://github.com/keymapperorg/KeyMapper/issues/1667) Hold down mode UI is missing from - 2.8. +- Inputting key events with Shizuku does not crash the app if a Key Mapper keyboard is being used at the same time. And latency when inputting key events has been improved in some apps. +- [#1646](https://github.com/keymapperorg/KeyMapper/issues/1646) disabling Bluetooth clears the list of connected devices. +- [#1655](https://github.com/keymapperorg/KeyMapper/issues/1655) do not crash when restoring key map groups. +- [#1649](https://github.com/keymapperorg/KeyMapper/issues/1649) show purchase verification failed error if no network connection. +- [#1648](https://github.com/keymapperorg/KeyMapper/issues/1648) caching purchases works so you can use floating buttons and assistant trigger without an internet connection. +- [#1658](https://github.com/keymapperorg/KeyMapper/issues/1658) floating buttons appear in the wrong place in portrait if saved in landscape. +- [#1659](https://github.com/keymapperorg/KeyMapper/issues/1659) Use trigger does not work if the screen orientation changes when re-entering the app. +- [#1668](https://github.com/keymapperorg/KeyMapper/issues/1668) Crashes when floating menu does not fit in the display height. +- [#1667](https://github.com/keymapperorg/KeyMapper/issues/1667) Hold down mode UI is missing from 2.8. ## [3.0.0](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0) @@ -549,8 +449,7 @@ _See the changes from previous 3.0 Beta releases._ #### 10 April 2025 -- [#1635](https://github.com/keymapperorg/KeyMapper/issues/1635) do not crash if the URL for the - HTTP action is malformed +- [#1635](https://github.com/keymapperorg/KeyMapper/issues/1635) do not crash if the URL for the HTTP action is malformed ## [3.0 Beta 5](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.5) @@ -562,8 +461,7 @@ _See the changes from previous 3.0 Beta releases as well._ ## Bug fixes -- [#1627](https://github.com/keymapperorg/KeyMapper/issues/1627) open camera app action does not - work when device is locked +- [#1627](https://github.com/keymapperorg/KeyMapper/issues/1627) open camera app action does not work when device is locked ## [3.0 Beta 4](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.4) @@ -573,11 +471,8 @@ _See the changes from previous 3.0 Beta releases as well._ ## Added -- [#1620](https://github.com/keymapperorg/KeyMapper/issues/1620) enable Key Mapper Basic Input - Method without user interaction on Android 13+. -- [#1619](https://github.com/keymapperorg/KeyMapper/issues/1619) Automatically select the non key - mapper keyboard when the device is locked and wanting to - type. +- [#1620](https://github.com/keymapperorg/KeyMapper/issues/1620) enable Key Mapper Basic Input Method without user interaction on Android 13+. +- [#1619](https://github.com/keymapperorg/KeyMapper/issues/1619) Automatically select the non key mapper keyboard when the device is locked and wanting to type. ## Changed @@ -585,8 +480,7 @@ _See the changes from previous 3.0 Beta releases as well._ ## Bug fixes -- [#1618](https://github.com/keymapperorg/KeyMapper/issues/1618), [#1532](https://github.com/keymapperorg/KeyMapper/issues/1532), [#1590](https://github.com/keymapperorg/KeyMapper/issues/1590) - The Key Mapper keyboard is no longer required for Text actions. +- [#1618](https://github.com/keymapperorg/KeyMapper/issues/1618), [#1532](https://github.com/keymapperorg/KeyMapper/issues/1532), [#1590](https://github.com/keymapperorg/KeyMapper/issues/1590) The Key Mapper keyboard is no longer required for Text actions. - Flashlight action works again on devices that do not support variable brightness ## [3.0 Beta 3](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.3) @@ -599,13 +493,9 @@ This is not an April Fool's joke ;) ## Added -- [#320](https://github.com/keymapperorg/KeyMapper/issues/320) 🗂️ Key map groups! You can now sort - key maps into groups and share constraints across all the - key maps in the group. -- [#1586](https://github.com/keymapperorg/KeyMapper/issues/1586) 🎨 Customise floating button border - and background opacity. -- [#1276](https://github.com/keymapperorg/KeyMapper/issues/1276) Use key event scan code as fallback - if the key code is unrecognized. +- [#320](https://github.com/keymapperorg/KeyMapper/issues/320) 🗂️ Key map groups! You can now sort key maps into groups and share constraints across all the key maps in the group. +- [#1586](https://github.com/keymapperorg/KeyMapper/issues/1586) 🎨 Customise floating button border and background opacity. +- [#1276](https://github.com/keymapperorg/KeyMapper/issues/1276) Use key event scan code as fallback if the key code is unrecognized. - Make it clearer that the instructions need to be read for the assistant trigger. ## Changed @@ -615,16 +505,12 @@ This is not an April Fool's joke ;) ## Bug fixes -- Do not hide floating button when the quick settings are showing if the key map action can collapse - the status bar. +- Do not hide floating button when the quick settings are showing if the key map action can collapse the status bar. - Do not show floating buttons on the always-on display or when the display is "off". - Prompt to unlock device when tapping "Go back" on the floating menu. -- [#1596](https://github.com/keymapperorg/KeyMapper/issues/1596) Do not show the option for front - flashlight if the device does not have one. -- [#1598](https://github.com/keymapperorg/KeyMapper/issues/1598) Do not allow changing flashlight - brightness on devices that do not support it. -- Omit "Back" from Back flashlight actions and constraints since most devices only have a back - flashlight anyway. +- [#1596](https://github.com/keymapperorg/KeyMapper/issues/1596) Do not show the option for front flashlight if the device does not have one. +- [#1598](https://github.com/keymapperorg/KeyMapper/issues/1598) Do not allow changing flashlight brightness on devices that do not support it. +- Omit "Back" from Back flashlight actions and constraints since most devices only have a back flashlight anyway. - Do not ask for which flashlight to use in constraints if the device only has one ## [3.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.2) @@ -633,68 +519,43 @@ This is not an April Fool's joke ;) ## Added -- [#1560](https://github.com/keymapperorg/KeyMapper/issues/1560) Action to change flashlight - brightness and also set a custom brightness when enabling the - flashlight. +- [#1560](https://github.com/keymapperorg/KeyMapper/issues/1560) Action to change flashlight brightness and also set a custom brightness when enabling the flashlight. - Prompt to unlock device when using a floating button as a trigger from the lock screen ## Changed -- [#1577](https://github.com/keymapperorg/KeyMapper/issues/1577) Move unsupported actions to the - bottom of the list and do not allow selecting root actions - if root permission is not granted. -- [#1593](https://github.com/keymapperorg/KeyMapper/issues/1593) Deprecate the 'Open menu' action by - not letting new key maps use it. It is a relic of the - past when most apps had a 3-dot menu with a consistent content description making it somewhat easy - to identify. +- [#1577](https://github.com/keymapperorg/KeyMapper/issues/1577) Move unsupported actions to the bottom of the list and do not allow selecting root actions if root permission is not granted. +- [#1593](https://github.com/keymapperorg/KeyMapper/issues/1593) Deprecate the 'Open menu' action by not letting new key maps use it. It is a relic of the past when most apps had a 3-dot menu with a consistent content description making it somewhat easy to identify. ## Bug fixes -- [#1585](https://github.com/keymapperorg/KeyMapper/issues/1585) Track changes when editing key maps - and only prompt to discard changes if there were indeed - changes. +- [#1585](https://github.com/keymapperorg/KeyMapper/issues/1585) Track changes when editing key maps and only prompt to discard changes if there were indeed changes. ## [3.0 Beta 1](https://github.com/sds100/KeyMapper/releases/tag/v3.0.0-beta.1) #### 26 March 2025 -Most of the codebase has been touched and most of the user interface has been rewritten -in Jetpack Compose, resulting in many improvements to the user experience. +Most of the codebase has been touched and most of the user interface has been rewritten in Jetpack Compose, resulting in many improvements to the user experience. ## Added -- [#1407](https://github.com/keymapperorg/KeyMapper/issues/1407) New trigger! Add floating buttons - on top of other apps to input key maps. +- [#1407](https://github.com/keymapperorg/KeyMapper/issues/1407) New trigger! Add floating buttons on top of other apps to input key maps. - Key maps are much more dense on the home screen. - Button to pause/resume key maps at the top of the home screen. -- [#1502](https://github.com/keymapperorg/KeyMapper/issues/1502) Constraint for lockscreen is (not) - showing. -- [#1203](https://github.com/keymapperorg/KeyMapper/issues/1203) Show a share sheet after exporting - key maps rather than asking where to store it. This - solves the problem when no apps are installed to select where to back it up. You can still find - the file in the Downloads file. -- [#1531](https://github.com/keymapperorg/KeyMapper/issues/1531) Show shortcuts to quickly add - recently used actions and constraints. -- [#1487](https://github.com/keymapperorg/KeyMapper/issues/1487) Add confirmation dialog when - importing key maps and offer the option to replace all the key - maps or append to the list. -- [#1546](https://github.com/keymapperorg/KeyMapper/issues/1546) Add short explanation of what - constraints mean on top of the list. -- [#1548](https://github.com/keymapperorg/KeyMapper/issues/1548) Dynamically change key map enabled - switch label. -- [#1562](https://github.com/keymapperorg/KeyMapper/issues/1562) Import key maps by opening .json - and .zip files from other apps and file managers. +- [#1502](https://github.com/keymapperorg/KeyMapper/issues/1502) Constraint for lockscreen is (not) showing. +- [#1203](https://github.com/keymapperorg/KeyMapper/issues/1203) Show a share sheet after exporting key maps rather than asking where to store it. This solves the problem when no apps are installed to select where to back it up. You can still find the file in the Downloads file. +- [#1531](https://github.com/keymapperorg/KeyMapper/issues/1531) Show shortcuts to quickly add recently used actions and constraints. +- [#1487](https://github.com/keymapperorg/KeyMapper/issues/1487) Add confirmation dialog when importing key maps and offer the option to replace all the key maps or append to the list. +- [#1546](https://github.com/keymapperorg/KeyMapper/issues/1546) Add short explanation of what constraints mean on top of the list. +- [#1548](https://github.com/keymapperorg/KeyMapper/issues/1548) Dynamically change key map enabled switch label. +- [#1562](https://github.com/keymapperorg/KeyMapper/issues/1562) Import key maps by opening .json and .zip files from other apps and file managers. ## Bug fixes -- [#1518](https://github.com/keymapperorg/KeyMapper/issues/1518) detect more apps that are playing - media (fix to previous fix). -- [#1545](https://github.com/keymapperorg/KeyMapper/issues/1545) support phone call constraints in - more apps. -- [#1536](https://github.com/keymapperorg/KeyMapper/issues/1536) 'Edit action' sometimes does not - appear. -- [#1507](https://github.com/keymapperorg/KeyMapper/issues/1507) only vibrate once when mixing - short, long, and double press key maps. +- [#1518](https://github.com/keymapperorg/KeyMapper/issues/1518) detect more apps that are playing media (fix to previous fix). +- [#1545](https://github.com/keymapperorg/KeyMapper/issues/1545) support phone call constraints in more apps. +- [#1536](https://github.com/keymapperorg/KeyMapper/issues/1536) 'Edit action' sometimes does not appear. +- [#1507](https://github.com/keymapperorg/KeyMapper/issues/1507) only vibrate once when mixing short, long, and double press key maps. - Prevent various system errors from crashing the apps. ## [2.8.3](https://github.com/sds100/KeyMapper/releases/tag/v2.8.3) @@ -703,15 +564,12 @@ in Jetpack Compose, resulting in many improvements to the user experience. ## Changed -- [#1474](https://github.com/keymapperorg/KeyMapper/issues/1474) always allow specifying a name for - key map launcher shortcuts. -- [#1533](https://github.com/keymapperorg/KeyMapper/issues/1533) simplify naming of ringer mode - actions. +- [#1474](https://github.com/keymapperorg/KeyMapper/issues/1474) always allow specifying a name for key map launcher shortcuts. +- [#1533](https://github.com/keymapperorg/KeyMapper/issues/1533) simplify naming of ringer mode actions. ## Bug fixes -- [#1535](https://github.com/keymapperorg/KeyMapper/issues/1535) side key/assistant trigger does not - trigger from non-assistant buttons. +- [#1535](https://github.com/keymapperorg/KeyMapper/issues/1535) side key/assistant trigger does not trigger from non-assistant buttons. ## [2.8.2](https://github.com/sds100/KeyMapper/releases/tag/v2.8.2) @@ -719,16 +577,12 @@ in Jetpack Compose, resulting in many improvements to the user experience. ## Changes -- [#1514](https://github.com/keymapperorg/KeyMapper/issues/1514), [#1454](https://github.com/keymapperorg/KeyMapper/issues/1454) - Improving naming of assistant trigger to also refer to the side key and do not force - the user to select Key Mapper as the assistant. +- [#1514](https://github.com/keymapperorg/KeyMapper/issues/1514), [#1454](https://github.com/keymapperorg/KeyMapper/issues/1454) Improving naming of assistant trigger to also refer to the side key and do not force the user to select Key Mapper as the assistant. ## Bug fixes -- [#1461](https://github.com/keymapperorg/KeyMapper/issues/1461) fix: crash on startup due to - getting MotionEvent device -- [#1518](https://github.com/keymapperorg/KeyMapper/issues/1518) fix: detect apps playing media - without a notification for media constraints +- [#1461](https://github.com/keymapperorg/KeyMapper/issues/1461) fix: crash on startup due to getting MotionEvent device +- [#1518](https://github.com/keymapperorg/KeyMapper/issues/1518) fix: detect apps playing media without a notification for media constraints ## [2.8.1](https://github.com/sds100/KeyMapper/releases/tag/v2.8.1) @@ -736,21 +590,13 @@ in Jetpack Compose, resulting in many improvements to the user experience. ## Bug fixes -- [#1433](https://github.com/keymapperorg/KeyMapper/issues/1433) open Key Mapper by default and not - the Assistant Trigger app. -- [#1386](https://github.com/keymapperorg/KeyMapper/issues/1386) wait for sequence trigger timeout - before triggering other overlapping triggers. -- [#1449](https://github.com/keymapperorg/KeyMapper/issues/1449) improve the key mapper crashed - dialog. -- [#1415](https://github.com/keymapperorg/KeyMapper/issues/1415) make the discard changes dialog - less confusing. -- [#1440](https://github.com/keymapperorg/KeyMapper/issues/1440) do not show the "Button not - detected?" bottom sheet every time you open the config key map - screen in some cases. -- [#1447](https://github.com/keymapperorg/KeyMapper/issues/1447) the app bar when configuring an - Intent action would extend to the top of the screen. -- [#1444](https://github.com/keymapperorg/KeyMapper/issues/1444) use the correct icon for screen - on/off constraints. +- [#1433](https://github.com/keymapperorg/KeyMapper/issues/1433) open Key Mapper by default and not the Assistant Trigger app. +- [#1386](https://github.com/keymapperorg/KeyMapper/issues/1386) wait for sequence trigger timeout before triggering other overlapping triggers. +- [#1449](https://github.com/keymapperorg/KeyMapper/issues/1449) improve the key mapper crashed dialog. +- [#1415](https://github.com/keymapperorg/KeyMapper/issues/1415) make the discard changes dialog less confusing. +- [#1440](https://github.com/keymapperorg/KeyMapper/issues/1440) do not show the "Button not detected?" bottom sheet every time you open the config key map screen in some cases. +- [#1447](https://github.com/keymapperorg/KeyMapper/issues/1447) the app bar when configuring an Intent action would extend to the top of the screen. +- [#1444](https://github.com/keymapperorg/KeyMapper/issues/1444) use the correct icon for screen on/off constraints. ## [2.8.0](https://github.com/sds100/KeyMapper/releases/tag/v2.8.0) @@ -759,42 +605,28 @@ in Jetpack Compose, resulting in many improvements to the user experience. ## Added - [#491](https://github.com/keymapperorg/KeyMapper/issues/491) remap DPAD buttons. -- [#1223](https://github.com/keymapperorg/KeyMapper/issues/1223) sort key maps by triggers, actions, - constraints and options. -- [#1344](https://github.com/keymapperorg/KeyMapper/issues/1344) target Android 15 and support - edge-to-edge display mode. -- [#1372](https://github.com/keymapperorg/KeyMapper/issues/1372) allow Shizuku features to work with - Sui. -- [#1391](https://github.com/keymapperorg/KeyMapper/issues/1391) button in Settings to reset all - settings to their defaults. +- [#1223](https://github.com/keymapperorg/KeyMapper/issues/1223) sort key maps by triggers, actions, constraints and options. +- [#1344](https://github.com/keymapperorg/KeyMapper/issues/1344) target Android 15 and support edge-to-edge display mode. +- [#1372](https://github.com/keymapperorg/KeyMapper/issues/1372) allow Shizuku features to work with Sui. +- [#1391](https://github.com/keymapperorg/KeyMapper/issues/1391) button in Settings to reset all settings to their defaults. ## Changed -- [#1412](https://github.com/keymapperorg/KeyMapper/issues/1412) make the record trigger text - clearer by saying it is recording. +- [#1412](https://github.com/keymapperorg/KeyMapper/issues/1412) make the record trigger text clearer by saying it is recording. ## Removed -- [#1411](https://github.com/keymapperorg/KeyMapper/issues/1411) remove the app intro screen for - remapping fingerprint gestures because almost all new phones - do not support them anyway. +- [#1411](https://github.com/keymapperorg/KeyMapper/issues/1411) remove the app intro screen for remapping fingerprint gestures because almost all new phones do not support them anyway. ## Bug fixes -- [#1426](https://github.com/keymapperorg/KeyMapper/issues/1426), [#1434](https://github.com/keymapperorg/KeyMapper/issues/1434) - key map launcher shortcut icons were white. -- [#1410](https://github.com/keymapperorg/KeyMapper/issues/1410) vibrations not working on Android - 13+. -- [#1342](https://github.com/keymapperorg/KeyMapper/issues/1342) add missing Meta modifier options - for key event actions. -- [#1375](https://github.com/keymapperorg/KeyMapper/issues/1375) memory leak when rebinding to the - relay service in the Key Mapper GUI Keyboard. -- [#1376](https://github.com/keymapperorg/KeyMapper/issues/1376) Key Mapper Basic Input Method would - not work on Android 14+ in some situations. -- [#1094](https://github.com/keymapperorg/KeyMapper/issues/1094) wrong repository name in the - introduction screen. -- [#1387](https://github.com/keymapperorg/KeyMapper/issues/1387) some app shortcuts would not open - on Android 14+. +- [#1426](https://github.com/keymapperorg/KeyMapper/issues/1426), [#1434](https://github.com/keymapperorg/KeyMapper/issues/1434) key map launcher shortcut icons were white. +- [#1410](https://github.com/keymapperorg/KeyMapper/issues/1410) vibrations not working on Android 13+. +- [#1342](https://github.com/keymapperorg/KeyMapper/issues/1342) add missing Meta modifier options for key event actions. +- [#1375](https://github.com/keymapperorg/KeyMapper/issues/1375) memory leak when rebinding to the relay service in the Key Mapper GUI Keyboard. +- [#1376](https://github.com/keymapperorg/KeyMapper/issues/1376) Key Mapper Basic Input Method would not work on Android 14+ in some situations. +- [#1094](https://github.com/keymapperorg/KeyMapper/issues/1094) wrong repository name in the introduction screen. +- [#1387](https://github.com/keymapperorg/KeyMapper/issues/1387) some app shortcuts would not open on Android 14+. ## [2.7.2](https://github.com/sds100/KeyMapper/releases/tag/v2.7.2) @@ -802,20 +634,14 @@ in Jetpack Compose, resulting in many improvements to the user experience. ## Added -- [#1298](https://github.com/keymapperorg/KeyMapper/issues/1298) add action to launch the Android - device controls screen for managing Home devices. +- [#1298](https://github.com/keymapperorg/KeyMapper/issues/1298) add action to launch the Android device controls screen for managing Home devices. ## Bug fixes -- [#1342](https://github.com/keymapperorg/KeyMapper/issues/1342) add Meta modifier keys to the key - event action. -- [#1101](https://github.com/keymapperorg/KeyMapper/issues/1101) deprecate the toggle split screen - action on Android 12L and newer. -- [#1370](https://github.com/keymapperorg/KeyMapper/issues/1370) warn the user that extra - permissions are required for the Launch app action on Xiaomi - devices -- [#1371](https://github.com/keymapperorg/KeyMapper/issues/1371) try to fix the app not opening on - people's devices +- [#1342](https://github.com/keymapperorg/KeyMapper/issues/1342) add Meta modifier keys to the key event action. +- [#1101](https://github.com/keymapperorg/KeyMapper/issues/1101) deprecate the toggle split screen action on Android 12L and newer. +- [#1370](https://github.com/keymapperorg/KeyMapper/issues/1370) warn the user that extra permissions are required for the Launch app action on Xiaomi devices +- [#1371](https://github.com/keymapperorg/KeyMapper/issues/1371) try to fix the app not opening on people's devices ## [2.7.1](https://github.com/sds100/KeyMapper/releases/tag/v2.7.1) @@ -823,12 +649,9 @@ in Jetpack Compose, resulting in many improvements to the user experience. ## Bug fixes -- [#1360](https://github.com/keymapperorg/KeyMapper/issues/1360) complete the documentation for - advanced triggers at docs.keymapper.club. -- [#1364](https://github.com/keymapperorg/KeyMapper/issues/1364) key event actions no longer crash - when using Shizuku. -- [#1362](https://github.com/keymapperorg/KeyMapper/issues/1362) backing up and restoring key maps - works again. +- [#1360](https://github.com/keymapperorg/KeyMapper/issues/1360) complete the documentation for advanced triggers at docs.keymapper.club. +- [#1364](https://github.com/keymapperorg/KeyMapper/issues/1364) key event actions no longer crash when using Shizuku. +- [#1362](https://github.com/keymapperorg/KeyMapper/issues/1362) backing up and restoring key maps works again. ## [2.7.0](https://github.com/sds100/KeyMapper/releases/tag/v2.7.0) @@ -836,25 +659,19 @@ in Jetpack Compose, resulting in many improvements to the user experience. ## Added -- [#1274](https://github.com/keymapperorg/KeyMapper/issues/1274) New trigger! You can now trigger - your key maps from any of the ways your phone launches the - assistant! This could be the Bixby button, Power button, or a button on your headset. +- [#1274](https://github.com/keymapperorg/KeyMapper/issues/1274) New trigger! You can now trigger your key maps from any of the ways your phone launches the assistant! This could be the Bixby button, Power button, or a button on your headset. - [#1304](https://github.com/keymapperorg/KeyMapper/issues/1304) Vietnamese translations. ## Bug fixes -- [#1222](https://github.com/keymapperorg/KeyMapper/issues/1222) [#1307](https://github.com/keymapperorg/KeyMapper/issues/1307) - Key Mapper doesn't execute the correct app shortcut action if you created multiple - from the same app. -- [#1328](https://github.com/keymapperorg/KeyMapper/issues/1328) Single-character non-ASCII - TEXT_BLOCK input crashes the service +- [#1222](https://github.com/keymapperorg/KeyMapper/issues/1222) [#1307](https://github.com/keymapperorg/KeyMapper/issues/1307) Key Mapper doesn't execute the correct app shortcut action if you created multiple from the same app. +- [#1328](https://github.com/keymapperorg/KeyMapper/issues/1328) Single-character non-ASCII TEXT_BLOCK input crashes the service ## [2.6.2](https://github.com/sds100/KeyMapper/releases/tag/v2.6.2) #### 9 September 2024 -- [#1293](https://github.com/keymapperorg/KeyMapper/issues/1293) Checkbox buttons were invisible - when configuring some actions. +- [#1293](https://github.com/keymapperorg/KeyMapper/issues/1293) Checkbox buttons were invisible when configuring some actions. ## [2.6.1](https://github.com/sds100/KeyMapper/releases/tag/v2.6.1) @@ -864,43 +681,27 @@ This release adds support for Android 14 and fixes some bugs associated with it. ### Added -- [#1256](https://github.com/keymapperorg/KeyMapper/issues/1256) Add Russian and Chinese Simplified - translations. Update other languages. -- [#1282](https://github.com/keymapperorg/KeyMapper/issues/1282) Add Assist key code as screen off - trigger for Bixby button. +- [#1256](https://github.com/keymapperorg/KeyMapper/issues/1256) Add Russian and Chinese Simplified translations. Update other languages. +- [#1282](https://github.com/keymapperorg/KeyMapper/issues/1282) Add Assist key code as screen off trigger for Bixby button. ### Bug fixes -- [#1218](https://github.com/keymapperorg/KeyMapper/issues/1218), [#1251](https://github.com/keymapperorg/KeyMapper/issues/1251) - Key event actions and triggering key maps from an intent were delayed by 1 second on - Android 14 due to new broadcast receiver restrictions. -- [#1175](https://github.com/keymapperorg/KeyMapper/issues/1175) Bypass the do not disturb - permission requirement for volume button triggers. -- [#1234](https://github.com/keymapperorg/KeyMapper/issues/1234) Granting permissions with Shizuku - crashes on Android 14. -- [#1249](https://github.com/keymapperorg/KeyMapper/issues/1249) Crash when opening help page from - the home page if no browser app for custom tabs was found. -- [#1250](https://github.com/keymapperorg/KeyMapper/issues/1250) Random crashes when picking a - screenshot for actions. -- [#1227](https://github.com/keymapperorg/KeyMapper/issues/1227) Deprecate Bluetooth actions on - Android 13+ due to new restrictions. -- [#1252](https://github.com/keymapperorg/KeyMapper/issues/1252) Add another Camera key code as - supported for screen off triggers. -- [#1219](https://github.com/keymapperorg/KeyMapper/issues/1219) Key Mapper notifications could not - be enabled on Android 14. -- [#1194](https://github.com/keymapperorg/KeyMapper/issues/1194) Deprecate closing the status bar on - Android 14 due to new restrictions. -- [#1190](https://github.com/keymapperorg/KeyMapper/issues/1190) Add a 3 second delay after the - screenshot action before showing the on-screen message - confirming it happened. +- [#1218](https://github.com/keymapperorg/KeyMapper/issues/1218), [#1251](https://github.com/keymapperorg/KeyMapper/issues/1251) Key event actions and triggering key maps from an intent were delayed by 1 second on Android 14 due to new broadcast receiver restrictions. +- [#1175](https://github.com/keymapperorg/KeyMapper/issues/1175) Bypass the do not disturb permission requirement for volume button triggers. +- [#1234](https://github.com/keymapperorg/KeyMapper/issues/1234) Granting permissions with Shizuku crashes on Android 14. +- [#1249](https://github.com/keymapperorg/KeyMapper/issues/1249) Crash when opening help page from the home page if no browser app for custom tabs was found. +- [#1250](https://github.com/keymapperorg/KeyMapper/issues/1250) Random crashes when picking a screenshot for actions. +- [#1227](https://github.com/keymapperorg/KeyMapper/issues/1227) Deprecate Bluetooth actions on Android 13+ due to new restrictions. +- [#1252](https://github.com/keymapperorg/KeyMapper/issues/1252) Add another Camera key code as supported for screen off triggers. +- [#1219](https://github.com/keymapperorg/KeyMapper/issues/1219) Key Mapper notifications could not be enabled on Android 14. +- [#1194](https://github.com/keymapperorg/KeyMapper/issues/1194) Deprecate closing the status bar on Android 14 due to new restrictions. +- [#1190](https://github.com/keymapperorg/KeyMapper/issues/1190) Add a 3 second delay after the screenshot action before showing the on-screen message confirming it happened. ## [2.6.0](https://github.com/sds100/KeyMapper/releases/tag/v2.6.0) #### 7 October 2023 -- [#550](https://github.com/keymapperorg/KeyMapper/issues/550) Action for doing pinches and swipes - on the screen with 2 or more fingers. Many thanks to - Tino (@pixel-shock) for working on this feature. 😊 +- [#550](https://github.com/keymapperorg/KeyMapper/issues/550) Action for doing pinches and swipes on the screen with 2 or more fingers. Many thanks to Tino (@pixel-shock) for working on this feature. 😊 ## [2.5.0](https://github.com/sds100/KeyMapper/releases/tag/v2.5.0) @@ -908,9 +709,7 @@ This release adds support for Android 14 and fixes some bugs associated with it. ### Added -- [#1157](https://github.com/keymapperorg/KeyMapper/pull/1157) Action for doing swipe gestures on - the screen with 1 or more fingers. Many thanks to Tino (@pixel-shock) for working on this feature. - 😊 +- [#1157](https://github.com/keymapperorg/KeyMapper/pull/1157) Action for doing swipe gestures on the screen with 1 or more fingers. Many thanks to Tino (@pixel-shock) for working on this feature. 😊 ## [2.4.6](https://github.com/sds100/KeyMapper/releases/tag/v2.4.6) @@ -918,8 +717,7 @@ This release adds support for Android 14 and fixes some bugs associated with it. ### Changed -- [#1148](https://github.com/keymapperorg/KeyMapper/issues/1148) Fix crash when accessibility - service started. +- [#1148](https://github.com/keymapperorg/KeyMapper/issues/1148) Fix crash when accessibility service started. ## [2.4.5](https://github.com/sds100/KeyMapper/releases/tag/v2.4.5) @@ -927,8 +725,7 @@ This release adds support for Android 14 and fixes some bugs associated with it. ### Changed -- [#1120](https://github.com/keymapperorg/KeyMapper/issues/1120) Do not change the UIDs of key maps - when importing them. +- [#1120](https://github.com/keymapperorg/KeyMapper/issues/1120) Do not change the UIDs of key maps when importing them. ## [2.4.4](https://github.com/sds100/KeyMapper/releases/tag/v2.4.4) @@ -936,24 +733,14 @@ This release adds support for Android 14 and fixes some bugs associated with it. ### Bug fixes -- [#1073](https://github.com/keymapperorg/KeyMapper/issues/1073) The toggle key maps quick settings - tile now looks - enabled when the key maps are resumed. -- [#1062](https://github.com/keymapperorg/KeyMapper/issues/1062) The select word at cursor action - would select the whole - line if the cursor is at the end of the line. -- [#999](https://github.com/keymapperorg/KeyMapper/issues/999) Remove delay when launching app - shortcut actions when at - the launcher. +- [#1073](https://github.com/keymapperorg/KeyMapper/issues/1073) The toggle key maps quick settings tile now looks enabled when the key maps are resumed. +- [#1062](https://github.com/keymapperorg/KeyMapper/issues/1062) The select word at cursor action would select the whole line if the cursor is at the end of the line. +- [#999](https://github.com/keymapperorg/KeyMapper/issues/999) Remove delay when launching app shortcut actions when at the launcher. ### Added -- [#1054](https://github.com/keymapperorg/KeyMapper/issues/1054) Make it clearer why key map - launcher shortcut can't be - created automatically. -- [#1068](https://github.com/keymapperorg/KeyMapper/issues/1068) Make the confirmation dialog when - leaving without - saving clearer. +- [#1054](https://github.com/keymapperorg/KeyMapper/issues/1054) Make it clearer why key map launcher shortcut can't be created automatically. +- [#1068](https://github.com/keymapperorg/KeyMapper/issues/1068) Make the confirmation dialog when leaving without saving clearer. ## [2.4.3](https://github.com/sds100/KeyMapper/releases/tag/v2.4.3) @@ -961,33 +748,18 @@ This release adds support for Android 14 and fixes some bugs associated with it. ### Bug fixes -- [#1052](https://github.com/keymapperorg/KeyMapper/issues/1052) Crash when disabling accessibility - service through the - Key Mapper notification on Android Lollipop and Marshmallow. -- [#1049](https://github.com/keymapperorg/KeyMapper/issues/1049) Enabling the accessibility service - automatically with - WRITE_SECURE_SETTINGS wouldn't work if no other accessibility features were enabled. -- [#1044](https://github.com/keymapperorg/KeyMapper/issues/1044) On some devices there was a random - crash in the - notification listener service. -- [#999](https://github.com/keymapperorg/KeyMapper/issues/999) Action to launch app has a 5-10 - second delay when you're - on your device's home screen. -- [#1047](https://github.com/keymapperorg/KeyMapper/issues/1047) App NOT playing media constraint - would be saved as - wrong constraint. -- [#1043](https://github.com/keymapperorg/KeyMapper/issues/1043) Inputting key events with Shizuku - didn't actually work - on release builds. OOPS. 🤦‍ +- [#1052](https://github.com/keymapperorg/KeyMapper/issues/1052) Crash when disabling accessibility service through the Key Mapper notification on Android Lollipop and Marshmallow. +- [#1049](https://github.com/keymapperorg/KeyMapper/issues/1049) Enabling the accessibility service automatically with WRITE_SECURE_SETTINGS wouldn't work if no other accessibility features were enabled. +- [#1044](https://github.com/keymapperorg/KeyMapper/issues/1044) On some devices there was a random crash in the notification listener service. +- [#999](https://github.com/keymapperorg/KeyMapper/issues/999) Action to launch app has a 5-10 second delay when you're on your device's home screen. +- [#1047](https://github.com/keymapperorg/KeyMapper/issues/1047) App NOT playing media constraint would be saved as wrong constraint. +- [#1043](https://github.com/keymapperorg/KeyMapper/issues/1043) Inputting key events with Shizuku didn't actually work on release builds. OOPS. 🤦‍ ### Added - [#1025](https://github.com/keymapperorg/KeyMapper/issues/1025) Support for Android 12L. -- [#1016](https://github.com/keymapperorg/KeyMapper/issues/1016) Ability to never show the denied Do - Not Disturb - permission errors if the device does not support those settings. -- [#1042](https://github.com/keymapperorg/KeyMapper/issues/1042) Put date in the timestamp in the - log. +- [#1016](https://github.com/keymapperorg/KeyMapper/issues/1016) Ability to never show the denied Do Not Disturb permission errors if the device does not support those settings. +- [#1042](https://github.com/keymapperorg/KeyMapper/issues/1042) Put date in the timestamp in the log. ## [2.4.2](https://github.com/sds100/KeyMapper/releases/tag/v2.4.2) @@ -995,9 +767,7 @@ This release adds support for Android 14 and fixes some bugs associated with it. ### Bug fixes -- [#1017](https://github.com/keymapperorg/KeyMapper/issues/1017) The app would go in an infinite - loop saying "Using root - to grant WRITE_SECURE_SETTINGS permission" on screen and then eventually crashing. +- [#1017](https://github.com/keymapperorg/KeyMapper/issues/1017) The app would go in an infinite loop saying "Using root to grant WRITE_SECURE_SETTINGS permission" on screen and then eventually crashing. ## [2.4.1](https://github.com/sds100/KeyMapper/releases/tag/v2.4.1) @@ -1011,9 +781,7 @@ This release adds support for Android 14 and fixes some bugs associated with it. - Crash if trying to grant permission with Shizuku but Key Mapper doesn't have Shizuku permission. - [#1009](https://github.com/keymapperorg/KeyMapper/issues/1009) Crash when trying to edit actions. -- [#1001](https://github.com/keymapperorg/KeyMapper/issues/1001) Crash when granting permission with - Shizuku on Android - 12+. +- [#1001](https://github.com/keymapperorg/KeyMapper/issues/1001) Crash when granting permission with Shizuku on Android 12+. ## [2.4.0](https://github.com/sds100/KeyMapper/releases/tag/v2.4.0) @@ -1025,60 +793,34 @@ See beta releases for bug fixes. #### Important -- [#748](https://github.com/keymapperorg/KeyMapper/issues/748) Android 12 and Material You support - 🎨! -- [#746](https://github.com/keymapperorg/KeyMapper/issues/746) Shizuku support for some features! - You can use this - instead of using a Key Mapper keyboard! +- [#748](https://github.com/keymapperorg/KeyMapper/issues/748) Android 12 and Material You support 🎨! +- [#746](https://github.com/keymapperorg/KeyMapper/issues/746) Shizuku support for some features! You can use this instead of using a Key Mapper keyboard! #### Actions -- [#603](https://github.com/keymapperorg/KeyMapper/issues/603) You can now edit actions! You don't - have to delete an - action and completely reconfigure it. +- [#603](https://github.com/keymapperorg/KeyMapper/issues/603) You can now edit actions! You don't have to delete an action and completely reconfigure it. - [#851](https://github.com/keymapperorg/KeyMapper/issues/850) Action to answer/end a phone call. - [#704](https://github.com/keymapperorg/KeyMapper/issues/704) Action to dismiss notifications. #### Constraints -- [#851](https://github.com/keymapperorg/KeyMapper/issues/851) Constraints for when the device is - ringing and in a phone - call. -- [#811](https://github.com/keymapperorg/KeyMapper/issues/811) Constraint for when the device is - locked. -- [#776](https://github.com/keymapperorg/KeyMapper/issues/776) Constraint for when an input method - is chosen. -- [#598](https://github.com/keymapperorg/KeyMapper/issues/598) Constraint for any app (not) playing - or a specific app - not playing media. -- [#702](https://github.com/keymapperorg/KeyMapper/issues/702) WiFi on/off/connected/disconnected - constraints. +- [#851](https://github.com/keymapperorg/KeyMapper/issues/851) Constraints for when the device is ringing and in a phone call. +- [#811](https://github.com/keymapperorg/KeyMapper/issues/811) Constraint for when the device is locked. +- [#776](https://github.com/keymapperorg/KeyMapper/issues/776) Constraint for when an input method is chosen. +- [#598](https://github.com/keymapperorg/KeyMapper/issues/598) Constraint for any app (not) playing or a specific app not playing media. +- [#702](https://github.com/keymapperorg/KeyMapper/issues/702) WiFi on/off/connected/disconnected constraints. - [#722](https://github.com/keymapperorg/KeyMapper/issues/722) Flashlight on/off constraint. #### Other -- [#911](https://github.com/keymapperorg/KeyMapper/issues/911) Detect camera button when screen is - off. -- [#780](https://github.com/keymapperorg/KeyMapper/issues/780) If the accessibility settings can't - be found prompt the - user to follow an online guide to do it with ADB. -- [#773](https://github.com/keymapperorg/KeyMapper/issues/773) Prompt for a message when the user - reports a bug. -- [#686](https://github.com/keymapperorg/KeyMapper/issues/686) Setting to switch to a different - input method on input - focus. -- [#715](https://github.com/keymapperorg/KeyMapper/issues/715) Show a share button after a - successful backup. -- [#716](https://github.com/keymapperorg/KeyMapper/issues/716) Support for a Key Mapper compatible - version of Hacker's - Keyboard. Releases can be found - here: https://github.com/keymapperorg/KeyMapperHackersKeyboard/releases -- [#955](https://github.com/keymapperorg/KeyMapper/issues/955) You can now detect the Menu and - Search button when the - screen is off. On some devices the Bixby button is detected by Key Mapper as the Menu button. -- [#928](https://github.com/keymapperorg/KeyMapper/issues/928) You can now call Termux RUN_COMMAND - intents because the - necessary permission has been added to Key Mapper. +- [#911](https://github.com/keymapperorg/KeyMapper/issues/911) Detect camera button when screen is off. +- [#780](https://github.com/keymapperorg/KeyMapper/issues/780) If the accessibility settings can't be found prompt the user to follow an online guide to do it with ADB. +- [#773](https://github.com/keymapperorg/KeyMapper/issues/773) Prompt for a message when the user reports a bug. +- [#686](https://github.com/keymapperorg/KeyMapper/issues/686) Setting to switch to a different input method on input focus. +- [#715](https://github.com/keymapperorg/KeyMapper/issues/715) Show a share button after a successful backup. +- [#716](https://github.com/keymapperorg/KeyMapper/issues/716) Support for a Key Mapper compatible version of Hacker's Keyboard. Releases can be found here: https://github.com/keymapperorg/KeyMapperHackersKeyboard/releases +- [#955](https://github.com/keymapperorg/KeyMapper/issues/955) You can now detect the Menu and Search button when the screen is off. On some devices the Bixby button is detected by Key Mapper as the Menu button. +- [#928](https://github.com/keymapperorg/KeyMapper/issues/928) You can now call Termux RUN_COMMAND intents because the necessary permission has been added to Key Mapper. ## [2.4.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v2.4.0-beta.02) @@ -1086,26 +828,15 @@ See beta releases for bug fixes. ### Bug fixes -- [#946](https://github.com/keymapperorg/KeyMapper/issues/946) Choosing a Bluetooth device - disconnected constraint would - save it as a connected constraint. -- [#981](https://github.com/keymapperorg/KeyMapper/issues/981) Trying to backup/restore would crash - the app if no - file-picker was installed. -- [#965](https://github.com/keymapperorg/KeyMapper/issues/965) The app would crash if you had - disabled key maps. -- [#957](https://github.com/keymapperorg/KeyMapper/issues/957) The dialog to choose flags for an - Intent action wouldn't - show the flags that had already been picked. +- [#946](https://github.com/keymapperorg/KeyMapper/issues/946) Choosing a Bluetooth device disconnected constraint would save it as a connected constraint. +- [#981](https://github.com/keymapperorg/KeyMapper/issues/981) Trying to backup/restore would crash the app if no file-picker was installed. +- [#965](https://github.com/keymapperorg/KeyMapper/issues/965) The app would crash if you had disabled key maps. +- [#957](https://github.com/keymapperorg/KeyMapper/issues/957) The dialog to choose flags for an Intent action wouldn't show the flags that had already been picked. ### Added -- [#955](https://github.com/keymapperorg/KeyMapper/issues/955) You can now detect the Menu and - Search button when the - screen is off. On some devices the Bixby button is detected by Key Mapper as the Menu button. -- [#928](https://github.com/keymapperorg/KeyMapper/issues/928) You can now call Termux RUN_COMMAND - intents because the - necessary permission has been added to Key Mapper. +- [#955](https://github.com/keymapperorg/KeyMapper/issues/955) You can now detect the Menu and Search button when the screen is off. On some devices the Bixby button is detected by Key Mapper as the Menu button. +- [#928](https://github.com/keymapperorg/KeyMapper/issues/928) You can now call Termux RUN_COMMAND intents because the necessary permission has been added to Key Mapper. ## [2.4.0 Beta 1](https://github.com/sds100/KeyMapper/releases/tag/v2.4.0-beta.01) @@ -1113,80 +844,48 @@ See beta releases for bug fixes. ### Changes -- [#815](https://github.com/keymapperorg/KeyMapper/issues/815) Always show the button to pick a - package when configuring - an intent action. -- [#749](https://github.com/keymapperorg/KeyMapper/issues/749) Remove do not disturb app intro - slide. +- [#815](https://github.com/keymapperorg/KeyMapper/issues/815) Always show the button to pick a package when configuring an intent action. +- [#749](https://github.com/keymapperorg/KeyMapper/issues/749) Remove do not disturb app intro slide. - [#750](https://github.com/keymapperorg/KeyMapper/issues/750) Redesign the About screen. -- [#747](https://github.com/keymapperorg/KeyMapper/issues/747) Reorganise the Settings screen so it - is less cluttered. +- [#747](https://github.com/keymapperorg/KeyMapper/issues/747) Reorganise the Settings screen so it is less cluttered. ### Added #### Important -- [#748](https://github.com/keymapperorg/KeyMapper/issues/748) Android 12 and Material You support - 🎨! -- [#746](https://github.com/keymapperorg/KeyMapper/issues/746) Shizuku support for some features! - You can use this - instead of using a Key Mapper keyboard! +- [#748](https://github.com/keymapperorg/KeyMapper/issues/748) Android 12 and Material You support 🎨! +- [#746](https://github.com/keymapperorg/KeyMapper/issues/746) Shizuku support for some features! You can use this instead of using a Key Mapper keyboard! #### Actions -- [#603](https://github.com/keymapperorg/KeyMapper/issues/603) You can now edit actions! You don't - have to delete an - action and completely reconfigure it. +- [#603](https://github.com/keymapperorg/KeyMapper/issues/603) You can now edit actions! You don't have to delete an action and completely reconfigure it. - [#851](https://github.com/keymapperorg/KeyMapper/issues/850) Action to answer/end a phone call. - [#704](https://github.com/keymapperorg/KeyMapper/issues/704) Action to dismiss notifications. #### Constraints -- [#851](https://github.com/keymapperorg/KeyMapper/issues/851) Constraints for when the device is - ringing and in a phone - call. -- [#811](https://github.com/keymapperorg/KeyMapper/issues/811) Constraint for when the device is - locked. -- [#776](https://github.com/keymapperorg/KeyMapper/issues/776) Constraint for when an input method - is chosen. -- [#598](https://github.com/keymapperorg/KeyMapper/issues/598) Constraint for any app (not) playing - or a specific app - not playing media. -- [#702](https://github.com/keymapperorg/KeyMapper/issues/702) WiFi on/off/connected/disconnected - constraints. +- [#851](https://github.com/keymapperorg/KeyMapper/issues/851) Constraints for when the device is ringing and in a phone call. +- [#811](https://github.com/keymapperorg/KeyMapper/issues/811) Constraint for when the device is locked. +- [#776](https://github.com/keymapperorg/KeyMapper/issues/776) Constraint for when an input method is chosen. +- [#598](https://github.com/keymapperorg/KeyMapper/issues/598) Constraint for any app (not) playing or a specific app not playing media. +- [#702](https://github.com/keymapperorg/KeyMapper/issues/702) WiFi on/off/connected/disconnected constraints. - [#722](https://github.com/keymapperorg/KeyMapper/issues/722) Flashlight on/off constraint. #### Other -- [#911](https://github.com/keymapperorg/KeyMapper/issues/911) Detect camera button when screen is - off. -- [#780](https://github.com/keymapperorg/KeyMapper/issues/780) If the accessibility settings can't - be found prompt the - user to follow an online guide to do it with ADB. -- [#773](https://github.com/keymapperorg/KeyMapper/issues/773) Prompt for a message when the user - reports a bug. -- [#686](https://github.com/keymapperorg/KeyMapper/issues/686) Setting to switch to a different - input method on input - focus. -- [#715](https://github.com/keymapperorg/KeyMapper/issues/715) Show a share button after a - successful backup. -- [#716](https://github.com/keymapperorg/KeyMapper/issues/716) Support for a Key Mapper compatible - version of Hacker's - Keyboard. Releases can be found - here: https://github.com/keymapperorg/KeyMapperHackersKeyboard/releases +- [#911](https://github.com/keymapperorg/KeyMapper/issues/911) Detect camera button when screen is off. +- [#780](https://github.com/keymapperorg/KeyMapper/issues/780) If the accessibility settings can't be found prompt the user to follow an online guide to do it with ADB. +- [#773](https://github.com/keymapperorg/KeyMapper/issues/773) Prompt for a message when the user reports a bug. +- [#686](https://github.com/keymapperorg/KeyMapper/issues/686) Setting to switch to a different input method on input focus. +- [#715](https://github.com/keymapperorg/KeyMapper/issues/715) Show a share button after a successful backup. +- [#716](https://github.com/keymapperorg/KeyMapper/issues/716) Support for a Key Mapper compatible version of Hacker's Keyboard. Releases can be found here: https://github.com/keymapperorg/KeyMapperHackersKeyboard/releases ### Bug Fixes -- [#794](https://github.com/keymapperorg/KeyMapper/issues/794) Only list apps that can be launched - when creating an open - app action. -- [#823](https://github.com/keymapperorg/KeyMapper/issues/823) Can't choose an app when creating - media action. -- [#756](https://github.com/keymapperorg/KeyMapper/issues/756) On slighter smaller screens show - split layout when - configuring a mapping. -- [#739](https://github.com/keymapperorg/KeyMapper/issues/739) Long press triggers ignore - constraints. +- [#794](https://github.com/keymapperorg/KeyMapper/issues/794) Only list apps that can be launched when creating an open app action. +- [#823](https://github.com/keymapperorg/KeyMapper/issues/823) Can't choose an app when creating media action. +- [#756](https://github.com/keymapperorg/KeyMapper/issues/756) On slighter smaller screens show split layout when configuring a mapping. +- [#739](https://github.com/keymapperorg/KeyMapper/issues/739) Long press triggers ignore constraints. ## [2.3.3](https://github.com/sds100/KeyMapper/releases/tag/v2.3.3) @@ -1196,8 +895,7 @@ See beta releases for bug fixes. ### Bug fixes -- [#893](https://github.com/sds100/KeyMapper/issues/893) Creating intent actions with a boolean - extra didn't work. +- [#893](https://github.com/sds100/KeyMapper/issues/893) Creating intent actions with a boolean extra didn't work. - [#885](https://github.com/keymapperorg/KeyMapper/issues/885) F-Droid build failed. - [#894](https://github.com/keymapperorg/KeyMapper/issues/894) Links to documentation website broke. - [#904](https://github.com/keymapperorg/KeyMapper/issues/904) Fix string. @@ -1208,34 +906,20 @@ See beta releases for bug fixes. ### Changes -- [#828](https://github.com/sds100/KeyMapper/issues/828) Rename the "Android 11 workaround" setting - to be more clear - what it does. -- [#859](https://github.com/sds100/KeyMapper/issues/859) Rename the "trigger from other apps" - trigger option to be more - clear what it does. -- [#753](https://github.com/sds100/KeyMapper/issues/753) Automatically add the "do not remap" - trigger key option when - remapping a modifier key. This will make sure the modifier key can still behave like a normal - modifier key. +- [#828](https://github.com/sds100/KeyMapper/issues/828) Rename the "Android 11 workaround" setting to be more clear what it does. +- [#859](https://github.com/sds100/KeyMapper/issues/859) Rename the "trigger from other apps" trigger option to be more clear what it does. +- [#753](https://github.com/sds100/KeyMapper/issues/753) Automatically add the "do not remap" trigger key option when remapping a modifier key. This will make sure the modifier key can still behave like a normal modifier key. ### Added -- [#814](https://github.com/sds100/KeyMapper/issues/814) Show system dialog to remove Key Mapper - from battery - optimisation so the user doesn't have to dig through their device sittings. +- [#814](https://github.com/sds100/KeyMapper/issues/814) Show system dialog to remove Key Mapper from battery optimisation so the user doesn't have to dig through their device sittings. ### Bug Fixes - [#810](https://github.com/sds100/KeyMapper/issues/810) Intent actions didn't work -- [#789](https://github.com/sds100/KeyMapper/issues/789) Try to fix Key Mapper saying that the - accessibility service has - crashed even if it hasn't. -- [#829](https://github.com/sds100/KeyMapper/issues/829) Try to fix Key Mapper being listed as - incompatible on Google - Play for some devices. -- [#854](https://github.com/sds100/KeyMapper/issues/854) The toggle airplane mode action didn't - work. +- [#789](https://github.com/sds100/KeyMapper/issues/789) Try to fix Key Mapper saying that the accessibility service has crashed even if it hasn't. +- [#829](https://github.com/sds100/KeyMapper/issues/829) Try to fix Key Mapper being listed as incompatible on Google Play for some devices. +- [#854](https://github.com/sds100/KeyMapper/issues/854) The toggle airplane mode action didn't work. ## [2.3.1](https://github.com/sds100/KeyMapper/releases/tag/v2.3.1) @@ -1243,24 +927,17 @@ See beta releases for bug fixes. ### Changes -- [#772](https://github.com/sds100/KeyMapper/issues/772) Remapping game controllers should work - automatically in games now. You no longer have to manually set the device of a key event action to - be the game controller. +- [#772](https://github.com/sds100/KeyMapper/issues/772) Remapping game controllers should work automatically in games now. You no longer have to manually set the device of a key event action to be the game controller. ### Bug Fixes - Try to fix a lot of random crashes on some devices. -- [#771](https://github.com/sds100/KeyMapper/issues/771) Don't show a "failed to find accessibility - node" toast message when the open menu action fails. -- -- [#775](https://github.com/sds100/KeyMapper/issues/775) The options for key maps would sometimes - sporadically change when navigating the configuration screen. -- +- [#771](https://github.com/sds100/KeyMapper/issues/771) Don't show a "failed to find accessibility node" toast message when the open menu action fails. +- [#775](https://github.com/sds100/KeyMapper/issues/775) The options for key maps would sometimes sporadically change when navigating the configuration screen. ### Removed -- Option to give feedback by emailing the developer. The number of emails was overwhelming and most - of them were not constructive at all. +- Option to give feedback by emailing the developer. The number of emails was overwhelming and most of them were not constructive at all. ## [2.3.0](https://github.com/sds100/KeyMapper/releases/tag/v2.3.0) @@ -1272,95 +949,57 @@ These are all the changes from 2.2.0. - 🎉 A new website with a tutorial! 🎉 [docs.keymapper.club](https://docs.keymapper.club) -- Action to broadcast intent, start activity and start - service. [#112](https://github.com/keymapperorg/KeyMapper/issues/112) -- Action to show the input method picker by using the Key Mapper - keyboard. [#531](https://github.com/keymapperorg/KeyMapper/issues/531) -- Action to toggle the notification drawer and the quick settings - drawer. [#242](https://github.com/keymapperorg/KeyMapper/issues/242) +- Action to broadcast intent, start activity and start service. [#112](https://github.com/keymapperorg/KeyMapper/issues/112) +- Action to show the input method picker by using the Key Mapper keyboard. [#531](https://github.com/keymapperorg/KeyMapper/issues/531) +- Action to toggle the notification drawer and the quick settings drawer. [#242](https://github.com/keymapperorg/KeyMapper/issues/242) - Action to call a phone number. [#516](https://github.com/keymapperorg/KeyMapper/issues/516) - Action to play a sound. -- A workaround for the Android 11 bug that sets the language of external keyboards to English-US - when an accessibility service is - enabled. [#618](https://github.com/keymapperorg/KeyMapper/issues/618) Read the guide - here https://docs.keymapper.club/redirects/android-11-device-id-bug-work-around - -- Prompt the user to read the quick start guide on the website the first time the app is opened. - [#544](https://github.com/keymapperorg/KeyMapper/issues/544) -- Links to a relevant online guide in each screen in the - app. [#539](https://github.com/keymapperorg/KeyMapper/issues/539) -- Option in key event action to input the key event through the - shell. [#559](https://github.com/keymapperorg/KeyMapper/issues/559) +- A workaround for the Android 11 bug that sets the language of external keyboards to English-US when an accessibility service is enabled. [#618](https://github.com/keymapperorg/KeyMapper/issues/618) Read the guide here https://docs.keymapper.club/redirects/android-11-device-id-bug-work-around + +- Prompt the user to read the quick start guide on the website the first time the app is opened. [#544](https://github.com/keymapperorg/KeyMapper/issues/544) +- Links to a relevant online guide in each screen in the app. [#539](https://github.com/keymapperorg/KeyMapper/issues/539) +- Option in key event action to input the key event through the shell. [#559](https://github.com/keymapperorg/KeyMapper/issues/559) - Splash screen [#561](https://github.com/keymapperorg/KeyMapper/issues/561) -- Data migrations when restoring from - backups. [#574](https://github.com/keymapperorg/KeyMapper/issues/574) -- Enable hold down and disable repeat by default for modifier key - actions. [#579](https://github.com/keymapperorg/KeyMapper/issues/579) -- Ability to change the input method with the accessibility service on Android - 11+. [#619](https://github.com/keymapperorg/KeyMapper/issues/619) -- Make it clearer that selecting a screenshot to set up a tap coordinate action is - optional. [#632](https://github.com/keymapperorg/KeyMapper/issues/632) -- Show a prompt to install the Key Mapper GUI Keyboard when a key event action is - created. [#645](https://github.com/keymapperorg/KeyMapper/issues/645) -- Back up default key map settings in back - ups. [#659](https://github.com/keymapperorg/KeyMapper/issues/659) -- Warnings when the accessibility service is turned on but isn't actually - running. [#643](https://github.com/keymapperorg/KeyMapper/issues/643) -- Show a message at the top of the home screen when mappings are - paused. [#642](https://github.com/keymapperorg/KeyMapper/issues/642) -- A caution message to avoid locking the user when using screen pinning - mode. [#602](https://github.com/keymapperorg/KeyMapper/issues/602) -- A logging page in the app which can be used instead of bug - reports. [#651](https://github.com/keymapperorg/KeyMapper/issues/651) -- A button in the settings to reset sliders to their - default. [#589](https://github.com/keymapperorg/KeyMapper/issues/589) +- Data migrations when restoring from backups. [#574](https://github.com/keymapperorg/KeyMapper/issues/574) +- Enable hold down and disable repeat by default for modifier key actions. [#579](https://github.com/keymapperorg/KeyMapper/issues/579) +- Ability to change the input method with the accessibility service on Android 11+. [#619](https://github.com/keymapperorg/KeyMapper/issues/619) +- Make it clearer that selecting a screenshot to set up a tap coordinate action is optional. [#632](https://github.com/keymapperorg/KeyMapper/issues/632) +- Show a prompt to install the Key Mapper GUI Keyboard when a key event action is created. [#645](https://github.com/keymapperorg/KeyMapper/issues/645) +- Back up default key map settings in back ups. [#659](https://github.com/keymapperorg/KeyMapper/issues/659) +- Warnings when the accessibility service is turned on but isn't actually running. [#643](https://github.com/keymapperorg/KeyMapper/issues/643) +- Show a message at the top of the home screen when mappings are paused. [#642](https://github.com/keymapperorg/KeyMapper/issues/642) +- A caution message to avoid locking the user when using screen pinning mode. [#602](https://github.com/keymapperorg/KeyMapper/issues/602) +- A logging page in the app which can be used instead of bug reports. [#651](https://github.com/keymapperorg/KeyMapper/issues/651) +- A button in the settings to reset sliders to their default. [#589](https://github.com/keymapperorg/KeyMapper/issues/589) - A repeat limit action option. [#663](https://github.com/keymapperorg/KeyMapper/issues/663) - Show a dialog before resetting fingerprint gesture maps. -- A new Key Mapper keyboard that is designed for Android - TV. [#493](https://github.com/keymapperorg/KeyMapper/issues/493) -- An Intent API to pause/resume key - maps. [#668](https://github.com/keymapperorg/KeyMapper/issues/668) -- Allow Key Mapper to be launched from the Android TV - launcher. [#695](https://github.com/keymapperorg/KeyMapper/issues/695) -- Make it much easier to report bugs and turn off aggressive app - killing. [#728](https://github.com/keymapperorg/KeyMapper/issues/728) There is now a button - in the home screen menu to send a bug report and the user is now prompted to read - dontkillmyapp.com when the accessibility service crashes. -- Support for repeat until limit reached action option in fingerprint gesture - maps. [#710](https://github.com/keymapperorg/KeyMapper/issues/710) +- A new Key Mapper keyboard that is designed for Android TV. [#493](https://github.com/keymapperorg/KeyMapper/issues/493) +- An Intent API to pause/resume key maps. [#668](https://github.com/keymapperorg/KeyMapper/issues/668) +- Allow Key Mapper to be launched from the Android TV launcher. [#695](https://github.com/keymapperorg/KeyMapper/issues/695) +- Make it much easier to report bugs and turn off aggressive app killing. [#728](https://github.com/keymapperorg/KeyMapper/issues/728) There is now a button in the home screen menu to send a bug report and the user is now prompted to read dontkillmyapp.com when the accessibility service crashes. +- Support for repeat until limit reached action option in fingerprint gesture maps. [#710](https://github.com/keymapperorg/KeyMapper/issues/710) - Polish translations. - Czech translations. ### Changed -- Move action option to show a toast message to the same place as the vibrate - option. [#565](https://github.com/keymapperorg/KeyMapper/issues/565) -- Replace setting to choose Bluetooth device in settings with setting to choose any input device. - [#620](https://github.com/keymapperorg/KeyMapper/issues/620) -- Rename 'action count' option to 'how many - times'. [#611](https://github.com/keymapperorg/KeyMapper/issues/611) -- Move option to show the volume ui for an action to when the action is - created. [#639](https://github.com/keymapperorg/KeyMapper/issues/639) -- Tapping the pause/resume key maps notification now opens Key - Mapper. [#665](https://github.com/keymapperorg/KeyMapper/issues/665) -- Make action descriptions more descriptive when repeat is turned - on. [#666](https://github.com/keymapperorg/KeyMapper/issues/666) +- Move action option to show a toast message to the same place as the vibrate option. [#565](https://github.com/keymapperorg/KeyMapper/issues/565) +- Replace setting to choose Bluetooth device in settings with setting to choose any input device. [#620](https://github.com/keymapperorg/KeyMapper/issues/620) +- Rename 'action count' option to 'how many times'. [#611](https://github.com/keymapperorg/KeyMapper/issues/611) +- Move option to show the volume ui for an action to when the action is created. [#639](https://github.com/keymapperorg/KeyMapper/issues/639) +- Tapping the pause/resume key maps notification now opens Key Mapper. [#665](https://github.com/keymapperorg/KeyMapper/issues/665) +- Make action descriptions more descriptive when repeat is turned on. [#666](https://github.com/keymapperorg/KeyMapper/issues/666) - Alerts at the top of the home screen have been simplified. ### Removed -- Dex slide in the app intro because it didn't - work. [#646](https://github.com/keymapperorg/KeyMapper/issues/646) -- Buttons to enable all and disable all key maps in the home screen - menu. [#647](https://github.com/keymapperorg/KeyMapper/issues/647) -- Support for Android KitKat 4.4 and - older. [#627](https://github.com/keymapperorg/KeyMapper/issues/627) -- Ability to view changelog, license and privacy policy in an in-app dialog. They now open a link in - the browser. [#648](https://github.com/keymapperorg/KeyMapper/issues/648) -- Alerts at the top of the home screen to enable a Key Mapper keyboard, grant WRITE_SECURE_SETTINGS - and grant Do not Disturb mode. +- Dex slide in the app intro because it didn't work. [#646](https://github.com/keymapperorg/KeyMapper/issues/646) +- Buttons to enable all and disable all key maps in the home screen menu. [#647](https://github.com/keymapperorg/KeyMapper/issues/647) +- Support for Android KitKat 4.4 and older. [#627](https://github.com/keymapperorg/KeyMapper/issues/627) +- Ability to view changelog, license and privacy policy in an in-app dialog. They now open a link in the browser. [#648](https://github.com/keymapperorg/KeyMapper/issues/648) +- Alerts at the top of the home screen to enable a Key Mapper keyboard, grant WRITE_SECURE_SETTINGS and grant Do not Disturb mode. ### Bug Fixes @@ -1373,8 +1012,7 @@ See the 2.3.0 Beta releases below. ### Changes - Never show the "key mapper has crashed" dialog automatically since this causes a lot of confusion. -- Prompt the user to restart the accessibility service rather than report a - bug. [#736](https://github.com/keymapperorg/KeyMapper/issues/736) +- Prompt the user to restart the accessibility service rather than report a bug. [#736](https://github.com/keymapperorg/KeyMapper/issues/736) ### Added @@ -1393,18 +1031,14 @@ See the 2.3.0 Beta releases below. ### Changes -- Don't show "key mapper has crashed" dialog the first time the app detects it has been crashed - after being opened. +- Don't show "key mapper has crashed" dialog the first time the app detects it has been crashed after being opened. ### Bug Fixes -- Write Secure Settings section in settings is enabled even if permission is - revoked. [#732](https://github.com/keymapperorg/KeyMapper/issues/732) -- Many random - crashes. [#744](https://github.com/keymapperorg/KeyMapper/issues/744), [#743](https://github.com/keymapperorg/KeyMapper/issues/743), [#742](https://github.com/keymapperorg/KeyMapper/issues/742), [#741](https://github.com/keymapperorg/KeyMapper/issues/741), [#740](https://github.com/keymapperorg/KeyMapper/issues/740), [#738](https://github.com/keymapperorg/KeyMapper/issues/738), [#737](https://github.com/keymapperorg/KeyMapper/issues/737) +- Write Secure Settings section in settings is enabled even if permission is revoked. [#732](https://github.com/keymapperorg/KeyMapper/issues/732) +- Many random crashes. [#744](https://github.com/keymapperorg/KeyMapper/issues/744), [#743](https://github.com/keymapperorg/KeyMapper/issues/743), [#742](https://github.com/keymapperorg/KeyMapper/issues/742), [#741](https://github.com/keymapperorg/KeyMapper/issues/741), [#740](https://github.com/keymapperorg/KeyMapper/issues/740), [#738](https://github.com/keymapperorg/KeyMapper/issues/738), [#737](https://github.com/keymapperorg/KeyMapper/issues/737) - Don't crash when restoring back ups without a sounds folder in it. -- Don't restore a back up from a newer version of key mapper to prevent the app crashing when - reading the restored data. +- Don't restore a back up from a newer version of key mapper to prevent the app crashing when reading the restored data. ## [2.3.0 Beta 3](https://github.com/sds100/KeyMapper/releases/tag/v2.3.0-beta.03) @@ -1412,25 +1046,17 @@ See the 2.3.0 Beta releases below. ### Added -- Make it much easier to report bugs and turn off aggressive app - killing. [#728](https://github.com/keymapperorg/KeyMapper/issues/728) There is now a button - in the home screen menu to send a bug report and the user is now prompted to read - dontkillmyapp.com when the accessibility service crashes. +- Make it much easier to report bugs and turn off aggressive app killing. [#728](https://github.com/keymapperorg/KeyMapper/issues/728) There is now a button in the home screen menu to send a bug report and the user is now prompted to read dontkillmyapp.com when the accessibility service crashes. - Action to play a sound ### Bug Fixes -- Close notification drawer after the notification has been - pressed. [#719](https://github.com/keymapperorg/KeyMapper/issues/719) +- Close notification drawer after the notification has been pressed. [#719](https://github.com/keymapperorg/KeyMapper/issues/719) - Crash if couldn't find input device. [#730](https://github.com/keymapperorg/KeyMapper/issues/730) -- Crash if couldn't find chosen input - method. [#731](https://github.com/keymapperorg/KeyMapper/issues/731) -- Crash when failing to get package - info. [#721](https://github.com/keymapperorg/KeyMapper/issues/721) -- Crash if couldn't find Bluetooth - device. [#723](https://github.com/keymapperorg/KeyMapper/issues/723) -- Crash when disabling accessibility - service. [#720](https://github.com/keymapperorg/KeyMapper/issues/720) +- Crash if couldn't find chosen input method. [#731](https://github.com/keymapperorg/KeyMapper/issues/731) +- Crash when failing to get package info. [#721](https://github.com/keymapperorg/KeyMapper/issues/721) +- Crash if couldn't find Bluetooth device. [#723](https://github.com/keymapperorg/KeyMapper/issues/723) +- Crash when disabling accessibility service. [#720](https://github.com/keymapperorg/KeyMapper/issues/720) - Reduce memory usage. [#725](https://github.com/keymapperorg/KeyMapper/issues/725) - Ensure log doesn't grow forever. [#729](https://github.com/keymapperorg/KeyMapper/issues/729) @@ -1440,139 +1066,87 @@ See the 2.3.0 Beta releases below. ### Added -- Support for repeat until limit reached action option in fingerprint gesture - maps. [#710](https://github.com/keymapperorg/KeyMapper/issues/710) +- Support for repeat until limit reached action option in fingerprint gesture maps. [#710](https://github.com/keymapperorg/KeyMapper/issues/710) ### Bug Fixes - Crash on start up on some devices. [#706](https://github.com/keymapperorg/KeyMapper/issues/706) -- Notification advertising fingerprint gesture maps is shown on every - update [#709](https://github.com/keymapperorg/KeyMapper/issues/709) -- Key map launcher shortcut repeats indefinitely when triggered if repeat until released is chosen. - [#707](https://github.com/keymapperorg/KeyMapper/issues/707) +- Notification advertising fingerprint gesture maps is shown on every update [#709](https://github.com/keymapperorg/KeyMapper/issues/709) +- Key map launcher shortcut repeats indefinitely when triggered if repeat until released is chosen. [#707](https://github.com/keymapperorg/KeyMapper/issues/707) ## [2.3.0 Beta 1](https://github.com/sds100/KeyMapper/releases/tag/v2.3.0-beta.01) #### 22 June 2021 -- A huge rewrite of the code which should make the app more stable and easier to add features in the - future. +- A huge rewrite of the code which should make the app more stable and easier to add features in the future. ### Added - 🎉 A new website with a tutorial! 🎉 [docs.keymapper.club](https://docs.keymapper.club) -- Action to broadcast intent, start activity and start - service. [#112](https://github.com/keymapperorg/KeyMapper/issues/112) -- Action to show the input method picker by using the Key Mapper - keyboard. [#531](https://github.com/keymapperorg/KeyMapper/issues/531) -- Action to toggle the notification drawer and the quick settings - drawer. [#242](https://github.com/keymapperorg/KeyMapper/issues/242) +- Action to broadcast intent, start activity and start service. [#112](https://github.com/keymapperorg/KeyMapper/issues/112) +- Action to show the input method picker by using the Key Mapper keyboard. [#531](https://github.com/keymapperorg/KeyMapper/issues/531) +- Action to toggle the notification drawer and the quick settings drawer. [#242](https://github.com/keymapperorg/KeyMapper/issues/242) - Action to call a phone number. [#516](https://github.com/keymapperorg/KeyMapper/issues/516) -- A workaround for the Android 11 bug that sets the language of external keyboards to English-US - when an accessibility service is - enabled. [#618](https://github.com/keymapperorg/KeyMapper/issues/618) Read the guide - here https://docs.keymapper.club/redirects/android-11-device-id-bug-work-around - -- Prompt the user to read the quick start guide on the website the first time the app is opened. - [#544](https://github.com/keymapperorg/KeyMapper/issues/544) -- Links to a relevant online guide in each screen in the - app. [#539](https://github.com/keymapperorg/KeyMapper/issues/539) -- Option in key event action to input the key event through the - shell. [#559](https://github.com/keymapperorg/KeyMapper/issues/559) +- A workaround for the Android 11 bug that sets the language of external keyboards to English-US when an accessibility service is enabled. [#618](https://github.com/keymapperorg/KeyMapper/issues/618) Read the guide here https://docs.keymapper.club/redirects/android-11-device-id-bug-work-around + +- Prompt the user to read the quick start guide on the website the first time the app is opened. [#544](https://github.com/keymapperorg/KeyMapper/issues/544) +- Links to a relevant online guide in each screen in the app. [#539](https://github.com/keymapperorg/KeyMapper/issues/539) +- Option in key event action to input the key event through the shell. [#559](https://github.com/keymapperorg/KeyMapper/issues/559) - Splash screen [#561](https://github.com/keymapperorg/KeyMapper/issues/561) -- Data migrations when restoring from - backups. [#574](https://github.com/keymapperorg/KeyMapper/issues/574) -- Enable hold down and disable repeat by default for modifier key - actions. [#579](https://github.com/keymapperorg/KeyMapper/issues/579) -- Ability to change the input method with the accessibility service on Android - 11+. [#619](https://github.com/keymapperorg/KeyMapper/issues/619) -- Make it clearer that selecting a screenshot to set up a tap coordinate action is - optional. [#632](https://github.com/keymapperorg/KeyMapper/issues/632) -- Show a prompt to install the Key Mapper GUI Keyboard when a key event action is - created. [#645](https://github.com/keymapperorg/KeyMapper/issues/645) -- Back up default key map settings in back - ups. [#659](https://github.com/keymapperorg/KeyMapper/issues/659) -- Warnings when the accessibility service is turned on but isn't actually - running. [#643](https://github.com/keymapperorg/KeyMapper/issues/643) -- Show a message at the top of the home screen when mappings are - paused. [#642](https://github.com/keymapperorg/KeyMapper/issues/642) -- A caution message to avoid locking the user when using screen pinning - mode. [#602](https://github.com/keymapperorg/KeyMapper/issues/602) -- A logging page in the app which can be used instead of bug - reports. [#651](https://github.com/keymapperorg/KeyMapper/issues/651) -- A button in the settings to reset sliders to their - default. [#589](https://github.com/keymapperorg/KeyMapper/issues/589) +- Data migrations when restoring from backups. [#574](https://github.com/keymapperorg/KeyMapper/issues/574) +- Enable hold down and disable repeat by default for modifier key actions. [#579](https://github.com/keymapperorg/KeyMapper/issues/579) +- Ability to change the input method with the accessibility service on Android 11+. [#619](https://github.com/keymapperorg/KeyMapper/issues/619) +- Make it clearer that selecting a screenshot to set up a tap coordinate action is optional. [#632](https://github.com/keymapperorg/KeyMapper/issues/632) +- Show a prompt to install the Key Mapper GUI Keyboard when a key event action is created. [#645](https://github.com/keymapperorg/KeyMapper/issues/645) +- Back up default key map settings in back ups. [#659](https://github.com/keymapperorg/KeyMapper/issues/659) +- Warnings when the accessibility service is turned on but isn't actually running. [#643](https://github.com/keymapperorg/KeyMapper/issues/643) +- Show a message at the top of the home screen when mappings are paused. [#642](https://github.com/keymapperorg/KeyMapper/issues/642) +- A caution message to avoid locking the user when using screen pinning mode. [#602](https://github.com/keymapperorg/KeyMapper/issues/602) +- A logging page in the app which can be used instead of bug reports. [#651](https://github.com/keymapperorg/KeyMapper/issues/651) +- A button in the settings to reset sliders to their default. [#589](https://github.com/keymapperorg/KeyMapper/issues/589) - A repeat limit action option. [#663](https://github.com/keymapperorg/KeyMapper/issues/663) - Show a dialog before resetting fingerprint gesture maps. -- A new Key Mapper keyboard that is designed for Android - TV. [#493](https://github.com/keymapperorg/KeyMapper/issues/493) -- An Intent API to pause/resume key - maps. [#668](https://github.com/keymapperorg/KeyMapper/issues/668) -- Allow Key Mapper to be launched from the Android TV - launcher. [#695](https://github.com/keymapperorg/KeyMapper/issues/695) +- A new Key Mapper keyboard that is designed for Android TV. [#493](https://github.com/keymapperorg/KeyMapper/issues/493) +- An Intent API to pause/resume key maps. [#668](https://github.com/keymapperorg/KeyMapper/issues/668) +- Allow Key Mapper to be launched from the Android TV launcher. [#695](https://github.com/keymapperorg/KeyMapper/issues/695) ### Changed -- Move action option to show a toast message to the same place as the vibrate - option. [#565](https://github.com/keymapperorg/KeyMapper/issues/565) -- Replace setting to choose Bluetooth device in settings with setting to choose any input device. - [#620](https://github.com/keymapperorg/KeyMapper/issues/620) -- Rename 'action count' option to 'how many - times'. [#611](https://github.com/keymapperorg/KeyMapper/issues/611) -- Move option to show the volume ui for an action to when the action is - created. [#639](https://github.com/keymapperorg/KeyMapper/issues/639) -- Tapping the pause/resume key maps notification now opens Key - Mapper. [#665](https://github.com/keymapperorg/KeyMapper/issues/665) -- Make action descriptions more descriptive when repeat is turned - on. [#666](https://github.com/keymapperorg/KeyMapper/issues/666) +- Move action option to show a toast message to the same place as the vibrate option. [#565](https://github.com/keymapperorg/KeyMapper/issues/565) +- Replace setting to choose Bluetooth device in settings with setting to choose any input device. [#620](https://github.com/keymapperorg/KeyMapper/issues/620) +- Rename 'action count' option to 'how many times'. [#611](https://github.com/keymapperorg/KeyMapper/issues/611) +- Move option to show the volume ui for an action to when the action is created. [#639](https://github.com/keymapperorg/KeyMapper/issues/639) +- Tapping the pause/resume key maps notification now opens Key Mapper. [#665](https://github.com/keymapperorg/KeyMapper/issues/665) +- Make action descriptions more descriptive when repeat is turned on. [#666](https://github.com/keymapperorg/KeyMapper/issues/666) - Alerts at the top of the home screen have been simplified. ### Removed -- Dex slide in the app intro because it didn't - work. [#646](https://github.com/keymapperorg/KeyMapper/issues/646) -- Buttons to enable all and disable all key maps in the home screen - menu. [#647](https://github.com/keymapperorg/KeyMapper/issues/647) -- Support for Android KitKat 4.4 and - older. [#627](https://github.com/keymapperorg/KeyMapper/issues/627) -- Ability to view changelog, license and privacy policy in an in-app dialog. They now open a link in - the browser. [#648](https://github.com/keymapperorg/KeyMapper/issues/648) -- Alerts at the top of the home screen to enable a Key Mapper keyboard, grant WRITE_SECURE_SETTINGS - and grant Do not Disturb mode. +- Dex slide in the app intro because it didn't work. [#646](https://github.com/keymapperorg/KeyMapper/issues/646) +- Buttons to enable all and disable all key maps in the home screen menu. [#647](https://github.com/keymapperorg/KeyMapper/issues/647) +- Support for Android KitKat 4.4 and older. [#627](https://github.com/keymapperorg/KeyMapper/issues/627) +- Ability to view changelog, license and privacy policy in an in-app dialog. They now open a link in the browser. [#648](https://github.com/keymapperorg/KeyMapper/issues/648) +- Alerts at the top of the home screen to enable a Key Mapper keyboard, grant WRITE_SECURE_SETTINGS and grant Do not Disturb mode. ### Bug fixes - Fix jank [#549](https://github.com/keymapperorg/KeyMapper/issues/549) - Fix text consistency [#543](https://github.com/keymapperorg/KeyMapper/issues/543) -- A parallel trigger which contains another parallel trigger after the first key should cancel the - other. [#571](https://github.com/keymapperorg/KeyMapper/issues/571) -- Actions go off screen for key maps on the home - screen. [#613](https://github.com/keymapperorg/KeyMapper/issues/613) -- Remove uses of Android framework strings for dialog - buttons. [#650](https://github.com/keymapperorg/KeyMapper/issues/650) -- Trigger key click type sometimes resets to short - press. [#615](https://github.com/keymapperorg/KeyMapper/issues/615) -- Wrong device id is used when performing key event actions and there are multiple devices with the - same descriptor. [#637](https://github.com/keymapperorg/KeyMapper/issues/637) -- Trigger key isn't imitated after a failed double - press. [#606](https://github.com/keymapperorg/KeyMapper/issues/606) -- Actions don't start repeating on a failed long press or failed double - press. [#626](https://github.com/keymapperorg/KeyMapper/issues/626) -- Crash when modifying a huge number of key - maps. [#641](https://github.com/keymapperorg/KeyMapper/issues/641) -- Home menu is chopped off on screens with small - height. [#582](https://github.com/keymapperorg/KeyMapper/issues/582) -- Crash when double pressing button to open action or trigger key - options. [#600](https://github.com/keymapperorg/KeyMapper/issues/600) -- Some action options disappear when adding a new trigger - key. [#594](https://github.com/keymapperorg/KeyMapper/issues/594) -- An action can continue to repeat even when the trigger is released if delay until next action is - not 0. [#662](https://github.com/keymapperorg/KeyMapper/issues/662) -- A lot of input latency when using a lot of - constraints. [#599](https://github.com/keymapperorg/KeyMapper/issues/599) -- Trigger button isn't imitated when a short press trigger with multiple keys fails to be triggered. - [#664](https://github.com/keymapperorg/KeyMapper/issues/664) +- A parallel trigger which contains another parallel trigger after the first key should cancel the other. [#571](https://github.com/keymapperorg/KeyMapper/issues/571) +- Actions go off screen for key maps on the home screen. [#613](https://github.com/keymapperorg/KeyMapper/issues/613) +- Remove uses of Android framework strings for dialog buttons. [#650](https://github.com/keymapperorg/KeyMapper/issues/650) +- Trigger key click type sometimes resets to short press. [#615](https://github.com/keymapperorg/KeyMapper/issues/615) +- Wrong device id is used when performing key event actions and there are multiple devices with the same descriptor. [#637](https://github.com/keymapperorg/KeyMapper/issues/637) +- Trigger key isn't imitated after a failed double press. [#606](https://github.com/keymapperorg/KeyMapper/issues/606) +- Actions don't start repeating on a failed long press or failed double press. [#626](https://github.com/keymapperorg/KeyMapper/issues/626) +- Crash when modifying a huge number of key maps. [#641](https://github.com/keymapperorg/KeyMapper/issues/641) +- Home menu is chopped off on screens with small height. [#582](https://github.com/keymapperorg/KeyMapper/issues/582) +- Crash when double pressing button to open action or trigger key options. [#600](https://github.com/keymapperorg/KeyMapper/issues/600) +- Some action options disappear when adding a new trigger key. [#594](https://github.com/keymapperorg/KeyMapper/issues/594) +- An action can continue to repeat even when the trigger is released if delay until next action is not 0. [#662](https://github.com/keymapperorg/KeyMapper/issues/662) +- A lot of input latency when using a lot of constraints. [#599](https://github.com/keymapperorg/KeyMapper/issues/599) +- Trigger button isn't imitated when a short press trigger with multiple keys fails to be triggered. [#664](https://github.com/keymapperorg/KeyMapper/issues/664) - Overlapping triggers. [#653](https://github.com/keymapperorg/KeyMapper/issues/653) ## [2.2.0](https://github.com/sds100/KeyMapper/releases/tag/v2.2.0) @@ -1583,43 +1157,29 @@ This sums up all the changes for 2.2 ### Added -- Remap fingerprint gestures! [#378](https://github.com/keymapperorg/KeyMapper/issues/378) Android - 8.0+ and only on devices which support them. Even devices - with the setting to swipe down for notifications might not support this! The dev can't do anything - about this. +- Remap fingerprint gestures! [#378](https://github.com/keymapperorg/KeyMapper/issues/378) Android 8.0+ and only on devices which support them. Even devices with the setting to swipe down for notifications might not support this! The dev can't do anything about this. - Widget/shortcut to launch actions. [#459](https://github.com/keymapperorg/KeyMapper/issues/459) -- Setting to show the first 5 digits of input devices so devices with the same name can be - differentiated in Key Mapper lists. [#470](https://github.com/keymapperorg/KeyMapper/issues/470) -- Show a warning at the top of the homescreen if the user hasn't disabled battery optimisation for - Key Mapper. [#496](https://github.com/keymapperorg/KeyMapper/issues/496) -- Action option to hold down until the trigger is pressed - again. [#479](https://github.com/keymapperorg/KeyMapper/issues/479) -- Action option to change the delay before the next action in the - list. [#476](https://github.com/keymapperorg/KeyMapper/issues/476) +- Setting to show the first 5 digits of input devices so devices with the same name can be differentiated in Key Mapper lists. [#470](https://github.com/keymapperorg/KeyMapper/issues/470) +- Show a warning at the top of the homescreen if the user hasn't disabled battery optimisation for Key Mapper. [#496](https://github.com/keymapperorg/KeyMapper/issues/496) +- Action option to hold down until the trigger is pressed again. [#479](https://github.com/keymapperorg/KeyMapper/issues/479) +- Action option to change the delay before the next action in the list. [#476](https://github.com/keymapperorg/KeyMapper/issues/476) - Orientation constraint. [#505](https://github.com/keymapperorg/KeyMapper/issues/505) -- Key Event action option to pretend that the Key Event came from a particular - device. [#509](https://github.com/keymapperorg/KeyMapper/issues/509) -- Use duplicates of the same key in a sequence - trigger. [#513](https://github.com/keymapperorg/KeyMapper/issues/513) -- Show the fingerprint gesture intro slide when updating to - 2.2 [#545](https://github.com/keymapperorg/KeyMapper/issues/545) -- Show a silent notification, which advertises the remapping fingerprint gesture feature, when the - user updates to 2.2 [#546](https://github.com/keymapperorg/KeyMapper/issues/546) +- Key Event action option to pretend that the Key Event came from a particular device. [#509](https://github.com/keymapperorg/KeyMapper/issues/509) +- Use duplicates of the same key in a sequence trigger. [#513](https://github.com/keymapperorg/KeyMapper/issues/513) +- Show the fingerprint gesture intro slide when updating to 2.2 [#545](https://github.com/keymapperorg/KeyMapper/issues/545) +- Show a silent notification, which advertises the remapping fingerprint gesture feature, when the user updates to 2.2 [#546](https://github.com/keymapperorg/KeyMapper/issues/546) - Trigger key maps from an Intent [#490](https://github.com/keymapperorg/KeyMapper/issues/490) - Prompt the user to go to https://dontkillmyapp.com when they first setup the app. -- Add Fdroid link to the Key Mapper GUI Keyboard - ad. [#524](https://github.com/keymapperorg/KeyMapper/issues/524) +- Add Fdroid link to the Key Mapper GUI Keyboard ad. [#524](https://github.com/keymapperorg/KeyMapper/issues/524) ### BREAKING CHANGES -- Key Mapper action shortcuts work completely differently. - See https://docs.keymapper.club/user-guide/triggers/#trigger-from-other-apps-230 +- Key Mapper action shortcuts work completely differently. See https://docs.keymapper.club/user-guide/triggers/#trigger-from-other-apps-230 ### Changes -- No max limit for sliders (except in - settings). [#458](https://github.com/keymapperorg/KeyMapper/issues/458) +- No max limit for sliders (except in settings). [#458](https://github.com/keymapperorg/KeyMapper/issues/458) - The app intro slides will show feedback if the steps have been done correctly. ### Removed @@ -1628,22 +1188,14 @@ This sums up all the changes for 2.2 ### Bug Fixes -- Save and restore state for all view - models. [#519](https://github.com/keymapperorg/KeyMapper/issues/519) -- Use View Binding in fragments properly. This should stop random crashes for some - users. [#518](https://github.com/keymapperorg/KeyMapper/issues/518) -- Hold Down action option doesn't work for long press - triggers. [#504](https://github.com/keymapperorg/KeyMapper/issues/504) -- A trigger for a specific device can still be detected if the same buttons on another device are - pressed. [#523](https://github.com/keymapperorg/KeyMapper/issues/523) -- Fix layout of the trigger fragment on some screen sizes so that some things aren't cut - off. [#522](https://github.com/keymapperorg/KeyMapper/issues/522) -- Remapping modifier keys to the same key didn't work as - expected. [#563](https://github.com/keymapperorg/KeyMapper/issues/563) -- Parallel triggers which contained another parallel trigger didn't cancel the - other. [#571](https://github.com/keymapperorg/KeyMapper/issues/571) -- Don't allow screen on/off constraints for fingerprint - gestures [#570](https://github.com/keymapperorg/KeyMapper/issues/570) +- Save and restore state for all view models. [#519](https://github.com/keymapperorg/KeyMapper/issues/519) +- Use View Binding in fragments properly. This should stop random crashes for some users. [#518](https://github.com/keymapperorg/KeyMapper/issues/518) +- Hold Down action option doesn't work for long press triggers. [#504](https://github.com/keymapperorg/KeyMapper/issues/504) +- A trigger for a specific device can still be detected if the same buttons on another device are pressed. [#523](https://github.com/keymapperorg/KeyMapper/issues/523) +- Fix layout of the trigger fragment on some screen sizes so that some things aren't cut off. [#522](https://github.com/keymapperorg/KeyMapper/issues/522) +- Remapping modifier keys to the same key didn't work as expected. [#563](https://github.com/keymapperorg/KeyMapper/issues/563) +- Parallel triggers which contained another parallel trigger didn't cancel the other. [#571](https://github.com/keymapperorg/KeyMapper/issues/571) +- Don't allow screen on/off constraints for fingerprint gestures [#570](https://github.com/keymapperorg/KeyMapper/issues/570) - Rename Key Mapper CI Keyboard to Key Mapper CI Basic Input Method. - Notifications had no icon on Android Lollipop. - remove coloured navigation bar on Android Lollipop. @@ -1651,19 +1203,13 @@ This sums up all the changes for 2.2 - Detecting whether remapping fingerprint gestures are supported didn't work. - The flashlight action would sometimes crash the app. - The error message for an app being disabled was the wrong one. -- Actions to open Android TV apps didn't - work [#503](https://github.com/keymapperorg/KeyMapper/issues/503) -- The app list didn't show Android TV-only - apps. [#487](https://github.com/keymapperorg/KeyMapper/issues/487) -- Settings for repeat rate and delay until repeat didn't match their names when configuring an - action. -- Text would move up/down when sliding between slides in the app - intro. [#540](https://github.com/keymapperorg/KeyMapper/issues/540) -- Icon for "specific app playing media" constraint had the wrong - tint. [#535](https://github.com/keymapperorg/KeyMapper/issues/535) +- Actions to open Android TV apps didn't work [#503](https://github.com/keymapperorg/KeyMapper/issues/503) +- The app list didn't show Android TV-only apps. [#487](https://github.com/keymapperorg/KeyMapper/issues/487) +- Settings for repeat rate and delay until repeat didn't match their names when configuring an action. +- Text would move up/down when sliding between slides in the app intro. [#540](https://github.com/keymapperorg/KeyMapper/issues/540) +- Icon for "specific app playing media" constraint had the wrong tint. [#535](https://github.com/keymapperorg/KeyMapper/issues/535) - Limit Media actions to Android 4.4 KitKat+ because they don't work on older versions. -- Up Key Event was sent from all keymaps with the "hold down" action option regardless of whether - the trigger was released. [#533](https://github.com/keymapperorg/KeyMapper/issues/533) +- Up Key Event was sent from all keymaps with the "hold down" action option regardless of whether the trigger was released. [#533](https://github.com/keymapperorg/KeyMapper/issues/533) - Testing actions didn't work. - Scroll position was lost when reloading the key map list. - Try to fix random crashes when navigating. @@ -1675,23 +1221,16 @@ This sums up all the changes for 2.2 ### Added -- Remap fingerprint gestures! [#378](https://github.com/keymapperorg/KeyMapper/issues/378) Android - 8.0+ and only on devices which support them. Even devices - with the setting to swipe down for notifications might not support this! The dev can't do anything - about this. -- Show the fingerprint gesture intro slide when updating to - 2.2 [#545](https://github.com/keymapperorg/KeyMapper/issues/545) -- Show a silent notification, which advertises the remapping fingerprint gesture feature, when the - user updates to 2.2 [#546](https://github.com/keymapperorg/KeyMapper/issues/546) +- Remap fingerprint gestures! [#378](https://github.com/keymapperorg/KeyMapper/issues/378) Android 8.0+ and only on devices which support them. Even devices with the setting to swipe down for notifications might not support this! The dev can't do anything about this. +- Show the fingerprint gesture intro slide when updating to 2.2 [#545](https://github.com/keymapperorg/KeyMapper/issues/545) +- Show a silent notification, which advertises the remapping fingerprint gesture feature, when the user updates to 2.2 [#546](https://github.com/keymapperorg/KeyMapper/issues/546) - Trigger key maps from an Intent [#490](https://github.com/keymapperorg/KeyMapper/issues/490) - Prompt the user to go to https://dontkillmyapp.com when they first setup the app. -- Add Fdroid link to the Key Mapper GUI Keyboard - ad. [#524](https://github.com/keymapperorg/KeyMapper/issues/524) +- Add Fdroid link to the Key Mapper GUI Keyboard ad. [#524](https://github.com/keymapperorg/KeyMapper/issues/524) ### BREAKING CHANGES -- Key Mapper action shortcuts work completely differently. - See https://docs.keymapper.club/user-guide/triggers/#trigger-from-other-apps-230 +- Key Mapper action shortcuts work completely differently. See https://docs.keymapper.club/user-guide/triggers/#trigger-from-other-apps-230 ### Changes @@ -1703,12 +1242,9 @@ This sums up all the changes for 2.2 ### Bug Fixes -- Remapping modifier keys to the same key didn't work as - expected. [#563](https://github.com/keymapperorg/KeyMapper/issues/563) -- Parallel triggers which contained another parallel trigger didn't cancel the - other. [#571](https://github.com/keymapperorg/KeyMapper/issues/571) -- Don't allow screen on/off constraints for fingerprint - gestures [#570](https://github.com/keymapperorg/KeyMapper/issues/570) +- Remapping modifier keys to the same key didn't work as expected. [#563](https://github.com/keymapperorg/KeyMapper/issues/563) +- Parallel triggers which contained another parallel trigger didn't cancel the other. [#571](https://github.com/keymapperorg/KeyMapper/issues/571) +- Don't allow screen on/off constraints for fingerprint gestures [#570](https://github.com/keymapperorg/KeyMapper/issues/570) - Rename Key Mapper CI Keyboard to Key Mapper CI Basic Input Method. - Notifications had no icon on Android Lollipop. - remove coloured navigation bar on Android Lollipop. @@ -1716,19 +1252,13 @@ This sums up all the changes for 2.2 - Detecting whether remapping fingerprint gestures are supported didn't work. - The flashlight action would sometimes crash the app. - The error message for an app being disabled was the wrong one. -- Actions to open Android TV apps didn't - work [#503](https://github.com/keymapperorg/KeyMapper/issues/503) -- The app list didn't show Android TV-only - apps. [#487](https://github.com/keymapperorg/KeyMapper/issues/487) -- Settings for repeat rate and delay until repeat didn't match their names when configuring an - action. -- Text would move up/down when sliding between slides in the app - intro. [#540](https://github.com/keymapperorg/KeyMapper/issues/540) -- Icon for "specific app playing media" constraint had the wrong - tint. [#535](https://github.com/keymapperorg/KeyMapper/issues/535) +- Actions to open Android TV apps didn't work [#503](https://github.com/keymapperorg/KeyMapper/issues/503) +- The app list didn't show Android TV-only apps. [#487](https://github.com/keymapperorg/KeyMapper/issues/487) +- Settings for repeat rate and delay until repeat didn't match their names when configuring an action. +- Text would move up/down when sliding between slides in the app intro. [#540](https://github.com/keymapperorg/KeyMapper/issues/540) +- Icon for "specific app playing media" constraint had the wrong tint. [#535](https://github.com/keymapperorg/KeyMapper/issues/535) - Limit Media actions to Android 4.4 KitKat+ because they don't work on older versions. -- Up Key Event was sent from all keymaps with the "hold down" action option regardless of whether - the trigger was released. [#533](https://github.com/keymapperorg/KeyMapper/issues/533) +- Up Key Event was sent from all keymaps with the "hold down" action option regardless of whether the trigger was released. [#533](https://github.com/keymapperorg/KeyMapper/issues/533) - Testing actions didn't work. - Scroll position was lost when reloading the key map list. - Try to fix random crashes when navigating. @@ -1740,47 +1270,30 @@ This sums up all the changes for 2.2 ### Added -- Remap fingerprint gestures! [#378](https://github.com/keymapperorg/KeyMapper/issues/378) Android - 8.0+ and only on devices which support them. Even devices - with the setting to swipe down for notifications might not support this! The dev can't do anything - about this. +- Remap fingerprint gestures! [#378](https://github.com/keymapperorg/KeyMapper/issues/378) Android 8.0+ and only on devices which support them. Even devices with the setting to swipe down for notifications might not support this! The dev can't do anything about this. - Widget/shortcut to launch actions. [#459](https://github.com/keymapperorg/KeyMapper/issues/459) -- Setting to show the first 5 digits of input devices so devices with the same name can be - differentiated in Key Mapper lists. [#470](https://github.com/keymapperorg/KeyMapper/issues/470) -- Show a warning at the top of the homescreen if the user hasn't disabled battery optimisation for - Key Mapper. [#496](https://github.com/keymapperorg/KeyMapper/issues/496) -- Action option to hold down until the trigger is pressed - again. [#479](https://github.com/keymapperorg/KeyMapper/issues/479) -- Action option to change the delay before the next action in the - list. [#476](https://github.com/keymapperorg/KeyMapper/issues/476) +- Setting to show the first 5 digits of input devices so devices with the same name can be differentiated in Key Mapper lists. [#470](https://github.com/keymapperorg/KeyMapper/issues/470) +- Show a warning at the top of the homescreen if the user hasn't disabled battery optimisation for Key Mapper. [#496](https://github.com/keymapperorg/KeyMapper/issues/496) +- Action option to hold down until the trigger is pressed again. [#479](https://github.com/keymapperorg/KeyMapper/issues/479) +- Action option to change the delay before the next action in the list. [#476](https://github.com/keymapperorg/KeyMapper/issues/476) - Orientation constraint. [#505](https://github.com/keymapperorg/KeyMapper/issues/505) -- Constraint for when a specific app is playing - media. [#508](https://github.com/keymapperorg/KeyMapper/issues/508) -- Key Event action option to pretend that the Key Event came from a particular - device. [#509](https://github.com/keymapperorg/KeyMapper/issues/509) -- Use duplicates of the same key in a sequence - trigger. [#513](https://github.com/keymapperorg/KeyMapper/issues/513) -- Hold down repeatedly if repeat and hold down are - enabled. [#500](https://github.com/keymapperorg/KeyMapper/issues/500) +- Constraint for when a specific app is playing media. [#508](https://github.com/keymapperorg/KeyMapper/issues/508) +- Key Event action option to pretend that the Key Event came from a particular device. [#509](https://github.com/keymapperorg/KeyMapper/issues/509) +- Use duplicates of the same key in a sequence trigger. [#513](https://github.com/keymapperorg/KeyMapper/issues/513) +- Hold down repeatedly if repeat and hold down are enabled. [#500](https://github.com/keymapperorg/KeyMapper/issues/500) ### Changes -- No max limit for sliders (except in - settings). [#458](https://github.com/keymapperorg/KeyMapper/issues/458) +- No max limit for sliders (except in settings). [#458](https://github.com/keymapperorg/KeyMapper/issues/458) ### Bug Fixes -- Save and restore state for all view - models. [#519](https://github.com/keymapperorg/KeyMapper/issues/519) -- Use View Binding in fragments properly. This should stop random crashes for some - users. [#518](https://github.com/keymapperorg/KeyMapper/issues/518) -- Hold Down action option doesn't work for long press - triggers. [#504](https://github.com/keymapperorg/KeyMapper/issues/504) -- A trigger for a specific device can still be detected if the same buttons on another device are - pressed. [#523](https://github.com/keymapperorg/KeyMapper/issues/523) -- Fix layout of the trigger fragment on some screen sizes so that some things aren't cut - off. [#522](https://github.com/keymapperorg/KeyMapper/issues/522) +- Save and restore state for all view models. [#519](https://github.com/keymapperorg/KeyMapper/issues/519) +- Use View Binding in fragments properly. This should stop random crashes for some users. [#518](https://github.com/keymapperorg/KeyMapper/issues/518) +- Hold Down action option doesn't work for long press triggers. [#504](https://github.com/keymapperorg/KeyMapper/issues/504) +- A trigger for a specific device can still be detected if the same buttons on another device are pressed. [#523](https://github.com/keymapperorg/KeyMapper/issues/523) +- Fix layout of the trigger fragment on some screen sizes so that some things aren't cut off. [#522](https://github.com/keymapperorg/KeyMapper/issues/522) ## [2.1.0](https://github.com/sds100/KeyMapper/releases/tag/v2.1.0) @@ -1797,15 +1310,11 @@ This summarises the changes since 2.0.2. - Action to create Key Event with optional modifiers. - Action to select word at cursor. - Action to toggle the screen on and off. -- Action to tap a coordinate on the screen. The user and the app can NOT touch the screen at the - same time. This is a - limitation in Android. +- Action to tap a coordinate on the screen. The user and the app can NOT touch the screen at the same time. This is a limitation in Android. - Action to double press recents to go to last app. -- Dismiss button to the notification that pauses/resumes keymaps. It will be shown again when the - app is opened. +- Dismiss button to the notification that pauses/resumes keymaps. It will be shown again when the app is opened. - Show a warning dialog when leaving the screen to configure a keymap without saving. -- Keymaps can have multiple of the same action. There is now a slider in the action options called " - Action Count". +- Keymaps can have multiple of the same action. There is now a slider in the action options called " Action Count". - Can detect the headset button when the screen is off. - Prompt the user to reboot their device if they fail to record a trigger 2 times in a row. - Show a toast after using the Screenshot (ROOT) action. @@ -1824,8 +1333,7 @@ This summarises the changes since 2.0.2. - The Menu (ROOT) action was slow - show a toast if there is an IOException when detecting buttons when the screen is off. - Remapping modifier keys to modifier keys doesn't work as expected. -- the Screenshot (ROOT) action didn't create the Pictures and Screenshots directories. Therefore, it - didn't save the screenshot. +- the Screenshot (ROOT) action didn't create the Pictures and Screenshots directories. Therefore, it didn't save the screenshot. - Hold Down action option didn't work for long-press triggers. - Opening a keymap with a long-press parallel trigger would set it to short press. - Crash if a modifier key trigger is not mapped to a Key Event action. @@ -1833,8 +1341,7 @@ This summarises the changes since 2.0.2. - Attempt to fix the problem of the accessibility service being enabled but broken on some devices. - Typo in the dialog message prompting the user to reboot. - The dialog prompting the user to reboot would show at the wrong time. -- Switch to a new App Intro library. Hopefully it is more stable because the old library was - crashing for many users. +- Switch to a new App Intro library. Hopefully it is more stable because the old library was crashing for many users. ## [2.1.0 Beta 4](https://github.com/sds100/KeyMapper/releases/tag/v2.1.0-beta.4) @@ -1857,8 +1364,7 @@ This summarises the changes since 2.0.2. - Attempt to fix the problem of the accessibility service being enabled but broken on some devices. - Typo in the dialog message prompting the user to reboot. - The dialog prompting the user to reboot would show at the wrong time. -- Switch to a new App Intro library. Hopefully it is more stable because the old library was - crashing for many users. +- Switch to a new App Intro library. Hopefully it is more stable because the old library was crashing for many users. ## [2.1.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v2.1.0-beta.2) @@ -1874,16 +1380,13 @@ This summarises the changes since 2.0.2. - Dragging trigger keys by the remove button would cause a crash - stop recording if the user leaves the Trigger fragment - The Menu (ROOT) action was slow -- Entering an invalid integer into the keycode box when creating a Key Event action would cause a - crash. +- Entering an invalid integer into the keycode box when creating a Key Event action would cause a crash. - show a toast if there is an IOException when detecting buttons when the screen is off. - Remapping modifier keys to modifier keys doesn't work as expected. -- the Screenshot (ROOT) action didn't create the Pictures and Screenshots directories. Therefore, it - didn't save the screenshot. +- the Screenshot (ROOT) action didn't create the Pictures and Screenshots directories. Therefore, it didn't save the screenshot. - Hold Down action option didn't work for long-press triggers. - Opening a keymap with a long-press parallel trigger would set it to short press. -- JSON files are sometimes greyed out when picking a file to restore. All file types are now shown - because Android doens't have a mimetype for JSON files. +- JSON files are sometimes greyed out when picking a file to restore. All file types are now shown because Android doens't have a mimetype for JSON files. - Crash if a modifier key trigger is not mapped to a Key Event action. - Potential crash when showing keymaps on the homescreen. @@ -1901,15 +1404,11 @@ This summarises the changes since 2.0.2. - Action to create Key Event with optional modifiers. - Action to select word at cursor. - Action to toggle the screen on and off. -- Action to tap a coordinate on the screen. The user and the app can NOT touch the screen at the - same time. This is a - limitation in Android. +- Action to tap a coordinate on the screen. The user and the app can NOT touch the screen at the same time. This is a limitation in Android. - Action to double press recents to go to last app. -- Dismiss button to the notification that pauses/resumes keymaps. It will be shown again when the - app is opened. +- Dismiss button to the notification that pauses/resumes keymaps. It will be shown again when the app is opened. - Show a warning dialog when leaving the screen to configure a keymap without saving. -- Keymaps can have multiple of the same action. There is now a slider in the action options called " - Action Count". +- Keymaps can have multiple of the same action. There is now a slider in the action options called " Action Count". - Can detect the headset button when the screen is off. - Option to not override the default behavior of the trigger. @@ -1932,8 +1431,7 @@ This summarises the changes since 2.0.2. ### Changes -- Make the functionality to fix actions by pressing on them more discoverable. The top of the keymap - on the homescreen will show "Tap actions to fix!" and the broken actions have a red tint. +- Make the functionality to fix actions by pressing on them more discoverable. The top of the keymap on the homescreen will show "Tap actions to fix!" and the broken actions have a red tint. ## [2.0.1](https://github.com/sds100/KeyMapper/releases/tag/v2.0.1) @@ -1942,8 +1440,7 @@ This summarises the changes since 2.0.2. ### Bug Fixes - Choosing app shortcut actions didn't work -- Remapping the Home and Recents buttons wouldn't stop them from doing their default Home/Recents - actions. +- Remapping the Home and Recents buttons wouldn't stop them from doing their default Home/Recents actions. - All titles for flashlight actions are the same. - Actions didn't work on Android 11. - Screen off triggers didn't pause. @@ -1962,11 +1459,9 @@ This summarises the changes since 2.0.2. - A keymap can have multiple actions. - Triggers - 2 modes. The keys can all be pressed at the same time or one after another in a sequence. - - Keys can be limited to a specific external device, any device or the device the app is - installed on. + - Keys can be limited to a specific external device, any device or the device the app is installed on. - Double press support. -- Constraints. Keymaps can be restricted to only work in certain situations. Constraints can be - mixed in OR mode or AND mode. +- Constraints. Keymaps can be restricted to only work in certain situations. Constraints can be mixed in OR mode or AND mode. - App in foreground - App not in foreground - Bluetooth device connected @@ -1985,8 +1480,7 @@ This summarises the changes since 2.0.2. - Renamed "Repeat Delay" to "Repeat Rate". - Renamed "Hold Down Delay" to "Repeat Delay" - Modifier keys now affect Key and Keycode actions. -- Option to vibrate twice for long press actions. Once when initially pressing the keys and again - when the action is performed. +- Option to vibrate twice for long press actions. Once when initially pressing the keys and again when the action is performed. - Option for keymaps with volume key triggers to be detected when the screen is off (ROOT only). - Option to stop repeating an action when the trigger is pressed again. - Button in the homescreen menu to resume/pause keymaps and enable the accessibility service. @@ -1997,8 +1491,7 @@ This summarises the changes since 2.0.2. - Screen to configure keymaps is more optimised for very large screens. - Preference to switch to and from the Key Mapper keyboard when pausing/resuming keymaps. - The option to show the "performing action" toast has been moved to a toggle in each keymap. -- The long press delay, double press timeout, sequence trigger timeout, action repeat delay, - hold-down delay until actions are repeated and vibrate delay can be changed per keymap. +- The long press delay, double press timeout, sequence trigger timeout, action repeat delay, hold-down delay until actions are repeated and vibrate delay can be changed per keymap. - Keymaps which have modifier key actions now affect other keymaps and keys which aren't mapped. - Link to the Discord server in About. @@ -2009,8 +1502,7 @@ This summarises the changes since 2.0.2. ### Changes -- Keymaps can only have one trigger. Any keymaps with multiple triggers will be split up into - multiple keymaps. +- Keymaps can only have one trigger. Any keymaps with multiple triggers will be split up into multiple keymaps. ### Removed @@ -2060,13 +1552,11 @@ Significantly improved the input latency. ### Changes - Persist whether keymaps are paused. -- The "Switch Keyboard" action now works when the app has WRITE_SECURE_SETTINGS permission rather - than just rooted devices. +- The "Switch Keyboard" action now works when the app has WRITE_SECURE_SETTINGS permission rather than just rooted devices. ### Removed -- Setting to show a toast message when an action fails. Removing this made improving the input - latency much easier. +- Setting to show a toast message when an action fails. Removing this made improving the input latency much easier. ## [2.0.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v2.0.0-beta.2) @@ -2080,8 +1570,7 @@ Significantly improved the input latency. - Action to launch the device assistant rather than the voice assistant. - Notification to toggle the Key Mapper keyboard. - Quick Settings to toggle the Key Mapper keyboard and pause/resume keymaps. -- Keymap option to vibrate twice for long press actions. Once when initially pressing the keys and - again when the action is performed. +- Keymap option to vibrate twice for long press actions. Once when initially pressing the keys and again when the action is performed. - Duplicate keymaps. - Screen to configure keymaps is more optimised for very large screens. - Preference to switch to and from the Key Mapper keyboard when pausing/resuming keymaps. @@ -2095,8 +1584,7 @@ Significantly improved the input latency. - Don't consume keyevents when actions for parallel triggers fail. - Short press and long press triggers don't cross over. - Short press and double press triggers don't cross over. -- Wifi actions didn't work on Android Pie. Android doesn't allow apps to control WiFi anymore so - these actions have been restricted to rooted devices on Android 9.0+ . +- Wifi actions didn't work on Android Pie. Android doesn't allow apps to control WiFi anymore so these actions have been restricted to rooted devices on Android 9.0+ . - Crash when sometimes changing keymap options with a slider. - Sequence trigger timeout option was shown for a single key double press trigger. - Crash when launching the app for the first time in landscape. @@ -2112,18 +1600,15 @@ Significantly improved the input latency. - A keymap can have multiple actions. - Triggers - 2 modes. The keys can all be pressed at the same time or one after another in a sequence. - - Keys can be limited to a specific external device, any device or the device the app is - installed on. + - Keys can be limited to a specific external device, any device or the device the app is installed on. - Double press support. -- Constraints. Keymaps can be restricted to only work in certain situations. Constraints can be - mixed in OR mode or AND mode. +- Constraints. Keymaps can be restricted to only work in certain situations. Constraints can be mixed in OR mode or AND mode. - App in foreground - App not in foreground - Bluetooth device connected - Bluetooth device not connected - The option to show the "performing action" toast has been moved to a toggle in each keymap. -- The long press delay, double press timeout, sequence trigger timeout, action repeat delay, - hold-down delay until actions are repeated and vibrate delay can be changed per keymap. +- The long press delay, double press timeout, sequence trigger timeout, action repeat delay, hold-down delay until actions are repeated and vibrate delay can be changed per keymap. - Modifier keys now affect Key and Keycode actions. - Keymaps which have modifier key actions now affect other keymaps and keys which aren't mapped. - Show the keycode number when picking a Keycode action. @@ -2137,8 +1622,7 @@ Significantly improved the input latency. ### Changes -- Keymaps can only have one trigger. Any keymaps with multiple triggers will be split up into - multiple keymaps. +- Keymaps can only have one trigger. Any keymaps with multiple triggers will be split up into multiple keymaps. ### Removed @@ -2155,10 +1639,8 @@ Significantly improved the input latency. - crashed when the battery optimisation settings couldn't be found. - some trigger keys have no name. - unable to uncheck the "show volume dialog" flag. -- on some devices (e.g Oxygen OS 10), the volume buttons up keyevents need to be consumed to stop - them from changing the volume when performing an action. -- couldn't necessarily press the back button to get back to Key Mapper when opening the - accessibility settings. +- on some devices (e.g Oxygen OS 10), the volume buttons up keyevents need to be consumed to stop them from changing the volume when performing an action. +- couldn't necessarily press the back button to get back to Key Mapper when opening the accessibility settings. ### Added @@ -2193,8 +1675,7 @@ This is the first release to be released on F-Droid. ### Bug Fix -- KEYCODE_BACK appeared twice in the keycode action - list. [#247](https://github.com/keymapperorg/KeyMapper/issues/247) +- KEYCODE_BACK appeared twice in the keycode action list. [#247](https://github.com/keymapperorg/KeyMapper/issues/247) ## [1.1.4](https://github.com/sds100/KeyMapper/releases/tag/v1.1.4) @@ -2218,8 +1699,7 @@ This is the first release to be released on F-Droid. ### Bug Fixes -- Make all slides in the intro activity scrollable so the content can be displayed on smaller - devices +- Make all slides in the intro activity scrollable so the content can be displayed on smaller devices - Remapping the recents button would still open recents - Crash when the app was rotated in the "choose action" activity - Triggers are ignored when another trigger is being detected. @@ -2227,8 +1707,7 @@ This is the first release to be released on F-Droid. ### Added - Action to show the keyboard picker -- Guide the user to grant WRITE_SECURE_SETTINGS for the app so features previously restricted to - rooted devices can be used on all devices. +- Guide the user to grant WRITE_SECURE_SETTINGS for the app so features previously restricted to rooted devices can be used on all devices. - Slide to enable Do Not Disturb in the intro activity. ### Changed @@ -2240,8 +1719,7 @@ This is the first release to be released on F-Droid. #### 27 July 2019 -Exact same as 1.1.0 besides the version code and name. I messed up the versioning on Google play so -had to increment the version code. +Exact same as 1.1.0 besides the version code and name. I messed up the versioning on Google play so had to increment the version code. ## [1.1.0](https://github.com/sds100/KeyMapper/releases/tag/v1.1.0) @@ -2272,8 +1750,7 @@ Changes from 1.1.0 Beta 8: ### Added - Show an error on the homescreen and if an action needs the Key Mapper keyboard to be enabled. -- Show an error when trying to use an action which requires the Key Mapper keyboard and it is - disabled. +- Show an error when trying to use an action which requires the Key Mapper keyboard and it is disabled. - Action to move the cursor to the end of a file - Actions to toggle, show and hide the keyboard - Button to change the keyboard in the homescreen menu @@ -2283,8 +1760,7 @@ Changes from 1.1.0 Beta 8: - labels for the KEYCODE_BUTTON_START and KEYCODE_BUTTON_SELECT keycodes - An introduction activity the first time the app is opened - Logger: log when recording a trigger has started and stopped -- Show a dialog the first time the Key Mapper keyboard is chosen explaining why another keyboard - can't be used. +- Show a dialog the first time the Key Mapper keyboard is chosen explaining why another keyboard can't be used. - ChooseActionActivity: A tab to which lists all the actions which aren't supported and why. - Show a "requires root" message for actions which need it @@ -2331,8 +1807,7 @@ Changes from 1.1.0 Beta 8: ### Changes -- Add the trigger after the 5 seconds rather than having to press the button so the app can work - with devices which only have remotes as input. +- Add the trigger after the 5 seconds rather than having to press the button so the app can work with devices which only have remotes as input. - Cleanup Settings strings. - Use slightly darker homescreen background. - Don't show the "Key mapper is performing an action" toast message by default. @@ -2362,8 +1837,7 @@ Changes from 1.1.0 Beta 8: - Could potentially crash when trying to switch to the Key Mapper input method - Could potentially crash when removing a trigger from the list - Would crash if it couldn't find the input method settings page -- Would crash when trying to change a specific volume stream while the device is in a Do Not Disturb - state +- Would crash when trying to change a specific volume stream while the device is in a Do Not Disturb state - Would crash when using an app shortcut without the correct permissions. ## [1.1.0 Beta 3](https://github.com/sds100/KeyMapper/releases/tag/v1.1.0-beta.3) @@ -2378,21 +1852,16 @@ Changes from 1.1.0 Beta 8: - Flag to vibrate and an option to force vibrate for all actions - Action which just consumes the keyevent and does nothing -- Action to lock the device (ROOT only for now) and an option to lock the device securely (without - root). +- Action to lock the device (ROOT only for now) and an option to lock the device securely (without root). ### Bug fixes - The bottom app bar on the homescreen would overlap the list items -- The app would potentially crash when trying to perform a flashlight action whilst the camera is in - use in another app. -- Short press actions with the same trigger as a long press action would be performed with the long - press action -- A keymap would still have the "Show volume dialog" flag if the action is changed to a non volume - related action +- The app would potentially crash when trying to perform a flashlight action whilst the camera is in use in another app. +- Short press actions with the same trigger as a long press action would be performed with the long press action +- A keymap would still have the "Show volume dialog" flag if the action is changed to a non volume related action - The app would crash if trying to show the menu on the homescreen if it is already showing. -- The accessibility service status on the homescreen wouldn't change when the service is - started/stopped. +- The accessibility service status on the homescreen wouldn't change when the service is started/stopped. ## [1.1.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v1.1.0-beta.2) @@ -2412,18 +1881,14 @@ Changes from 1.1.0 Beta 8: ### Added - Setting to change the long-press delay. -- Persistent notification which can pause/resume your remaps. It can also open the accessibility - settings on the device to enable/disable the service. Rooted devices can start/stop the - accessibility service without going into settings and just tap the notification. +- Persistent notification which can pause/resume your remaps. It can also open the accessibility settings on the device to enable/disable the service. Rooted devices can start/stop the accessibility service without going into settings and just tap the notification. - Use Material Design 2 for homescreen. ### Bug fixes - Persistent notifications wouldn't show on boot -- The app would crash if using the "open google assistant" action if the Google app wasn't - installed. -- Prevent the accessibility service from stopping if there is a fatal exception and show a toast - when it happens. +- The app would crash if using the "open google assistant" action if the Google app wasn't installed. +- Prevent the accessibility service from stopping if there is a fatal exception and show a toast when it happens. ## [1.0.0 Beta 6](https://github.com/sds100/KeyMapper/releases/tag/v1.0.0-beta.6) @@ -2451,16 +1916,14 @@ Changes from 1.1.0 Beta 8: - Added more labels for keys. - Added a link to the app in the device's Accessibility settings. - Updated the Gradle version to 3.3.2 -- When the long-press flag is chosen, show a warning saying it will only work properly for volume - and navigation buttons. +- When the long-press flag is chosen, show a warning saying it will only work properly for volume and navigation buttons. - Enable the show-volume-ui flag by default. #### Bug fixes - App would crash when choosing flags for a keymap without an action. - Buttons being repeatedly pressed. -- Enabling the long-press flag would stop the button from working when it is pressed without a long - press. +- Enabling the long-press flag would stop the button from working when it is pressed without a long press. ## Accidentally skipped Beta 3 release. Oops. @@ -2476,16 +1939,13 @@ Changes from 1.1.0 Beta 8: #### 2 Mar 2019 - Initial release! -- Option to automatically change the input method and/or show the input method picker when a chosen - Bluetooth device is connected and switch back to the old one when disconnected -- Option to show a notification, which when clicked on, will show the input method picker. Android - 8.1+ needs root. +- Option to automatically change the input method and/or show the input method picker when a chosen Bluetooth device is connected and switch back to the old one when disconnected +- Option to show a notification, which when clicked on, will show the input method picker. Android 8.1+ needs root. - Option to show a toast message whenever an action is performed. - A Help activity - An About activity - No limit on the amount of triggers for a keymap and how many keys can be used to create a trigger. -- Optional flags for each keymap so it can only be triggered on a long press and whether to show the - volume dialog for volume related actions. +- Optional flags for each keymap so it can only be triggered on a long press and whether to show the volume dialog for volume related actions. - Ability to enable/disable specific/all keymaps. #### Added these actions diff --git a/README.md b/README.md index 62bad58efd..446accb2e2 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@

+

+ +

+ ## Unleash your keys! Make custom macros on your keyboard or gamepad, make on-screen buttons in any app, and unlock new functionality from your volume buttons! diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 424e398db2..f484fc06fa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,20 @@ android:name="android.hardware.touchscreen" android:required="false" /> + + + diff --git a/app/src/main/java/io/github/sds100/keymapper/AppHiltModule.kt b/app/src/main/java/io/github/sds100/keymapper/AppHiltModule.kt index 0cf2a1f83a..8b15cd1b07 100644 --- a/app/src/main/java/io/github/sds100/keymapper/AppHiltModule.kt +++ b/app/src/main/java/io/github/sds100/keymapper/AppHiltModule.kt @@ -42,6 +42,12 @@ class AppHiltModule { get() = BuildConfig.VERSION_CODE override val sdkInt: Int get() = Build.VERSION.SDK_INT + + // Android TV users predominantly sideload this FOSS build rather than + // install from Google Play, which restricts this permission to + // file-manager apps, so it's safe to request it here. + override val canRequestAllFilesAccess: Boolean + get() = true } @Singleton diff --git a/app/version.properties b/app/version.properties index 3a7f8f09ad..5c0a6b7d6c 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=4.3.1 -VERSION_CODE=259 +VERSION_NAME=4.4.0 +VERSION_CODE=264 diff --git a/base/build.gradle.kts b/base/build.gradle.kts index f9118e6def..8b0bf62100 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -68,6 +68,27 @@ android { composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + + // Needed for Roboelectric to work on Java 17+ + unitTests.all { + it.jvmArgs( + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-opens=java.base/java.net=ALL-UNNAMED", + "--add-opens=java.base/java.security=ALL-UNNAMED", + "--add-opens=java.base/java.text=ALL-UNNAMED", + "--add-opens=java.base/jdk.internal.access=ALL-UNNAMED", + "--add-opens=java.desktop/java.awt.font=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", + ) + } + } } dependencies { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt index b726317db1..f5c7d7e85e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainActivity.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base +import android.content.ActivityNotFoundException import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -8,6 +9,7 @@ import android.content.res.Configuration import android.net.Uri import android.os.Bundle import android.view.MotionEvent +import android.widget.Toast import androidx.activity.SystemBarStyle import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts.CreateDocument @@ -276,9 +278,27 @@ abstract class BaseMainActivity : AppCompatActivity() { val fileUri = IntentCompat.getParcelableExtra(intent, EXTRA_FILE_URI, Uri::class.java) ?: return + saveFileToUserChosenLocation(fileUri) + } + + /** + * Let the user pick a location to save [fileUri] to using the system document picker. + * This is used as a fallback for exporting when there is no app installed that can + * receive a shared file, for example on Android TV. + */ + fun saveFileToUserChosenLocation(fileUri: Uri) { val fileName = fileUri.toDocumentFile(this@BaseMainActivity)?.name ?: return originalFileUri = fileUri - saveFileLauncher.launch(fileName) + + try { + saveFileLauncher.launch(fileName) + } catch (_: ActivityNotFoundException) { + Toast.makeText( + this, + R.string.dialog_message_no_app_found_to_create_file, + Toast.LENGTH_LONG, + ).show() + } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt index 1927e46269..dd04c88808 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt @@ -28,6 +28,7 @@ import io.github.sds100.keymapper.base.constraints.ChooseConstraintViewModel import io.github.sds100.keymapper.base.debug.GetEventScreen import io.github.sds100.keymapper.base.expertmode.ExpertModeScreen import io.github.sds100.keymapper.base.expertmode.ExpertModeSetupScreen +import io.github.sds100.keymapper.base.expertmode.xiaomi.XiaomiOptimizationScreen import io.github.sds100.keymapper.base.logging.LogScreen import io.github.sds100.keymapper.base.onboarding.HandleAccessibilityServiceDialogs import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegateImpl @@ -158,6 +159,13 @@ fun BaseMainNavHost( ) } + composable { + XiaomiOptimizationScreen( + modifier = Modifier.fillMaxSize(), + viewModel = hiltViewModel(), + ) + } + composable { LogScreen( modifier = Modifier.fillMaxSize(), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt index 75ebbc90c7..7dbc421f2f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt @@ -29,6 +29,8 @@ import io.github.sds100.keymapper.base.expertmode.ExpertModeSetupDelegateImpl import io.github.sds100.keymapper.base.expertmode.SystemBridgeSetupDelegate import io.github.sds100.keymapper.base.expertmode.SystemBridgeSetupUseCase import io.github.sds100.keymapper.base.expertmode.SystemBridgeSetupUseCaseImpl +import io.github.sds100.keymapper.base.expertmode.xiaomi.XiaomiOptimizationUseCase +import io.github.sds100.keymapper.base.expertmode.xiaomi.XiaomiOptimizationUseCaseImpl import io.github.sds100.keymapper.base.home.ListKeyMapsUseCase import io.github.sds100.keymapper.base.home.ListKeyMapsUseCaseImpl import io.github.sds100.keymapper.base.home.ShowHomeScreenAlertsUseCase @@ -200,4 +202,10 @@ abstract class BaseViewModelHiltModule { abstract fun bindExpertModeSetupDelegate( impl: ExpertModeSetupDelegateImpl, ): SystemBridgeSetupDelegate + + @Binds + @ViewModelScoped + abstract fun bindXiaomiOptimizationUseCase( + impl: XiaomiOptimizationUseCaseImpl, + ): XiaomiOptimizationUseCase } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 0fbbf4a6fc..916eefff1b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -5,6 +5,7 @@ import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.NodeInteractionType import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.PinchScreenType +import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.intents.IntentExtraModel import io.github.sds100.keymapper.system.intents.IntentTarget @@ -245,6 +246,11 @@ sealed class ActionData : Comparable { } } + @Serializable + data object CycleKeyboardLanguage : ActionData() { + override val id = ActionId.CYCLE_KEYBOARD_LANGUAGE + } + @Serializable sealed class DoNotDisturb : ActionData() { @@ -378,12 +384,18 @@ sealed class ActionData : Comparable { } @Serializable - data class StepForward(override val packageName: String) : ControlMediaForApp() { + data class StepForward( + override val packageName: String, + val stepDurationMs: Long? = null, + ) : ControlMediaForApp() { override val id = ActionId.STEP_FORWARD_PACKAGE } @Serializable - data class StepBackward(override val packageName: String) : ControlMediaForApp() { + data class StepBackward( + override val packageName: String, + val stepDurationMs: Long? = null, + ) : ControlMediaForApp() { override val id = ActionId.STEP_BACKWARD_PACKAGE } } @@ -431,12 +443,12 @@ sealed class ActionData : Comparable { } @Serializable - data object StepForward : ControlMedia() { + data class StepForward(val stepDurationMs: Long? = null) : ControlMedia() { override val id = ActionId.STEP_FORWARD } @Serializable - data object StepBackward : ControlMedia() { + data class StepBackward(val stepDurationMs: Long? = null) : ControlMedia() { override val id = ActionId.STEP_BACKWARD } } @@ -457,7 +469,16 @@ sealed class ActionData : Comparable { } @Serializable - data class TapScreen(val x: Int, val y: Int, val description: String?) : ActionData() { + data class TapScreen( + val x: Int, + val y: Int, + val description: String?, + /** + * The display size that the coordinates were picked for. See issue #2217. This is null for + * actions that were created before the resolution was saved and those are never scaled. + */ + val screenResolution: SizeKM? = null, + ) : ActionData() { override val id = ActionId.TAP_SCREEN override fun compareTo(other: ActionData) = when (other) { @@ -482,6 +503,11 @@ sealed class ActionData : Comparable { val fingerCount: Int, val duration: Int, val description: String?, + /** + * The display size that the coordinates were picked for. See issue #2217. This is null for + * actions that were created before the resolution was saved and those are never scaled. + */ + val screenResolution: SizeKM? = null, ) : ActionData() { override val id = ActionId.SWIPE_SCREEN @@ -511,6 +537,12 @@ sealed class ActionData : Comparable { val fingerCount: Int, val duration: Int, val description: String?, + /** + * The display size that the coordinates and distance were picked for. See issue #2217. This + * is null for actions that were created before the resolution was saved and those are never + * scaled. + */ + val screenResolution: SizeKM? = null, ) : ActionData() { override val id = ActionId.PINCH_SCREEN diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index 1a84d7c59f..74ba3d8c49 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -8,6 +8,7 @@ import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.NodeInteractionType import io.github.sds100.keymapper.common.utils.PinchScreenType +import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.getKey import io.github.sds100.keymapper.common.utils.hasFlag @@ -129,7 +130,12 @@ object ActionDataEntityMapper { val description = entity.extras.getData(ActionEntity.EXTRA_COORDINATE_DESCRIPTION) .valueOrNull() - ActionData.TapScreen(x = x, y = y, description = description) + ActionData.TapScreen( + x = x, + y = y, + description = description, + screenResolution = getScreenResolution(entity), + ) } ActionId.SWIPE_SCREEN -> { @@ -176,6 +182,7 @@ object ActionDataEntityMapper { fingerCount = fingerCount, duration = duration, description = description, + screenResolution = getScreenResolution(entity), ) } @@ -230,6 +237,7 @@ object ActionDataEntityMapper { fingerCount = fingerCount, duration = duration, description = description, + screenResolution = getScreenResolution(entity), ) } @@ -436,6 +444,10 @@ object ActionDataEntityMapper { entity.extras.getData(ActionEntity.EXTRA_PACKAGE_NAME).valueOrNull() ?: return null + val stepDurationMs = entity.extras.getData( + ActionEntity.EXTRA_STEP_MEDIA_DURATION, + ).valueOrNull()?.toLongOrNull() + when (actionId) { ActionId.PAUSE_MEDIA_PACKAGE -> ActionData.ControlMediaForApp.Pause(packageName) @@ -462,10 +474,10 @@ object ActionDataEntityMapper { ActionData.ControlMediaForApp.Stop(packageName) ActionId.STEP_FORWARD_PACKAGE -> - ActionData.ControlMediaForApp.StepForward(packageName) + ActionData.ControlMediaForApp.StepForward(packageName, stepDurationMs) ActionId.STEP_BACKWARD_PACKAGE -> - ActionData.ControlMediaForApp.StepBackward(packageName) + ActionData.ControlMediaForApp.StepBackward(packageName, stepDurationMs) else -> throw Exception("don't know how to create system action for $actionId") } @@ -587,9 +599,21 @@ object ActionDataEntityMapper { ActionId.STOP_MEDIA -> ActionData.ControlMedia.Stop - ActionId.STEP_FORWARD -> ActionData.ControlMedia.StepForward + ActionId.STEP_FORWARD -> { + val stepDurationMs = entity.extras.getData( + ActionEntity.EXTRA_STEP_MEDIA_DURATION, + ).valueOrNull()?.toLongOrNull() - ActionId.STEP_BACKWARD -> ActionData.ControlMedia.StepBackward + ActionData.ControlMedia.StepForward(stepDurationMs) + } + + ActionId.STEP_BACKWARD -> { + val stepDurationMs = entity.extras.getData( + ActionEntity.EXTRA_STEP_MEDIA_DURATION, + ).valueOrNull()?.toLongOrNull() + + ActionData.ControlMedia.StepBackward(stepDurationMs) + } ActionId.GO_BACK -> ActionData.GoBack @@ -619,6 +643,8 @@ object ActionDataEntityMapper { ActionId.PERFORM_IME_ACTION -> ActionData.PerformImeAction + ActionId.CYCLE_KEYBOARD_LANGUAGE -> ActionData.CycleKeyboardLanguage + ActionId.TEXT_CUT -> ActionData.CutText ActionId.TEXT_COPY -> ActionData.CopyText @@ -918,6 +944,38 @@ object ActionDataEntityMapper { KMError.Exception(e) } + /** + * The display size that the coordinates of a tap, swipe or pinch screen action were picked for. + * See issue #2217. This is null for actions created before the resolution was saved, and for + * anything that can not be parsed, so that a broken value never stops the action loading. + */ + private fun getScreenResolution(entity: ActionEntity): SizeKM? { + val extraValue = entity.extras.getData(ActionEntity.EXTRA_SCREEN_RESOLUTION).valueOrNull() + ?: return null + + val split = extraValue.split(',') + + if (split.size != 2) { + return null + } + + val width = split[0].trim().toIntOrNull() ?: return null + val height = split[1].trim().toIntOrNull() ?: return null + + if (width <= 0 || height <= 0) { + return null + } + + return SizeKM(width = width, height = height) + } + + private fun createScreenResolutionExtra(screenResolution: SizeKM): EntityExtra { + return EntityExtra( + ActionEntity.EXTRA_SCREEN_RESOLUTION, + "${screenResolution.width},${screenResolution.height}", + ) + } + fun toEntity(data: ActionData): ActionEntity { val type = when (data) { is ActionData.Intent -> ActionEntity.Type.INTENT @@ -1105,10 +1163,36 @@ object ActionDataEntityMapper { EntityExtra(ActionEntity.EXTRA_RINGER_MODE, RINGER_MODE_MAP[data.ringerMode]!!), ) + is ActionData.ControlMediaForApp.StepForward -> buildList { + add(EntityExtra(ActionEntity.EXTRA_PACKAGE_NAME, data.packageName)) + data.stepDurationMs?.let { + add(EntityExtra(ActionEntity.EXTRA_STEP_MEDIA_DURATION, it.toString())) + } + } + + is ActionData.ControlMediaForApp.StepBackward -> buildList { + add(EntityExtra(ActionEntity.EXTRA_PACKAGE_NAME, data.packageName)) + data.stepDurationMs?.let { + add(EntityExtra(ActionEntity.EXTRA_STEP_MEDIA_DURATION, it.toString())) + } + } + is ActionData.ControlMediaForApp -> listOf( EntityExtra(ActionEntity.EXTRA_PACKAGE_NAME, data.packageName), ) + is ActionData.ControlMedia.StepForward -> buildList { + data.stepDurationMs?.let { + add(EntityExtra(ActionEntity.EXTRA_STEP_MEDIA_DURATION, it.toString())) + } + } + + is ActionData.ControlMedia.StepBackward -> buildList { + data.stepDurationMs?.let { + add(EntityExtra(ActionEntity.EXTRA_STEP_MEDIA_DURATION, it.toString())) + } + } + is ActionData.Rotation.CycleRotations -> listOf( EntityExtra( ActionEntity.EXTRA_ORIENTATIONS, @@ -1202,18 +1286,30 @@ object ActionDataEntityMapper { if (!data.description.isNullOrBlank()) { yield(EntityExtra(ActionEntity.EXTRA_COORDINATE_DESCRIPTION, data.description)) } + + if (data.screenResolution != null) { + yield(createScreenResolutionExtra(data.screenResolution)) + } }.toList() is ActionData.SwipeScreen -> sequence { if (!data.description.isNullOrBlank()) { yield(EntityExtra(ActionEntity.EXTRA_COORDINATE_DESCRIPTION, data.description)) } + + if (data.screenResolution != null) { + yield(createScreenResolutionExtra(data.screenResolution)) + } }.toList() is ActionData.PinchScreen -> sequence { if (!data.description.isNullOrBlank()) { yield(EntityExtra(ActionEntity.EXTRA_COORDINATE_DESCRIPTION, data.description)) } + + if (data.screenResolution != null) { + yield(createScreenResolutionExtra(data.screenResolution)) + } }.toList() is ActionData.Text -> emptyList() @@ -1503,6 +1599,7 @@ object ActionDataEntityMapper { ActionId.SELECT_ALL_TEXT to "select_all_text", ActionId.SWITCH_KEYBOARD to "switch_keyboard", + ActionId.CYCLE_KEYBOARD_LANGUAGE to "cycle_keyboard_language", ActionId.TOGGLE_AIRPLANE_MODE to "toggle_airplane_mode", ActionId.ENABLE_AIRPLANE_MODE to "enable_airplane_mode", diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index d7f3694432..3dd5c08179 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt @@ -129,6 +129,7 @@ enum class ActionId { PERFORM_IME_ACTION, SWITCH_KEYBOARD, + CYCLE_KEYBOARD_LANGUAGE, TOGGLE_AIRPLANE_MODE, ENABLE_AIRPLANE_MODE, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index bfa3840b36..cebf056bd6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -207,39 +207,55 @@ class ActionUiHelper( is ActionData.ControlMediaForApp -> getAppName(action.packageName).handle( onSuccess = { appName -> - val resId = when (action) { - is ActionData.ControlMediaForApp.Play -> - R.string.action_play_media_package_formatted + if (action is ActionData.ControlMediaForApp.StepForward && + action.stepDurationMs != null + ) { + getString( + R.string.action_step_forward_media_package_with_duration_formatted, + arrayOf(appName, (action.stepDurationMs / 1000).toInt()), + ) + } else if (action is ActionData.ControlMediaForApp.StepBackward && + action.stepDurationMs != null + ) { + getString( + R.string.action_step_backward_media_package_with_duration_formatted, + arrayOf(appName, (action.stepDurationMs / 1000).toInt()), + ) + } else { + val resId = when (action) { + is ActionData.ControlMediaForApp.Play -> + R.string.action_play_media_package_formatted - is ActionData.ControlMediaForApp.FastForward -> - R.string.action_fast_forward_package_formatted + is ActionData.ControlMediaForApp.FastForward -> + R.string.action_fast_forward_package_formatted - is ActionData.ControlMediaForApp.NextTrack -> - R.string.action_next_track_package_formatted + is ActionData.ControlMediaForApp.NextTrack -> + R.string.action_next_track_package_formatted - is ActionData.ControlMediaForApp.Pause -> - R.string.action_pause_media_package_formatted + is ActionData.ControlMediaForApp.Pause -> + R.string.action_pause_media_package_formatted - is ActionData.ControlMediaForApp.PlayPause -> - R.string.action_play_pause_media_package_formatted + is ActionData.ControlMediaForApp.PlayPause -> + R.string.action_play_pause_media_package_formatted - is ActionData.ControlMediaForApp.PreviousTrack -> - R.string.action_previous_track_package_formatted + is ActionData.ControlMediaForApp.PreviousTrack -> + R.string.action_previous_track_package_formatted - is ActionData.ControlMediaForApp.Rewind -> - R.string.action_rewind_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.Stop -> + R.string.action_stop_media_package_formatted - is ActionData.ControlMediaForApp.StepForward -> - R.string.action_step_forward_media_package_formatted + is ActionData.ControlMediaForApp.StepForward -> + R.string.action_step_forward_media_package_formatted - is ActionData.ControlMediaForApp.StepBackward -> - R.string.action_step_backward_media_package_formatted - } + is ActionData.ControlMediaForApp.StepBackward -> + R.string.action_step_backward_media_package_formatted + } - getString(resId, appName) + getString(resId, appName) + } }, onError = { val resId = when (action) { @@ -528,9 +544,25 @@ class ActionUiHelper( ActionData.ControlMedia.Stop -> getString(R.string.action_stop_media) - ActionData.ControlMedia.StepForward -> getString(R.string.action_step_forward_media) + is ActionData.ControlMedia.StepForward -> + if (action.stepDurationMs != null) { + getString( + R.string.action_step_forward_media_with_duration, + (action.stepDurationMs / 1000).toInt(), + ) + } else { + getString(R.string.action_step_forward_media) + } - ActionData.ControlMedia.StepBackward -> getString(R.string.action_step_backward_media) + is ActionData.ControlMedia.StepBackward -> + if (action.stepDurationMs != null) { + getString( + R.string.action_step_backward_media_with_duration, + (action.stepDurationMs / 1000).toInt(), + ) + } else { + getString(R.string.action_step_backward_media) + } ActionData.CopyText -> getString(R.string.action_text_copy) @@ -690,6 +722,8 @@ class ActionUiHelper( ActionData.ToggleKeyboard -> getString(R.string.action_toggle_keyboard) + ActionData.CycleKeyboardLanguage -> getString(R.string.action_cycle_keyboard_language) + ActionData.ToggleSplitScreen -> getString(R.string.action_toggle_split_screen) ActionData.VoiceAssistant -> getString(R.string.action_open_assistant) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt index becac27282..6f464f5e54 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt @@ -33,11 +33,12 @@ 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 import androidx.compose.material.icons.outlined.Keyboard +import androidx.compose.material.icons.outlined.KeyboardDoubleArrowLeft +import androidx.compose.material.icons.outlined.KeyboardDoubleArrowRight import androidx.compose.material.icons.outlined.KeyboardHide import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.Lock @@ -52,7 +53,6 @@ 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.SelectAll @@ -230,6 +230,7 @@ object ActionUtils { ActionId.SELECT_ALL_TEXT -> ActionCategory.KEYBOARD ActionId.PERFORM_IME_ACTION -> ActionCategory.KEYBOARD ActionId.SWITCH_KEYBOARD -> ActionCategory.KEYBOARD + ActionId.CYCLE_KEYBOARD_LANGUAGE -> ActionCategory.KEYBOARD ActionId.LOCK_DEVICE -> ActionCategory.INTERFACE ActionId.POWER_ON_OFF_DEVICE -> ActionCategory.INTERFACE ActionId.SECURE_LOCK_DEVICE -> ActionCategory.INTERFACE @@ -436,6 +437,8 @@ object ActionUtils { ActionId.SWITCH_KEYBOARD -> R.string.action_switch_keyboard + ActionId.CYCLE_KEYBOARD_LANGUAGE -> R.string.action_cycle_keyboard_language + ActionId.TOGGLE_AIRPLANE_MODE -> R.string.action_toggle_airplane_mode ActionId.ENABLE_AIRPLANE_MODE -> R.string.action_enable_airplane_mode @@ -492,6 +495,7 @@ object ActionUtils { ActionId.DISMISS_ALL_NOTIFICATIONS -> R.string.action_dismiss_all_notifications ActionId.CREATE_NOTIFICATION -> R.string.action_create_notification + ActionId.TOAST -> R.string.action_toast ActionId.ANSWER_PHONE_CALL -> R.string.action_answer_call @@ -836,6 +840,10 @@ object ActionUtils { ActionId.FAST_FORWARD_PACKAGE, ActionId.REWIND_PACKAGE, ActionId.STOP_MEDIA_PACKAGE, + ActionId.STEP_FORWARD, + ActionId.STEP_FORWARD_PACKAGE, + ActionId.STEP_BACKWARD, + ActionId.STEP_BACKWARD_PACKAGE, -> return listOf(Permission.NOTIFICATION_LISTENER) ActionId.VOLUME_UP, @@ -902,6 +910,8 @@ object ActionUtils { return listOf(Permission.WRITE_SECURE_SETTINGS) } + ActionId.CYCLE_KEYBOARD_LANGUAGE -> return listOf(Permission.WRITE_SECURE_SETTINGS) + ActionId.TOGGLE_AIRPLANE_MODE, ActionId.ENABLE_AIRPLANE_MODE, ActionId.DISABLE_AIRPLANE_MODE, @@ -1022,10 +1032,10 @@ object ActionUtils { 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.STEP_FORWARD -> Icons.Outlined.KeyboardDoubleArrowRight + ActionId.STEP_FORWARD_PACKAGE -> Icons.Outlined.KeyboardDoubleArrowRight + ActionId.STEP_BACKWARD -> Icons.Outlined.KeyboardDoubleArrowLeft + ActionId.STEP_BACKWARD_PACKAGE -> Icons.Outlined.KeyboardDoubleArrowLeft ActionId.GO_BACK -> Icons.AutoMirrored.Outlined.ArrowBack ActionId.GO_HOME -> Icons.Outlined.Home ActionId.OPEN_RECENTS -> Icons.Outlined.ViewArray @@ -1051,6 +1061,7 @@ object ActionUtils { ActionId.SELECT_ALL_TEXT -> Icons.Outlined.SelectAll ActionId.PERFORM_IME_ACTION -> Icons.Outlined.Keyboard ActionId.SWITCH_KEYBOARD -> Icons.Outlined.Keyboard + ActionId.CYCLE_KEYBOARD_LANGUAGE -> Icons.Outlined.Keyboard ActionId.TOGGLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive ActionId.ENABLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive ActionId.DISABLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeInactive @@ -1118,6 +1129,8 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.Sound, is ActionData.SwitchKeyboard, is ActionData.ControlMediaForApp, + is ActionData.ControlMedia.StepForward, + is ActionData.ControlMedia.StepBackward, is ActionData.Volume.Up, is ActionData.Volume.Down, is ActionData.Volume.Mute, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt index 4198aa2287..32ce5cf4a9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt @@ -133,10 +133,12 @@ private fun ActionsScreen( }, text = { Text(stringResource(R.string.action_list_delete_dialog_text)) }, confirmButton = { - TextButton(onClick = { - onRemoveClick(actionToDelete!!) - showDeleteDialog = false - }) { + TextButton( + onClick = { + onRemoveClick(actionToDelete!!) + showDeleteDialog = false + }, + ) { Text(stringResource(R.string.action_list_delete_yes)) } }, @@ -154,88 +156,22 @@ private fun ActionsScreen( is State.Data -> Surface(modifier = modifier) { Column { Spacer(Modifier.height(8.dp)) - - // Display action tip if available - tipModel?.let { tip -> - TipCard( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - title = tip.title, - message = tip.message, - isDismissable = tip.isDismissable, - onDismiss = onActionTipDismiss, - buttonText = tip.buttonText, - onButtonClick = { onTipButtonClick(tip.id) }, - ) - - Spacer(Modifier.height(8.dp)) - } - - when (val data = state.data) { - is ConfigActionsState.Empty -> { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - modifier = Modifier - .padding(32.dp) - .fillMaxWidth(), - text = stringResource(R.string.actions_recyclerview_placeholder), - textAlign = TextAlign.Center, - ) - - if (data.shortcuts.isNotEmpty()) { - Text( - text = stringResource(R.string.recently_used_actions), - style = MaterialTheme.typography.titleSmall, - ) - - Spacer(Modifier.height(8.dp)) - - ShortcutRow( - modifier = Modifier - .padding(horizontal = 32.dp) - .fillMaxWidth(), - shortcuts = data.shortcuts, - onClick = onClickShortcut, - ) - } - } - } - - is ConfigActionsState.Loaded -> { - if (data.actions.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - - Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = stringResource(R.string.action_list_explanation_header), - style = MaterialTheme.typography.titleSmall, - ) - } - - Spacer(Modifier.height(8.dp)) - - ActionList( - modifier = Modifier.weight(1f), - actionList = data.actions, - shortcuts = data.shortcuts, - isReorderingEnabled = data.isReorderingEnabled, - onRemoveClick = { - actionToDelete = it - showDeleteDialog = true - }, - onEditClick = onEditClick, - onFixErrorClick = onFixErrorClick, - onMove = onMoveAction, - onClickShortcut = onClickShortcut, - onTestClick = onTestClick, - ) - } - } + ActionList( + modifier = Modifier.weight(1f), + state = state.data, + tipModel = tipModel, + onRemoveClick = { + actionToDelete = it + showDeleteDialog = true + }, + onEditClick = onEditClick, + onFixErrorClick = onFixErrorClick, + onMove = onMoveAction, + onClickShortcut = onClickShortcut, + onTestClick = onTestClick, + onActionTipDismiss, + onTipButtonClick, + ) FilledTonalButton( modifier = Modifier @@ -264,27 +200,33 @@ private fun Loading(modifier: Modifier = Modifier) { @Composable private fun ActionList( modifier: Modifier = Modifier, - actionList: List, - shortcuts: Set>, - isReorderingEnabled: Boolean, + state: ConfigActionsState, + tipModel: OnboardingTipModel?, onRemoveClick: (String) -> Unit, onEditClick: (String) -> Unit, onFixErrorClick: (String) -> Unit, onMove: (fromIndex: Int, toIndex: Int) -> Unit, onClickShortcut: (ActionData) -> Unit, onTestClick: (String) -> Unit, + onActionTipDismiss: () -> Unit, + onTipButtonClick: (String) -> Unit, ) { val lazyListState = rememberLazyListState() - val dragDropState = rememberDragDropState( - lazyListState = lazyListState, - onMove = onMove, - // Do not drag and drop the row of shortcuts - ignoreLastItems = if (shortcuts.isEmpty()) { - 0 - } else { - 1 - }, - ) + + val dragDropState = if (state is ConfigActionsState.Loaded) { + rememberDragDropState( + lazyListState = lazyListState, + onMove = onMove, + // Do not drag and drop the row of shortcuts + ignoreLastItems = if (state.shortcuts.isEmpty()) { + 0 + } else { + 1 + }, + ) + } else { + null + } // Use dragContainer rather than .draggable() modifier because that causes // dragging the first item to be always be dropped in the next position. @@ -292,43 +234,84 @@ private fun ActionList( modifier = modifier, state = lazyListState, contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = if (state is ConfigActionsState.Empty) { + Arrangement.Center + } else { + Arrangement.Top + }, ) { - itemsIndexed( - actionList, - key = { _, item -> item.id }, - contentType = { _, _ -> "action" }, - ) { index, model -> - DraggableItem( - dragDropState = dragDropState, - index = index, - ) { isDragging -> - ActionListItem( - modifier = Modifier.fillMaxWidth(), - model = model, - index = index, - isDraggingEnabled = actionList.size > 1, - isDragging = isDragging, - isReorderingEnabled = isReorderingEnabled, - dragDropState = dragDropState, - onEditClick = { onEditClick(model.id) }, - onRemoveClick = { onRemoveClick(model.id) }, - onFixClick = { onFixErrorClick(model.id) }, - onTestClick = { onTestClick(model.id) }, - onMoveUp = if (isReorderingEnabled && index > 0) { - { onMove(index, index - 1) } - } else { - null - }, - onMoveDown = if (isReorderingEnabled && index < actionList.size - 1) { - { onMove(index, index + 1) } - } else { - null - }, + // Display action tip if available + tipModel?.let { tip -> + item { + TipCard( + modifier = Modifier + .padding(horizontal = 16.dp), + title = tip.title, + message = tip.message, + isDismissable = tip.isDismissable, + onDismiss = onActionTipDismiss, + buttonText = tip.buttonText, + onButtonClick = { onTipButtonClick(tip.id) }, ) + + Spacer(Modifier.height(8.dp)) } } - if (shortcuts.isNotEmpty()) { + when (state) { + is ConfigActionsState.Empty -> { + item { + Text( + modifier = Modifier + .padding(32.dp) + .fillMaxWidth(), + text = stringResource(R.string.actions_recyclerview_placeholder), + textAlign = TextAlign.Center, + ) + } + } + + is ConfigActionsState.Loaded -> { + itemsIndexed( + state.actions, + key = { _, item -> item.id }, + contentType = { _, _ -> "action" }, + ) { index, model -> + DraggableItem( + dragDropState = dragDropState!!, + index = index, + ) { isDragging -> + ActionListItem( + modifier = Modifier.fillMaxWidth(), + model = model, + index = index, + isDraggingEnabled = state.actions.size > 1, + isDragging = isDragging, + isReorderingEnabled = state.isReorderingEnabled, + dragDropState = dragDropState, + onEditClick = { onEditClick(model.id) }, + onRemoveClick = { onRemoveClick(model.id) }, + onFixClick = { onFixErrorClick(model.id) }, + onTestClick = { onTestClick(model.id) }, + onMoveUp = if (state.isReorderingEnabled && index > 0) { + { onMove(index, index - 1) } + } else { + null + }, + onMoveDown = if (state.isReorderingEnabled && + index < state.actions.size - 1 + ) { + { onMove(index, index + 1) } + } else { + null + }, + ) + } + } + } + } + + if (state.shortcuts.isNotEmpty()) { item(key = "shortcuts", contentType = "shortcuts") { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( @@ -342,7 +325,7 @@ private fun ActionList( modifier = Modifier .fillMaxWidth() .padding(horizontal = 32.dp), - shortcuts = shortcuts, + shortcuts = state.shortcuts, onClick = { onClickShortcut(it) }, ) } @@ -354,6 +337,18 @@ private fun ActionList( @Preview @Composable private fun EmptyPreview() { + KeyMapperTheme { + ActionsScreen( + state = State.Data( + ConfigActionsState.Empty(shortcuts = emptySet()), + ), + ) + } +} + +@Preview +@Composable +private fun EmptyWithShortcutsPreview() { KeyMapperTheme { ActionsScreen( state = State.Data( @@ -384,6 +379,13 @@ private fun EmptyPreview() { private fun LoadedPreview() { KeyMapperTheme { ActionsScreen( + tipModel = OnboardingTipModel( + id = "id", + title = "Use a dedicated action instead", + message = "Use the \"Flashlight\" action instead.", + isDismissable = false, + buttonText = "Replace action", + ), state = State.Data( ConfigActionsState.Loaded( actions = listOf( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt index 39a36e39af..0915c11e8b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt @@ -60,6 +60,7 @@ fun HandleActionBottomSheets(delegate: CreateActionDelegate) { ModifySettingActionBottomSheet(delegate) CreateNotificationActionBottomSheet(delegate) ToastActionBottomSheet(delegate) + StepMediaActionBottomSheet(delegate) PickTalkBackGestureDialog(delegate) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt index c2fc69a8b3..b606478b92 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt @@ -452,13 +452,15 @@ class ConfigActionsViewModel @Inject constructor( } sealed class ConfigActionsState { - data class Empty(val shortcuts: Set> = emptySet()) : + abstract val shortcuts: Set> + + data class Empty(override val shortcuts: Set> = emptySet()) : ConfigActionsState() data class Loaded( val actions: List = emptyList(), val isReorderingEnabled: Boolean = false, - val shortcuts: Set> = emptySet(), + override val shortcuts: Set> = emptySet(), ) : ConfigActionsState() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt index d70ce81a7e..6ccadaad10 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModel.kt @@ -15,6 +15,7 @@ import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.models.isExecuting import io.github.sds100.keymapper.common.utils.handle +import io.github.sds100.keymapper.common.utils.normalizeLineEndings import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager @@ -142,15 +143,19 @@ class ConfigShellCommandViewModel @Inject constructor( return false } + // Scripts pasted from a computer can have Windows line endings, which a shell fails to + // parse. See issue #2209. + val command = state.command.normalizeLineEndings() + val action = ActionData.ShellCommand( description = state.description, - command = state.command, + command = command, executionMode = state.executionMode, timeoutMillis = state.timeoutSeconds * 1000, ) // Save script text before navigating away - saveScriptText(state.command) + saveScriptText(command) viewModelScope.launch { navigationProvider.popBackStackWithResult(Json.encodeToString(action)) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index 9776d3073e..66671fef6f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -69,6 +69,7 @@ class CreateActionDelegate( var createNotificationActionBottomSheetState: CreateNotificationActionBottomSheetState? by mutableStateOf(null) var toastActionBottomSheetState: ToastActionBottomSheetState? by mutableStateOf(null) + var stepMediaActionBottomSheetState: StepMediaActionBottomSheetState? by mutableStateOf(null) init { coroutineScope.launch { @@ -388,6 +389,39 @@ class CreateActionDelegate( actionResult.update { action } } + fun onStepMediaDurationEnabledChange(enabled: Boolean) { + stepMediaActionBottomSheetState = + stepMediaActionBottomSheetState?.copy(durationEnabled = enabled) + } + + fun onStepMediaDurationChange(durationSeconds: Int) { + stepMediaActionBottomSheetState = + stepMediaActionBottomSheetState?.copy(durationSeconds = durationSeconds) + } + + fun onDoneStepMediaClick() { + val state = stepMediaActionBottomSheetState ?: return + + val durationMs = if (state.durationEnabled) { + state.durationSeconds * 1000L + } else { + null + } + + val action = when (state.actionId) { + ActionId.STEP_FORWARD -> ActionData.ControlMedia.StepForward(durationMs) + ActionId.STEP_BACKWARD -> ActionData.ControlMedia.StepBackward(durationMs) + ActionId.STEP_FORWARD_PACKAGE -> + ActionData.ControlMediaForApp.StepForward(state.packageName!!, durationMs) + ActionId.STEP_BACKWARD_PACKAGE -> + ActionData.ControlMediaForApp.StepBackward(state.packageName!!, durationMs) + else -> throw Exception("don't know how to create action for ${state.actionId}") + } + + stepMediaActionBottomSheetState = null + actionResult.update { action } + } + fun onRequestNotificationPermissionClick() { useCase.requestPermission(Permission.POST_NOTIFICATIONS) } @@ -431,8 +465,6 @@ class CreateActionDelegate( ActionId.FAST_FORWARD_PACKAGE, ActionId.REWIND_PACKAGE, ActionId.STOP_MEDIA_PACKAGE, - ActionId.STEP_FORWARD_PACKAGE, - ActionId.STEP_BACKWARD_PACKAGE, -> { val packageName = navigate( @@ -465,18 +497,37 @@ class CreateActionDelegate( 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") } return action } + ActionId.STEP_FORWARD_PACKAGE, + ActionId.STEP_BACKWARD_PACKAGE, + -> { + val packageName = + navigate( + "choose_app_for_media_action", + NavDestination.ChooseApp(allowHiddenApps = true), + ) ?: return null + + val oldStepDurationMs = when (oldData) { + is ActionData.ControlMediaForApp.StepForward -> oldData.stepDurationMs + is ActionData.ControlMediaForApp.StepBackward -> oldData.stepDurationMs + else -> null + } + + stepMediaActionBottomSheetState = StepMediaActionBottomSheetState( + actionId = actionId, + packageName = packageName, + durationEnabled = oldStepDurationMs != null, + durationSeconds = ((oldStepDurationMs ?: 30000L) / 1000).toInt(), + ) + + return null + } + ActionId.VOLUME_UP -> { val oldVolumeUpData = oldData as? ActionData.Volume.Up volumeActionState = VolumeActionBottomSheetState( @@ -778,6 +829,7 @@ class CreateActionDelegate( oldData.x, oldData.y, oldData.description ?: "", + oldData.screenResolution, ) } else { null @@ -794,6 +846,7 @@ class CreateActionDelegate( result.x, result.y, description, + result.screenResolution, ) } @@ -807,6 +860,7 @@ class CreateActionDelegate( oldData.fingerCount, oldData.duration, oldData.description ?: "", + oldData.screenResolution, ) } else { null @@ -827,6 +881,7 @@ class CreateActionDelegate( result.fingerCount, result.duration, description, + result.screenResolution, ) } @@ -840,6 +895,7 @@ class CreateActionDelegate( oldData.fingerCount, oldData.duration, oldData.description ?: "", + oldData.screenResolution, ) } else { null @@ -860,6 +916,7 @@ class CreateActionDelegate( result.fingerCount, result.duration, description, + result.screenResolution, ) } @@ -1067,9 +1124,23 @@ class CreateActionDelegate( ActionId.STOP_MEDIA -> return ActionData.ControlMedia.Stop - ActionId.STEP_FORWARD -> return ActionData.ControlMedia.StepForward + ActionId.STEP_FORWARD, + ActionId.STEP_BACKWARD, + -> { + val oldStepDurationMs = when (oldData) { + is ActionData.ControlMedia.StepForward -> oldData.stepDurationMs + is ActionData.ControlMedia.StepBackward -> oldData.stepDurationMs + else -> null + } + + stepMediaActionBottomSheetState = StepMediaActionBottomSheetState( + actionId = actionId, + durationEnabled = oldStepDurationMs != null, + durationSeconds = ((oldStepDurationMs ?: 30000L) / 1000).toInt(), + ) - ActionId.STEP_BACKWARD -> return ActionData.ControlMedia.StepBackward + return null + } ActionId.GO_BACK -> return ActionData.GoBack @@ -1099,6 +1170,8 @@ class CreateActionDelegate( ActionId.PERFORM_IME_ACTION -> return ActionData.PerformImeAction + ActionId.CYCLE_KEYBOARD_LANGUAGE -> return ActionData.CycleKeyboardLanguage + ActionId.TEXT_CUT -> return ActionData.CutText ActionId.TEXT_COPY -> return ActionData.CopyText diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt index 26e096ee28..85dfc0d745 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCase.kt @@ -4,6 +4,7 @@ import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.models.ShellResult import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.normalizeLineEndings import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.shell.ShellAdapter @@ -14,6 +15,11 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext +/** + * Line endings are normalized before executing because a shell does not recognize reserved words + * like "then" when they are followed by a \r, so scripts pasted with Windows line endings fail to + * parse. See issue #2209. + */ class ExecuteShellCommandUseCase @Inject constructor( private val shellAdapter: ShellAdapter, private val suAdapter: SuAdapter, @@ -24,10 +30,12 @@ class ExecuteShellCommandUseCase @Inject constructor( executionMode: ShellExecutionMode, timeoutMillis: Long, ): KMResult = withContext(Dispatchers.IO) { + val sanitizedCommand = command.normalizeLineEndings() + when (executionMode) { - ShellExecutionMode.STANDARD -> shellAdapter.execute(command, timeoutMillis) - ShellExecutionMode.ROOT -> suAdapter.execute(command, timeoutMillis) - ShellExecutionMode.ADB -> executeCommandSystemBridge(command, timeoutMillis) + ShellExecutionMode.STANDARD -> shellAdapter.execute(sanitizedCommand, timeoutMillis) + ShellExecutionMode.ROOT -> suAdapter.execute(sanitizedCommand, timeoutMillis) + ShellExecutionMode.ADB -> executeCommandSystemBridge(sanitizedCommand, timeoutMillis) } } @@ -36,16 +44,23 @@ class ExecuteShellCommandUseCase @Inject constructor( executionMode: ShellExecutionMode, timeoutMillis: Long, ): Flow> { + val sanitizedCommand = command.normalizeLineEndings() + return when (executionMode) { ShellExecutionMode.STANDARD -> shellAdapter.executeWithStreamingOutput( - command, + sanitizedCommand, timeoutMillis, ) - ShellExecutionMode.ROOT -> suAdapter.executeWithStreamingOutput(command, timeoutMillis) + ShellExecutionMode.ROOT -> suAdapter.executeWithStreamingOutput( + sanitizedCommand, + timeoutMillis, + ) // ADB mode doesn't support streaming - ShellExecutionMode.ADB -> flowOf(executeCommandSystemBridge(command, timeoutMillis)) + ShellExecutionMode.ADB -> flowOf( + executeCommandSystemBridge(sanitizedCommand, timeoutMillis), + ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index eac8372e48..fcbba68c89 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt @@ -26,6 +26,8 @@ import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMError.SdkVersionTooLow import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Orientation +import io.github.sds100.keymapper.common.utils.PointKM +import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.firstBlocking import io.github.sds100.keymapper.common.utils.getWordBoundaries @@ -72,6 +74,7 @@ import io.github.sds100.keymapper.system.volume.RingerMode import io.github.sds100.keymapper.system.volume.VolumeAdapter import io.github.sds100.keymapper.system.volume.VolumeStream import kotlin.math.absoluteValue +import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -243,11 +246,11 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.ControlMediaForApp.StepForward -> { - result = mediaAdapter.stepForward(action.packageName) + result = mediaAdapter.stepForward(action.packageName, action.stepDurationMs) } is ActionData.ControlMediaForApp.StepBackward -> { - result = mediaAdapter.stepBackward(action.packageName) + result = mediaAdapter.stepBackward(action.packageName, action.stepDurationMs) } is ActionData.Rotation.CycleRotations -> { @@ -297,6 +300,10 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } } + is ActionData.CycleKeyboardLanguage -> { + result = inputMethodAdapter.cycleInputMethodSubtype() + } + is ActionData.Volume.Down -> { result = audioAdapter.lowerVolume( stream = action.volumeStream, @@ -352,15 +359,29 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.TapScreen -> { - result = service.tapScreen(action.x, action.y, inputEventAction) + val displaySize = displayAdapter.size + val point = + scaleCoordinate(action.x, action.y, action.screenResolution, displaySize) + + result = service.tapScreen(point.x, point.y, inputEventAction) } is ActionData.SwipeScreen -> { - result = service.swipeScreen( + val displaySize = displayAdapter.size + val start = scaleCoordinate( action.xStart, action.yStart, - action.xEnd, - action.yEnd, + action.screenResolution, + displaySize, + ) + val end = + scaleCoordinate(action.xEnd, action.yEnd, action.screenResolution, displaySize) + + result = service.swipeScreen( + start.x, + start.y, + end.x, + end.y, action.fingerCount, action.duration, inputEventAction, @@ -368,10 +389,16 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.PinchScreen -> { + val displaySize = displayAdapter.size + val point = + scaleCoordinate(action.x, action.y, action.screenResolution, displaySize) + val distance = + scaleDistance(action.distance, action.screenResolution, displaySize) + result = service.pinchScreen( - action.x, - action.y, - action.distance, + point.x, + point.y, + distance, action.pinchType, action.fingerCount, action.duration, @@ -647,11 +674,11 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } is ActionData.ControlMedia.StepForward -> { - result = mediaAdapter.stepForward() + result = mediaAdapter.stepForward(durationMs = action.stepDurationMs) } is ActionData.ControlMedia.StepBackward -> { - result = mediaAdapter.stepBackward() + result = mediaAdapter.stepBackward(durationMs = action.stepDurationMs) } is ActionData.GoBack -> { @@ -1226,6 +1253,74 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } } +/** + * See issue #2217. Scale a coordinate that was picked on a display of size [from] so that it lands + * in the same relative position on a display of size [to]. Samsung phones let you change the + * display resolution dynamically so the coordinate that was saved with the action can be for a + * different resolution to the current one. + * + * The coordinate is returned unchanged if there is nothing to scale by. See + * [normaliseSourceDisplaySize]. + */ +internal fun scaleCoordinate(x: Int, y: Int, from: SizeKM?, to: SizeKM): PointKM { + val source = normaliseSourceDisplaySize(from, to) ?: return PointKM(x, y) + + val xRatio = x.toFloat() / source.width + val yRatio = y.toFloat() / source.height + + return PointKM((xRatio * to.width).roundToInt(), (yRatio * to.height).roundToInt()) +} + +/** + * See issue #2217. Scale a pixel distance, such as the pinch distance, that was picked on a display + * of size [from] to a display of size [to]. + * + * A distance is a radius around a point rather than a position so it must be scaled by a single + * factor. The average of the horizontal and vertical ratio is used, which is the same as either + * one of them whenever both axes scale equally. + */ +internal fun scaleDistance(distance: Int, from: SizeKM?, to: SizeKM): Int { + val source = normaliseSourceDisplaySize(from, to) ?: return distance + + val xRatio = to.width.toFloat() / source.width + val yRatio = to.height.toFloat() / source.height + + return (distance * (xRatio + yRatio) / 2).roundToInt() +} + +/** + * The display size to scale from, or null if there is nothing to scale by because [from] is unknown, + * either size is invalid, or the sizes already match. + * + * If [from] was saved in the opposite orientation to [to] then its width and height are swapped + * first. This means rotating the device on its own never moves the coordinate and only a genuine + * change in resolution does. + */ +private fun normaliseSourceDisplaySize(from: SizeKM?, to: SizeKM): SizeKM? { + if (from == null) { + return null + } + + if (from.width <= 0 || from.height <= 0 || to.width <= 0 || to.height <= 0) { + return null + } + + val isFromLandscape = from.width > from.height + val isToLandscape = to.width > to.height + + val source = if (isFromLandscape == isToLandscape) { + from + } else { + SizeKM(width = from.height, height = from.width) + } + + if (source == to) { + return null + } + + return source +} + interface PerformActionsUseCase { val defaultHoldDownDuration: Flow val defaultRepeatDelay: Flow diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ScreenshotPickerDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ScreenshotPickerDelegate.kt new file mode 100644 index 0000000000..f40ef22350 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ScreenshotPickerDelegate.kt @@ -0,0 +1,73 @@ +package io.github.sds100.keymapper.base.actions + +import android.graphics.Bitmap +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.utils.ui.DialogModel +import io.github.sds100.keymapper.base.utils.ui.DialogProvider +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.common.utils.SizeKM +import io.github.sds100.keymapper.system.display.DisplayAdapter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * Shared logic for picking a screenshot to tap/pinch/swipe on. See issue #2217 for why the + * chosen screen resolution is tracked, and why an aspect ratio match (rather than an exact + * resolution match) is now sufficient: coordinates are scaled against the runtime display size + * when the action is performed. + */ +class ScreenshotPickerDelegate( + private val coroutineScope: CoroutineScope, + private val displayAdapter: DisplayAdapter, + resourceProvider: ResourceProvider, + dialogProvider: DialogProvider, +) : ResourceProvider by resourceProvider, + DialogProvider by dialogProvider { + + private val _bitmap = MutableStateFlow(null) + val bitmap: StateFlow = _bitmap.asStateFlow() + + private val screenshotResolution = MutableStateFlow(null) + private val loadedResolution = MutableStateFlow(null) + + fun selectedScreenshot(newBitmap: Bitmap) { + val newBitmapSize = SizeKM(newBitmap.width, newBitmap.height) + + if (!displayAdapter.size.hasSameAspectRatio(newBitmapSize)) { + coroutineScope.launch { + val snackBar = DialogModel.SnackBar( + message = getString(R.string.toast_incorrect_screenshot_resolution), + ) + + showDialog("incorrect_resolution", snackBar) + } + + return + } + + screenshotResolution.value = newBitmapSize + _bitmap.value = newBitmap + } + + fun setLoadedResolution(size: SizeKM?) { + loadedResolution.value = size + } + + /** + * See issue #2217. Prefer the screenshot's resolution because the coordinates are in its + * pixel space, then the resolution the action was already saved with so that editing an + * action on a device that has since changed resolution does not stamp the wrong one on + * unchanged coordinates. + */ + fun screenResolution(): SizeKM = + screenshotResolution.value ?: loadedResolution.value ?: displayAdapter.size + + fun recycle() { + _bitmap.value?.recycle() + _bitmap.value = null + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/StepMediaActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/StepMediaActionBottomSheet.kt new file mode 100644 index 0000000000..b768f34a1c --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/StepMediaActionBottomSheet.kt @@ -0,0 +1,216 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +import kotlin.math.roundToInt +import kotlinx.coroutines.launch + +private const val MIN_DURATION_SECONDS = 5 +private const val MAX_DURATION_SECONDS = 60 +private const val DURATION_STEP_SECONDS = 5 + +data class StepMediaActionBottomSheetState( + val actionId: ActionId, + val packageName: String? = null, + val durationEnabled: Boolean = false, + /** + * UI works with seconds for user-friendliness + */ + val durationSeconds: Int = 30, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StepMediaActionBottomSheet(delegate: CreateActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (delegate.stepMediaActionBottomSheetState != null) { + StepMediaActionBottomSheet( + sheetState = sheetState, + state = delegate.stepMediaActionBottomSheetState!!, + onDismissRequest = { + delegate.stepMediaActionBottomSheetState = null + }, + onDurationEnabledChange = delegate::onStepMediaDurationEnabledChange, + onDurationChange = delegate::onStepMediaDurationChange, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneStepMediaClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun StepMediaActionBottomSheet( + sheetState: SheetState, + state: StepMediaActionBottomSheetState, + onDismissRequest: () -> Unit = {}, + onDurationEnabledChange: (Boolean) -> Unit = {}, + onDurationChange: (Int) -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + + val titleRes = when (state.actionId) { + ActionId.STEP_BACKWARD, ActionId.STEP_BACKWARD_PACKAGE -> + R.string.action_step_backward_media + + else -> R.string.action_step_forward_media + } + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + textAlign = TextAlign.Center, + text = stringResource(titleRes), + style = MaterialTheme.typography.headlineMedium, + ) + + CheckBoxText( + text = stringResource(R.string.action_step_media_duration_checkbox), + isChecked = state.durationEnabled, + onCheckedChange = onDurationEnabledChange, + ) + + if (state.durationEnabled) { + val durationValueFormat = + stringResource(R.string.action_step_media_duration_value) + + SliderOptionText( + title = stringResource(R.string.action_step_media_duration_label), + value = state.durationSeconds.toFloat(), + defaultValue = 30f, + valueText = { value -> + durationValueFormat.format(value.roundToInt()) + }, + onValueChange = { onDurationChange(it.roundToInt()) }, + valueRange = MIN_DURATION_SECONDS.toFloat()..MAX_DURATION_SECONDS.toFloat(), + stepSize = DURATION_STEP_SECONDS, + ) + } + + Text( + text = stringResource(R.string.action_step_media_duration_not_supported_text), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + modifier = Modifier.weight(1f), + onClick = onDoneClick, + ) { + Text(stringResource(R.string.pos_done)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun StepMediaActionBottomSheetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + + StepMediaActionBottomSheet( + sheetState = sheetState, + state = StepMediaActionBottomSheetState( + actionId = ActionId.STEP_FORWARD, + durationEnabled = true, + durationSeconds = 45, + ), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun StepMediaActionBottomSheetDefaultPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + + StepMediaActionBottomSheet( + sheetState = sheetState, + state = StepMediaActionBottomSheetState( + actionId = ActionId.STEP_BACKWARD, + durationEnabled = false, + ), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/DedicatedActionKeyCodes.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/DedicatedActionKeyCodes.kt new file mode 100644 index 0000000000..1cb4157fa7 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/DedicatedActionKeyCodes.kt @@ -0,0 +1,45 @@ +package io.github.sds100.keymapper.base.actions.keyevent + +import android.view.KeyEvent +import io.github.sds100.keymapper.base.actions.ActionData + +/** + * Many users create a "Key Code" action for a key that actually has a dedicated action, expecting + * it to work like a system shortcut. This does not work for system-level keys + * (volume, power, home, screenshot, media, etc.) it silently does nothing on most devices. + * This returns the dedicated [ActionData] + * Key Mapper recommends instead of injecting [keyCode] directly, or null if there isn't an + * unambiguous one. + */ +fun getDedicatedKeyCodeAction(keyCode: Int): ActionData? = when (keyCode) { + KeyEvent.KEYCODE_VOLUME_UP -> ActionData.Volume.Up(showVolumeUi = false) + KeyEvent.KEYCODE_VOLUME_DOWN -> ActionData.Volume.Down(showVolumeUi = false) + KeyEvent.KEYCODE_VOLUME_MUTE -> ActionData.Volume.Mute(showVolumeUi = false) + KeyEvent.KEYCODE_POWER -> ActionData.LockDevice + KeyEvent.KEYCODE_LANGUAGE_SWITCH -> ActionData.CycleKeyboardLanguage + KeyEvent.KEYCODE_CAMERA -> ActionData.OpenCamera + KeyEvent.KEYCODE_SETTINGS -> ActionData.OpenSettings + KeyEvent.KEYCODE_HOME -> ActionData.GoHome + KeyEvent.KEYCODE_APP_SWITCH -> ActionData.OpenRecents + KeyEvent.KEYCODE_BACK -> ActionData.GoBack + KeyEvent.KEYCODE_MUTE -> ActionData.Microphone.Mute + KeyEvent.KEYCODE_SCREENSHOT -> ActionData.Screenshot + KeyEvent.KEYCODE_MEDIA_PLAY -> ActionData.ControlMedia.Play + KeyEvent.KEYCODE_MEDIA_PAUSE -> ActionData.ControlMedia.Pause + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> ActionData.ControlMedia.PlayPause + KeyEvent.KEYCODE_MEDIA_NEXT -> ActionData.ControlMedia.NextTrack + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> ActionData.ControlMedia.PreviousTrack + KeyEvent.KEYCODE_MEDIA_STOP -> ActionData.ControlMedia.Stop + KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> ActionData.ControlMedia.FastForward + KeyEvent.KEYCODE_MEDIA_REWIND -> ActionData.ControlMedia.Rewind + KeyEvent.KEYCODE_MEDIA_STEP_FORWARD -> ActionData.ControlMedia.StepForward() + KeyEvent.KEYCODE_MEDIA_STEP_BACKWARD -> ActionData.ControlMedia.StepBackward() + KeyEvent.KEYCODE_BRIGHTNESS_UP -> ActionData.Brightness.Increase + KeyEvent.KEYCODE_BRIGHTNESS_DOWN -> ActionData.Brightness.Decrease + KeyEvent.KEYCODE_VOICE_ASSIST -> ActionData.VoiceAssistant + KeyEvent.KEYCODE_ASSIST -> ActionData.DeviceAssistant + KeyEvent.KEYCODE_CALL -> ActionData.AnswerCall + KeyEvent.KEYCODE_ENDCALL -> ActionData.EndCall + KeyEvent.KEYCODE_NOTIFICATION -> ActionData.StatusBar.ExpandNotifications + else -> null +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickCoordinateResult.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickCoordinateResult.kt index b3624183d4..e4fffcf004 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickCoordinateResult.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickCoordinateResult.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.actions.pinchscreen import io.github.sds100.keymapper.common.utils.PinchScreenType +import io.github.sds100.keymapper.common.utils.SizeKM import kotlinx.serialization.Serializable @Serializable @@ -12,4 +13,8 @@ data class PinchPickCoordinateResult( val fingerCount: Int, val duration: Int, val description: String, + /** + * The display size that the coordinates and distance were picked for. See issue #2217. + */ + val screenResolution: SizeKM? = null, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateFragment.kt index ebcee5903c..ab0082dc31 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateFragment.kt @@ -2,7 +2,6 @@ package io.github.sds100.keymapper.base.actions.pinchscreen import android.annotation.SuppressLint import android.graphics.Bitmap -import android.graphics.Point import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -10,7 +9,6 @@ import android.view.ViewGroup import android.widget.ArrayAdapter import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -51,12 +49,7 @@ class PinchPickDisplayCoordinateFragment : Fragment() { bitmap ?: return@registerForActivityResult - val displaySize = Point().apply { - @Suppress("DEPRECATION") - ContextCompat.getDisplayOrDefault(requireContext()).getRealSize(this) - } - - viewModel.selectedScreenshot(bitmap, displaySize) + viewModel.selectedScreenshot(bitmap) } /** diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt index 6091f9beba..853c561a63 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt @@ -2,7 +2,6 @@ package io.github.sds100.keymapper.base.actions.pinchscreen import android.accessibilityservice.GestureDescription import android.graphics.Bitmap -import android.graphics.Point import android.os.Build import android.view.View import android.widget.AdapterView @@ -10,11 +9,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.ScreenshotPickerDelegate import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog import io.github.sds100.keymapper.common.utils.PinchScreenType +import io.github.sds100.keymapper.system.display.DisplayAdapter import javax.inject.Inject import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableSharedFlow @@ -22,7 +23,6 @@ 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.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -30,6 +30,7 @@ import kotlinx.coroutines.launch @HiltViewModel class PinchPickDisplayCoordinateViewModel @Inject constructor( + private val displayAdapter: DisplayAdapter, resourceProvider: ResourceProvider, dialogProvider: DialogProvider, ) : ViewModel(), @@ -45,7 +46,12 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( private val fingerCount = MutableStateFlow(2) private val duration = MutableStateFlow(200) - private val _bitmap = MutableStateFlow(null) + private val screenshotDelegate = ScreenshotPickerDelegate( + viewModelScope, + displayAdapter, + resourceProvider, + dialogProvider, + ) private val _returnResult = MutableSharedFlow() private val description: MutableStateFlow = MutableStateFlow(null) @@ -139,7 +145,7 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( null }.stateIn(viewModelScope, SharingStarted.Lazily, null) - val bitmap = _bitmap.asStateFlow() + val bitmap = screenshotDelegate.bitmap val returnResult = _returnResult.asSharedFlow() private val isCoordinatesValid: StateFlow = @@ -164,23 +170,8 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( isCoordinatesValid && fingerCountError == null && durationError == null }.stateIn(viewModelScope, SharingStarted.Lazily, false) - fun selectedScreenshot(newBitmap: Bitmap, displaySize: Point) { - // check whether the height and width of the bitmap match the display size, even when it is rotated. - if ((displaySize.x != newBitmap.width && displaySize.y != newBitmap.height) && - (displaySize.y != newBitmap.width && displaySize.x != newBitmap.height) - ) { - viewModelScope.launch { - val snackBar = DialogModel.SnackBar( - message = getString(R.string.toast_incorrect_screenshot_resolution), - ) - - showDialog("incorrect_resolution", snackBar) - } - - return - } - - _bitmap.value = newBitmap + fun selectedScreenshot(newBitmap: Bitmap) { + screenshotDelegate.selectedScreenshot(newBitmap) } fun setX(x: String) { @@ -252,6 +243,7 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( fingerCount, duration, description, + screenshotDelegate.screenResolution(), ), ) } @@ -270,12 +262,12 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( fingerCount.value = result.fingerCount duration.value = result.duration description.value = result.description + screenshotDelegate.setLoadedResolution(result.screenResolution) } } override fun onCleared() { - bitmap.value?.recycle() - _bitmap.value = null + screenshotDelegate.recycle() super.onCleared() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickCoordinateResult.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickCoordinateResult.kt index 7f084200d0..d822b93445 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickCoordinateResult.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickCoordinateResult.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.actions.swipescreen +import io.github.sds100.keymapper.common.utils.SizeKM import kotlinx.serialization.Serializable @Serializable @@ -11,4 +12,8 @@ data class SwipePickCoordinateResult( val fingerCount: Int, val duration: Int, val description: String, + /** + * The display size that the coordinates were picked for. See issue #2217. + */ + val screenResolution: SizeKM? = null, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateFragment.kt index 98757a26dc..75f0994a0f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateFragment.kt @@ -2,14 +2,12 @@ package io.github.sds100.keymapper.base.actions.swipescreen import android.annotation.SuppressLint import android.graphics.Bitmap -import android.graphics.Point import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -47,12 +45,7 @@ class SwipePickDisplayCoordinateFragment : Fragment() { bitmap ?: return@registerForActivityResult - val displaySize = Point().apply { - @Suppress("DEPRECATION") - ContextCompat.getDisplayOrDefault(requireContext()).getRealSize(this) - } - - viewModel.selectedScreenshot(bitmap, displaySize) + viewModel.selectedScreenshot(bitmap) } /** diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt index e4fa5cb8ce..0c5bb07fcf 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt @@ -2,15 +2,16 @@ package io.github.sds100.keymapper.base.actions.swipescreen import android.accessibilityservice.GestureDescription import android.graphics.Bitmap -import android.graphics.Point import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.ScreenshotPickerDelegate import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.system.display.DisplayAdapter import javax.inject.Inject import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableSharedFlow @@ -18,7 +19,6 @@ 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.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -31,6 +31,7 @@ enum class ScreenshotTouchType { @HiltViewModel class SwipePickDisplayCoordinateViewModel @Inject constructor( + private val displayAdapter: DisplayAdapter, resourceProvider: ResourceProvider, dialogProvider: DialogProvider, ) : ViewModel(), @@ -46,7 +47,12 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( private val fingerCount = MutableStateFlow(1) private val duration = MutableStateFlow(200) - private val _bitmap = MutableStateFlow(null) + private val screenshotDelegate = ScreenshotPickerDelegate( + viewModelScope, + displayAdapter, + resourceProvider, + dialogProvider, + ) private val _returnResult = MutableSharedFlow() private val screenshotTouchType = MutableStateFlow(ScreenshotTouchType.START) @@ -127,7 +133,7 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( null }.stateIn(viewModelScope, SharingStarted.Lazily, null) - val bitmap = _bitmap.asStateFlow() + val bitmap = screenshotDelegate.bitmap val returnResult = _returnResult.asSharedFlow() val isSelectStartEndSwitchEnabled: StateFlow = combine(bitmap) { @@ -157,25 +163,9 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( isCoordinatesValid && isOptionsValid }.stateIn(viewModelScope, SharingStarted.Lazily, false) - fun selectedScreenshot(newBitmap: Bitmap, displaySize: Point) { + fun selectedScreenshot(newBitmap: Bitmap) { screenshotTouchType.value = ScreenshotTouchType.START - - // check whether the height and width of the bitmap match the display size, even when it is rotated. - if ((displaySize.x != newBitmap.width && displaySize.y != newBitmap.height) && - (displaySize.y != newBitmap.width && displaySize.x != newBitmap.height) - ) { - viewModelScope.launch { - val snackBar = DialogModel.SnackBar( - message = getString(R.string.toast_incorrect_screenshot_resolution), - ) - - showDialog("incorrect_resolution", snackBar) - } - - return - } - - _bitmap.value = newBitmap + screenshotDelegate.selectedScreenshot(newBitmap) } fun setXStart(x: String) { @@ -252,6 +242,7 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( fingerCount, duration, description, + screenshotDelegate.screenResolution(), ), ) } @@ -266,12 +257,12 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( fingerCount.value = result.fingerCount duration.value = result.duration description.value = result.description + screenshotDelegate.setLoadedResolution(result.screenResolution) } } override fun onCleared() { - bitmap.value?.recycle() - _bitmap.value = null + screenshotDelegate.recycle() super.onCleared() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/PickTalkBackGestureDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/PickTalkBackGestureDialog.kt index 47dcd83d72..9142583a93 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/PickTalkBackGestureDialog.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/PickTalkBackGestureDialog.kt @@ -60,6 +60,7 @@ private fun PickTalkBackGestureDialog( val groups = remember { listOf( R.string.talkback_gesture_section_1_finger to listOf( + TalkBackGestureType.ONE_FINGER_DOUBLE_TAP, TalkBackGestureType.SWIPE_UP, TalkBackGestureType.SWIPE_DOWN, TalkBackGestureType.SWIPE_LEFT, @@ -72,6 +73,7 @@ private fun PickTalkBackGestureDialog( ), R.string.talkback_gesture_section_2_finger to listOf( TalkBackGestureType.TWO_FINGER_TAP, + TalkBackGestureType.TWO_FINGER_DOUBLE_TAP, TalkBackGestureType.TWO_FINGER_DOUBLE_TAP_HOLD, TalkBackGestureType.TWO_FINGER_TRIPLE_TAP, TalkBackGestureType.TWO_FINGER_TRIPLE_TAP_HOLD, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureStrings.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureStrings.kt index e6956c4f69..651c538002 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureStrings.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureStrings.kt @@ -4,6 +4,9 @@ import io.github.sds100.keymapper.base.R object TalkBackGestureStrings { fun getActionLabel(gesture: TalkBackGestureType): Int = when (gesture) { + TalkBackGestureType.ONE_FINGER_DOUBLE_TAP -> + R.string.talkback_gesture_action_one_finger_double_tap + TalkBackGestureType.SWIPE_UP -> R.string.talkback_gesture_action_swipe_up @@ -34,6 +37,9 @@ object TalkBackGestureStrings { TalkBackGestureType.TWO_FINGER_TAP -> R.string.talkback_gesture_action_two_finger_tap + TalkBackGestureType.TWO_FINGER_DOUBLE_TAP -> + R.string.talkback_gesture_action_two_finger_double_tap + TalkBackGestureType.TWO_FINGER_DOUBLE_TAP_HOLD -> R.string.talkback_gesture_action_two_finger_double_tap_hold @@ -78,6 +84,9 @@ object TalkBackGestureStrings { } fun getGestureLabel(gesture: TalkBackGestureType): Int = when (gesture) { + TalkBackGestureType.ONE_FINGER_DOUBLE_TAP -> + R.string.talkback_gesture_name_one_finger_double_tap + TalkBackGestureType.SWIPE_UP -> R.string.talkback_gesture_name_swipe_up @@ -108,6 +117,9 @@ object TalkBackGestureStrings { TalkBackGestureType.TWO_FINGER_TAP -> R.string.talkback_gesture_name_two_finger_tap + TalkBackGestureType.TWO_FINGER_DOUBLE_TAP -> + R.string.talkback_gesture_name_two_finger_double_tap + TalkBackGestureType.TWO_FINGER_DOUBLE_TAP_HOLD -> R.string.talkback_gesture_name_two_finger_double_tap_hold diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureType.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureType.kt index db81147d59..feb423c0ac 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureType.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureType.kt @@ -1,6 +1,9 @@ package io.github.sds100.keymapper.base.actions.talkback enum class TalkBackGestureType { + // 1-finger gestures + ONE_FINGER_DOUBLE_TAP, + // 1-finger swipes SWIPE_UP, SWIPE_DOWN, @@ -16,6 +19,7 @@ enum class TalkBackGestureType { // 2-finger gestures TWO_FINGER_TAP, + TWO_FINGER_DOUBLE_TAP, TWO_FINGER_DOUBLE_TAP_HOLD, TWO_FINGER_TRIPLE_TAP, TWO_FINGER_TRIPLE_TAP_HOLD, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkbackGesturePerformer.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkbackGesturePerformer.kt index 1823e6ab54..04502671f7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkbackGesturePerformer.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkbackGesturePerformer.kt @@ -25,6 +25,17 @@ object TalkbackGesturePerformer { val gestureBuilder = GestureDescription.Builder() when (gesture) { + TalkBackGestureType.ONE_FINGER_DOUBLE_TAP -> + AccessibilityGestureUtils.addMultiFingerTaps( + gestureBuilder, + cx, + cy, + fingerSpacing, + fingerCount = 1, + tapCount = 2, + holdDuration = 50, + ) + TalkBackGestureType.SWIPE_UP -> gestureBuilder.addStroke( AccessibilityGestureUtils.buildSwipe( @@ -190,6 +201,17 @@ object TalkbackGesturePerformer { holdDuration = 50, ) + TalkBackGestureType.TWO_FINGER_DOUBLE_TAP -> + AccessibilityGestureUtils.addMultiFingerTaps( + gestureBuilder, + cx, + cy, + fingerSpacing, + fingerCount = 2, + tapCount = 2, + holdDuration = 50, + ) + TalkBackGestureType.TWO_FINGER_DOUBLE_TAP_HOLD -> AccessibilityGestureUtils.addMultiFingerDoubleTapHold( gestureBuilder, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickCoordinateResult.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickCoordinateResult.kt index 8c45352d20..07a4077074 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickCoordinateResult.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickCoordinateResult.kt @@ -1,6 +1,15 @@ package io.github.sds100.keymapper.base.actions.tapscreen +import io.github.sds100.keymapper.common.utils.SizeKM import kotlinx.serialization.Serializable @Serializable -data class PickCoordinateResult(val x: Int, val y: Int, val description: String) +data class PickCoordinateResult( + val x: Int, + val y: Int, + val description: String, + /** + * The display size that the coordinate was picked for. See issue #2217. + */ + val screenResolution: SizeKM? = null, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateFragment.kt index e8a8e88efb..aa6cfbcacd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateFragment.kt @@ -2,14 +2,12 @@ package io.github.sds100.keymapper.base.actions.tapscreen import android.annotation.SuppressLint import android.graphics.Bitmap -import android.graphics.Point import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat @@ -47,12 +45,7 @@ class PickDisplayCoordinateFragment : Fragment() { bitmap ?: return@registerForActivityResult - val displaySize = Point().apply { - @Suppress("DEPRECATION") - ContextCompat.getDisplayOrDefault(requireContext()).getRealSize(this) - } - - viewModel.selectedScreenshot(bitmap, displaySize) + viewModel.selectedScreenshot(bitmap) } /** diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateViewModel.kt index 723a201beb..afeff43c08 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateViewModel.kt @@ -1,15 +1,16 @@ package io.github.sds100.keymapper.base.actions.tapscreen import android.graphics.Bitmap -import android.graphics.Point import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.ScreenshotPickerDelegate import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.system.display.DisplayAdapter import javax.inject.Inject import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableSharedFlow @@ -17,7 +18,6 @@ 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.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -25,6 +25,7 @@ import kotlinx.coroutines.launch @HiltViewModel class PickDisplayCoordinateViewModel @Inject constructor( + private val displayAdapter: DisplayAdapter, resourceProvider: ResourceProvider, dialogProvider: DialogProvider, ) : ViewModel(), @@ -53,31 +54,21 @@ class PickDisplayCoordinateViewModel @Inject constructor( x >= 0 && y >= 0 }.stateIn(viewModelScope, SharingStarted.Lazily, false) - private val _bitmap = MutableStateFlow(null) - val bitmap = _bitmap.asStateFlow() + private val screenshotDelegate = ScreenshotPickerDelegate( + viewModelScope, + displayAdapter, + resourceProvider, + dialogProvider, + ) + val bitmap = screenshotDelegate.bitmap private val _returnResult = MutableSharedFlow() val returnResult = _returnResult.asSharedFlow() private val description: MutableStateFlow = MutableStateFlow(null) - fun selectedScreenshot(newBitmap: Bitmap, displaySize: Point) { - // check whether the height and width of the bitmap match the display size, even when it is rotated. - if ((displaySize.x != newBitmap.width && displaySize.y != newBitmap.height) && - (displaySize.y != newBitmap.width && displaySize.x != newBitmap.height) - ) { - viewModelScope.launch { - val snackBar = DialogModel.SnackBar( - message = getString(R.string.toast_incorrect_screenshot_resolution), - ) - - showDialog("incorrect_resolution", snackBar) - } - - return - } - - _bitmap.value = newBitmap + fun selectedScreenshot(newBitmap: Bitmap) { + screenshotDelegate.selectedScreenshot(newBitmap) } fun setX(x: String) { @@ -116,7 +107,9 @@ class PickDisplayCoordinateViewModel @Inject constructor( ), ) ?: return@launch - _returnResult.emit(PickCoordinateResult(x, y, description)) + _returnResult.emit( + PickCoordinateResult(x, y, description, screenshotDelegate.screenResolution()), + ) } } @@ -125,12 +118,12 @@ class PickDisplayCoordinateViewModel @Inject constructor( x.value = result.x y.value = result.y description.value = result.description + screenshotDelegate.setLoadedResolution(result.screenResolution) } } override fun onCleared() { - bitmap.value?.recycle() - _bitmap.value = null + screenshotDelegate.recycle() super.onCleared() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt index c79b29a1b9..5b6f935c88 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt @@ -185,11 +185,7 @@ class BackupManagerImpl @Inject constructor( val dataJsonFile = fileAdapter.getFile(extractedDir, DATA_JSON_FILE_NAME) - val inputStream = dataJsonFile.inputStream() - - if (inputStream == null) { - return KMError.UnknownIOError - } + val inputStream = dataJsonFile.inputStream() ?: return KMError.UnknownIOError return parseBackupContent(inputStream) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupRestoreMappingsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupRestoreMappingsUseCase.kt index d50b364790..ec9c941927 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupRestoreMappingsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupRestoreMappingsUseCase.kt @@ -1,33 +1,48 @@ package io.github.sds100.keymapper.base.backup +import android.os.Build +import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.then import io.github.sds100.keymapper.system.files.FileAdapter +import io.github.sds100.keymapper.system.files.FileUtils +import io.github.sds100.keymapper.system.files.IFile +import io.github.sds100.keymapper.system.leanback.LeanbackAdapter +import io.github.sds100.keymapper.system.permissions.Permission +import io.github.sds100.keymapper.system.permissions.PermissionAdapter import javax.inject.Inject import kotlinx.coroutines.flow.Flow import timber.log.Timber +sealed class ExportedBackupLocation { + data class PublicUri(val uri: String) : ExportedBackupLocation() + data class Downloads(val fileName: String) : ExportedBackupLocation() +} + class BackupRestoreMappingsUseCaseImpl @Inject constructor( private val fileAdapter: FileAdapter, private val backupManager: BackupManager, + private val leanbackAdapter: LeanbackAdapter, + private val buildConfigProvider: BuildConfigProvider, + private val permissionAdapter: PermissionAdapter, ) : BackupRestoreMappingsUseCase { override val onAutomaticBackupResult: Flow> = backupManager.onAutomaticBackupResult - override suspend fun backupEverything(): KMResult { + override suspend fun backupEverything(): KMResult { val fileName = BackupUtils.createBackupFileName() // Share in private files so the share sheet can show the file name. This is some quirk // of the storage access framework https://issuetracker.google.com/issues/268079113. // Saving it directly to Downloads with the MediaStore returns a content URI // that only contains a numerical ID, not the file name. - return fileAdapter.getPrivateFile("${BackupManagerImpl.BACKUP_DIR}/$fileName").let { file -> - file.createFile() - backupManager.backupEverything(file) - Success(fileAdapter.getPublicUriForPrivateFile(file)) - } + val file = fileAdapter.getPrivateFile("${BackupManagerImpl.BACKUP_DIR}/$fileName") + file.createFile() + backupManager.backupEverything(file) + + return exportToUserAccessibleLocation(file, fileName) } override suspend fun restoreKeyMaps(uri: String, restoreType: RestoreType): KMResult<*> { @@ -38,16 +53,76 @@ class BackupRestoreMappingsUseCaseImpl @Inject constructor( override suspend fun getKeyMapCountInBackup(uri: String): KMResult { val file = fileAdapter.getFileFromUri(uri) + return backupManager.getBackupContent(file) .then { Success(it.keyMapList?.size ?: 0) } .onFailure { Timber.e(it.toString()) } } + + override fun getDownloadedBackups(): KMResult> = + fileAdapter.getDownloads().then { files -> + Success(files.filter { it.extension.equals("zip", ignoreCase = true) }) + } + + override fun canRequestFullFileAccess(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + buildConfigProvider.canRequestAllFilesAccess && + !permissionAdapter.isGranted(Permission.MANAGE_EXTERNAL_STORAGE) + + override fun requestFullFileAccess() { + permissionAdapter.request(Permission.MANAGE_EXTERNAL_STORAGE) + } + + override suspend fun exportToUserAccessibleLocation( + privateFile: IFile, + fileName: String, + ): KMResult { + if (leanbackAdapter.isTvDevice() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return fileAdapter.openDownloadsFile(fileName, FileUtils.MIME_TYPE_ZIP).then { target -> + privateFile.inputStream()!!.use { input -> + target.outputStream()!!.use { output -> input.copyTo(output) } + } + + Success(ExportedBackupLocation.Downloads(fileName)) + } + } + + return Success( + ExportedBackupLocation.PublicUri(fileAdapter.getPublicUriForPrivateFile(privateFile)), + ) + } } interface BackupRestoreMappingsUseCase { val onAutomaticBackupResult: Flow> - suspend fun backupEverything(): KMResult + suspend fun backupEverything(): KMResult suspend fun restoreKeyMaps(uri: String, restoreType: RestoreType): KMResult<*> suspend fun getKeyMapCountInBackup(uri: String): KMResult + + /** + * Lists backup zips found in the Downloads folder, for choosing one to import on a + * device with no usable system file picker (Android TV). + */ + fun getDownloadedBackups(): KMResult> + + /** + * Whether the user could be offered the option to grant MANAGE_EXTERNAL_STORAGE so + * [getDownloadedBackups] can see backups other apps put in Downloads, not just ones + * this app itself exported. False if already granted or this build can't request it. + */ + fun canRequestFullFileAccess(): Boolean + fun requestFullFileAccess() + + /** + * Decides where a freshly created backup zip should end up. On Android TV there is no + * usable system file picker (see HomeKeyMapListScreen's import/export handling — SAF + * and share intents resolve to fake stub activities that always fail), so the file is + * written directly into the public Downloads collection instead of being shared + * through the private-file + share-sheet flow used everywhere else. + */ + suspend fun exportToUserAccessibleLocation( + privateFile: IFile, + fileName: String, + ): KMResult } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/backup/ImportExportState.kt b/base/src/main/java/io/github/sds100/keymapper/base/backup/ImportExportState.kt index a570129d85..9721f21de4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/backup/ImportExportState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/backup/ImportExportState.kt @@ -5,8 +5,26 @@ sealed class ImportExportState { data object Exporting : ImportExportState() data class FinishedExport(val uri: String) : ImportExportState() + /** + * The backup was written directly to the Downloads folder because there is no usable + * system file picker to share it through (Android TV). + */ + data class FinishedExportToDownloads(val fileName: String) : ImportExportState() + data class ConfirmImport(val fileUri: String, val keyMapCount: Int) : ImportExportState() + + /** + * Let the user pick a backup found in Downloads to import, for a device with no + * usable system file picker (Android TV). + */ + data class ChooseImportFileFromDownloads( + val files: List, + val canRequestFullAccess: Boolean, + ) : ImportExportState() + data object Importing : ImportExportState() data object FinishedImport : ImportExportState() data class Error(val error: String) : ImportExportState() } + +data class BackupFileListItem(val uri: String, val name: String) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt index 620ee5109b..e0e77eb0c6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.HelpOutline import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.rounded.BatteryChargingFull import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Close @@ -119,6 +120,7 @@ fun ExpertModeScreen(modifier: Modifier = Modifier, viewModel: ExpertModeViewMod onLaunchDeveloperOptionsClick = viewModel::onLaunchDeveloperOptionsClick, onGetShellStartCommandClick = viewModel::onGetShellStartCommandClick, onGetEventClick = viewModel::onGetEventClick, + onXiaomiOptimizationClick = viewModel::onXiaomiOptimizationClick, ) } } @@ -203,6 +205,7 @@ private fun Content( onLaunchDeveloperOptionsClick: () -> Unit = {}, onGetShellStartCommandClick: () -> Unit = {}, onGetEventClick: () -> Unit = {}, + onXiaomiOptimizationClick: () -> Unit = {}, ) { Column(modifier = modifier.verticalScroll(rememberScrollState())) { AnimatedVisibility( @@ -253,6 +256,7 @@ private fun Content( onEmergencyStopToggled = onEmergencyStopToggled, onLaunchDeveloperOptionsClick = onLaunchDeveloperOptionsClick, onGetShellStartCommandClick = onGetShellStartCommandClick, + onXiaomiOptimizationClick = onXiaomiOptimizationClick, ) } } @@ -300,6 +304,7 @@ private fun LoadedContent( onEmergencyStopToggled: () -> Unit = {}, onLaunchDeveloperOptionsClick: () -> Unit = {}, onGetShellStartCommandClick: () -> Unit = {}, + onXiaomiOptimizationClick: () -> Unit = {}, ) { Column(modifier) { OptionsHeaderRow( @@ -370,6 +375,17 @@ private fun LoadedContent( Spacer(modifier = Modifier.height(8.dp)) } + if (state.showXiaomiOptimizationCard) { + XiaomiOptimizationCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + onButtonClick = onXiaomiOptimizationClick, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + ExpertModeStartedCard( modifier = Modifier .fillMaxWidth() @@ -605,6 +621,30 @@ private fun UsbDebuggingSecuritySettingsCard(modifier: Modifier = Modifier) { ) } +@Composable +private fun XiaomiOptimizationCard(modifier: Modifier = Modifier, onButtonClick: () -> Unit = {}) { + SetupCard( + modifier = modifier, + color = MaterialTheme.colorScheme.primaryContainer, + icon = { + Icon( + imageVector = Icons.Rounded.BatteryChargingFull, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + title = stringResource(R.string.expert_mode_xiaomi_optimization_title), + content = { + Text( + text = stringResource(R.string.expert_mode_xiaomi_optimization_description), + style = MaterialTheme.typography.bodyMedium, + ) + }, + buttonText = stringResource(R.string.button_fix), + onButtonClick = onButtonClick, + ) +} + @Composable private fun WarningCard( modifier: Modifier = Modifier, @@ -1021,6 +1061,7 @@ private fun PreviewDark() { autoStartBootChecked = true, autoStartBootEnabled = true, showXiaomiAdbInputSecurityWarning = false, + showXiaomiOptimizationCard = false, emergencyStopChecked = true, ), ), @@ -1065,6 +1106,7 @@ private fun PreviewStarted() { autoStartBootChecked = false, autoStartBootEnabled = true, showXiaomiAdbInputSecurityWarning = false, + showXiaomiOptimizationCard = false, emergencyStopChecked = true, ), ), @@ -1115,6 +1157,33 @@ private fun PreviewUsbDebuggingSecuritySettingsCard() { autoStartBootChecked = false, autoStartBootEnabled = true, showXiaomiAdbInputSecurityWarning = false, + showXiaomiOptimizationCard = false, + emergencyStopChecked = true, + ), + ), + showInfoCard = false, + onInfoCardDismiss = {}, + onAutoStartAtBootToggled = {}, + onLaunchDeveloperOptionsClick = {}, + ) + } + } +} + +@Preview +@Composable +private fun PreviewXiaomiOptimizationCard() { + KeyMapperTheme { + ExpertModeScreen { + Content( + warningState = ExpertModeWarningState.Understood, + setupState = State.Data( + ExpertModeState.Started( + isDefaultUsbModeCompatible = true, + autoStartBootChecked = false, + autoStartBootEnabled = true, + showXiaomiAdbInputSecurityWarning = false, + showXiaomiOptimizationCard = true, emergencyStopChecked = true, ), ), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupScreen.kt index 89670020a0..231bbe69f8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeSetupScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.rounded.Accessibility import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.Build import androidx.compose.material.icons.rounded.CheckCircleOutline +import androidx.compose.material.icons.rounded.Lan import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.PlayArrow @@ -346,6 +347,7 @@ private fun getIconForStep(step: SystemBridgeSetupStep): ImageVector { return when (step) { SystemBridgeSetupStep.ACCESSIBILITY_SERVICE -> Icons.Rounded.Accessibility SystemBridgeSetupStep.NOTIFICATION_PERMISSION -> Icons.Rounded.Notifications + SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION -> Icons.Rounded.Lan SystemBridgeSetupStep.DEVELOPER_OPTIONS -> Icons.Rounded.Build SystemBridgeSetupStep.WIFI_NETWORK -> KeyMapperIcons.SignalWifiNotConnected SystemBridgeSetupStep.WIRELESS_DEBUGGING -> Icons.Rounded.BugReport @@ -385,6 +387,19 @@ private fun createPreviewStepContent(step: SystemBridgeSetupStep): StepContent { ), ) + SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION -> StepContent( + title = stringResource( + R.string.expert_mode_setup_wizard_local_network_permission_title, + ), + message = stringResource( + R.string.expert_mode_setup_wizard_local_network_permission_description, + ), + icon = icon, + buttonText = stringResource( + R.string.expert_mode_setup_wizard_local_network_permission_button, + ), + ) + SystemBridgeSetupStep.DEVELOPER_OPTIONS -> StepContent( title = stringResource( R.string.expert_mode_setup_wizard_enable_developer_options_title, @@ -481,6 +496,27 @@ private fun ExpertModeSetupScreenNotificationPermissionPreview() { } } +@Preview(name = "Local Network Permission Step") +@Composable +private fun ExpertModeSetupScreenLocalNetworkPermissionPreview() { + KeyMapperTheme { + val step = SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION + ExpertModeSetupScreen( + state = State.Data( + ExpertModeSetupState( + stepNumber = 3, + stepCount = 9, + step = step, + stepContent = createPreviewStepContent(step), + isSetupAssistantChecked = false, + isSetupAssistantButtonEnabled = true, + isStarting = false, + ), + ), + ) + } +} + @Preview(name = "Developer Options Step") @Composable private fun ExpertModeSetupScreenDeveloperOptionsPreview() { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeViewModel.kt index 6e1702aab2..0b94c2eaaf 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/ExpertModeViewModel.kt @@ -13,6 +13,7 @@ import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.isXiaomiDevice import io.github.sds100.keymapper.common.utils.valueOrNull import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -199,6 +200,12 @@ class ExpertModeViewModel @Inject constructor( } } + fun onXiaomiOptimizationClick() { + viewModelScope.launch { + navigate("open_xiaomi_optimization", NavDestination.XiaomiOptimization) + } + } + private fun stoppedStateFlow(): Flow = combine( useCase.isRootGranted, useCase.shizukuSetupState, @@ -239,6 +246,7 @@ class ExpertModeViewModel @Inject constructor( autoStartBootChecked = autoStartBootChecked, autoStartBootEnabled = autoStartBootEnabled, showXiaomiAdbInputSecurityWarning = !xiaomiAdbSecuritySettingsEnabled, + showXiaomiOptimizationCard = isXiaomiDevice(), emergencyStopChecked = emergencyStopChecked, ) } @@ -264,6 +272,7 @@ sealed class ExpertModeState { val autoStartBootChecked: Boolean, val autoStartBootEnabled: Boolean, val showXiaomiAdbInputSecurityWarning: Boolean, + val showXiaomiOptimizationCard: Boolean, val emergencyStopChecked: Boolean, ) : ExpertModeState() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarter.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarter.kt index c54298cffa..9519f59a12 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarter.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarter.kt @@ -130,7 +130,9 @@ class SystemBridgeAutoStarter @Inject constructor( AutoStartEligibility.NotEligible.WriteSecureSettingsRevoked } - !setupController.isAdbPaired() -> { + isAdbPaired == false || + !setupController.isAdbPaired() + .also { isAdbPaired = it } -> { AutoStartEligibility.NotEligible.AdbUnpaired } @@ -151,9 +153,19 @@ class SystemBridgeAutoStarter @Inject constructor( @OptIn(ExperimentalCoroutinesApi::class) private val autoStartFlow: Flow = connectionManager.connectionState.flatMapLatest { connectionState -> + if (connectionState is SystemBridgeConnectionState.Connected) { + // Reset this flag so it checks its paired next time. + isAdbPaired = null + } + getAutoStartEligibility(connectionState) } + /** + * Null if it hasn't been checked before or it is cleared so it is checked again. + */ + private var isAdbPaired: Boolean? = null + /** * This must only be called once in the application lifecycle */ @@ -227,7 +239,16 @@ class SystemBridgeAutoStarter @Inject constructor( ) } - AutoStartEligibility.NotEligible.WiFiDisconnected -> showWiFiDisconnectedNotification() + AutoStartEligibility.NotEligible.WiFiDisconnected -> + // Do not show the notification it previously determined that ADB + // was not paired correctly. This prevents the wifi disconnected notification + // from repeatedly showing every time the phone disconnects from WiFi. Connecting + // to WiFi won't fix the problem so stop spamming the user. + if (isAdbPaired != false) { + showWiFiDisconnectedNotification() + } + + AutoStartEligibility.NotEligible.AdbUnpaired -> showAutoStartFailedNotification() else -> { Timber.w("Not auto starting the system bridge: $eligibility") @@ -280,6 +301,13 @@ class SystemBridgeAutoStarter @Inject constructor( return } + // Return before setting the auto start time, otherwise the cooldown would block + // auto starting again once the storage becomes available. + if (!connectionManager.canStartSystemBridge()) { + Timber.w("Not auto starting with $type because the storage is unavailable.") + return + } + // This must use the unix timestamp and not a time relative to the uptime of the device. // Otherwise, it may not autostart on reboot if it started earlier than when it last auto // started relative to the last boot. diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupAssistantController.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupAssistantController.kt index 1cba44467a..cf06ba204c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupAssistantController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupAssistantController.kt @@ -1,7 +1,6 @@ package io.github.sds100.keymapper.base.expertmode import android.app.ActivityManager -import android.graphics.Rect import android.os.Build import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo @@ -12,14 +11,15 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.github.sds100.keymapper.base.BaseMainActivity import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.expertmode.SystemBridgeSetupAssistantController.Companion.PAIRING_CODE_BUTTON_STRING_RES_NAMES import io.github.sds100.keymapper.base.system.accessibility.BaseAccessibilityService +import io.github.sds100.keymapper.base.system.accessibility.findActionTarget import io.github.sds100.keymapper.base.system.accessibility.findNodeRecursively import io.github.sds100.keymapper.base.system.notifications.ManageNotificationsUseCase import io.github.sds100.keymapper.base.system.notifications.NotificationController import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.KeyMapperClassProvider import io.github.sds100.keymapper.common.notifications.KMNotificationAction -import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.onSuccess import io.github.sds100.keymapper.data.Keys @@ -75,6 +75,22 @@ class SystemBridgeSetupAssistantController @AssistedInject constructor( "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", ) + private const val SETTINGS_PACKAGE = "com.android.settings" + + /** + * The names of the string resources in the Settings app for the button that + * starts pairing with a code. Reading them from the Settings app means this + * works in every language. + */ + private val PAIRING_CODE_BUTTON_STRING_RES_NAMES = arrayOf( + "adb_pair_method_code_title", + "adb_pair_method_code_summary", + ) + + /** + * A fallback for when the Settings app on this device doesn't have the + * resources in [PAIRING_CODE_BUTTON_STRING_RES_NAMES]. + */ private val PAIRING_CODE_BUTTON_TEXT_FILTER = arrayOf( "six-digit code", // English "six digit code", // English @@ -114,6 +130,15 @@ class SystemBridgeSetupAssistantController @AssistedInject constructor( // Store the pairing code so only one request to pair is sent per pairing code. private var foundPairingCode: String? = null + /** + * The text on the button that starts pairing with a code. Read the strings from the + * Settings app itself so this works in every language, and fall back to a hardcoded + * list of translations if this device doesn't have those resources. + */ + private val pairingCodeButtonTextFilter: List by lazy { + getPairingCodeButtonStrings() + } + fun onServiceConnected() { coroutineScope.launch { setupController.setupAssistantStep.collect { step -> @@ -163,7 +188,7 @@ class SystemBridgeSetupAssistantController @AssistedInject constructor( val step = interactionStep ?: return val rootNode = accessibilityService.rootInActiveWindow ?: return - if (rootNode.packageName != "com.android.settings") { + if (rootNode.packageName != SETTINGS_PACKAGE) { return } @@ -250,21 +275,25 @@ class SystemBridgeSetupAssistantController @AssistedInject constructor( } private fun clickPairWithCodeButton(rootNode: AccessibilityNodeInfo) { - // This works more maintainable/adaptable then traversing the tree - // and trying to find the clickable node. This can change subtly between - // Android devices and ROMs. val textNode = rootNode.findNodeRecursively { node -> - PAIRING_CODE_BUTTON_TEXT_FILTER.any { text -> node.text?.contains(text) == true } + pairingCodeButtonTextFilter.any { text -> node.text?.contains(text) == true } } ?: return - val bounds = Rect() - textNode.getBoundsInScreen(bounds) + // The node with the text is almost never the node that handles the click. It is + // usually a child of the row that handles it, and on some ROMs the clickable node + // is a sibling covering the same place on screen. Resolving the node by its + // position is more maintainable than assuming where it is in the tree because + // that can change subtly between Android devices and ROMs. + val targetNode = + textNode.findActionTarget(action = AccessibilityNodeInfo.ACTION_CLICK) - accessibilityService.tapScreen( - bounds.centerX(), - bounds.centerY(), - InputEventAction.DOWN_UP, - ) + if (targetNode == null) { + Timber.w("Found the pair with code text but no node that can click it.") + return + } + + val success = targetNode.performAction(AccessibilityNodeInfo.ACTION_CLICK) + Timber.i("Clicked the pair with code button: $success") } private fun showNotification( @@ -392,10 +421,34 @@ class SystemBridgeSetupAssistantController @AssistedInject constructor( private fun getKeyMapperAppTask(): ActivityManager.AppTask? { val task = activityManager.appTasks ?.firstOrNull { - it.taskInfo.topActivity?.className == + it.taskInfo?.topActivity?.className == keyMapperClassProvider.getMainActivity().name } return task } + + private fun getPairingCodeButtonStrings(): List { + val stringsFromSettings = try { + val resources = accessibilityService.packageManager + .getResourcesForApplication(SETTINGS_PACKAGE) + + PAIRING_CODE_BUTTON_STRING_RES_NAMES.mapNotNull { name -> + val id = resources.getIdentifier(name, "string", SETTINGS_PACKAGE) + + if (id == 0) { + null + } else { + resources.getString(id).takeIf { it.isNotBlank() } + } + } + } catch (e: Exception) { + Timber.w(e, "Failed to read the pairing strings from $SETTINGS_PACKAGE") + emptyList() + } + + Timber.d("Pairing button strings from Settings: $stringsFromSettings") + + return stringsFromSettings + PAIRING_CODE_BUTTON_TEXT_FILTER + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupDelegate.kt index c4ba397200..28a16c14d4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupDelegate.kt @@ -5,6 +5,7 @@ import androidx.compose.material.icons.rounded.Accessibility import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.Build import androidx.compose.material.icons.rounded.CheckCircleOutline +import androidx.compose.material.icons.rounded.Lan import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.PlayArrow @@ -47,6 +48,8 @@ abstract class SystemBridgeSetupDelegateImpl( when (currentStep) { SystemBridgeSetupStep.ACCESSIBILITY_SERVICE -> useCase.enableAccessibilityService() SystemBridgeSetupStep.NOTIFICATION_PERMISSION -> useCase.requestNotificationPermission() + SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION -> + useCase.requestLocalNetworkPermission() SystemBridgeSetupStep.DEVELOPER_OPTIONS -> useCase.enableDeveloperOptions() SystemBridgeSetupStep.WIFI_NETWORK -> useCase.connectWifiNetwork() SystemBridgeSetupStep.WIRELESS_DEBUGGING -> useCase.enableWirelessDebugging() @@ -90,6 +93,19 @@ abstract class SystemBridgeSetupDelegateImpl( ), ) + SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION -> StepContent( + title = getString( + R.string.expert_mode_setup_wizard_local_network_permission_title, + ), + message = getString( + R.string.expert_mode_setup_wizard_local_network_permission_description, + ), + icon = Icons.Rounded.Lan, + buttonText = getString( + R.string.expert_mode_setup_wizard_local_network_permission_button, + ), + ) + SystemBridgeSetupStep.DEVELOPER_OPTIONS -> StepContent( title = getString( R.string.expert_mode_setup_wizard_enable_developer_options_title, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt index 2af04b5646..24a469eef4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCase.kt @@ -113,6 +113,9 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( override val isNotificationPermissionGranted: Flow = permissionAdapter.isGrantedFlow(Permission.POST_NOTIFICATIONS) + override val isLocalNetworkPermissionGranted: Flow = + permissionAdapter.isGrantedFlow(Permission.ACCESS_LOCAL_NETWORK) + @OptIn(ExperimentalCoroutinesApi::class) @RequiresApi(Build.VERSION_CODES.R) override val nextSetupStep: Flow = @@ -127,19 +130,39 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( AdbAutoStartEligibility.CHECKING -> emptyFlow() - AdbAutoStartEligibility.NOT_ELIGIBLE -> combine( - accessibilityServiceAdapter.state, - isNotificationPermissionGranted, - systemBridgeSetupController.isDeveloperOptionsEnabled, - networkAdapter.isWifiConnected, - systemBridgeSetupController.isWirelessDebuggingEnabled, - ::getNextStep, - ) + AdbAutoStartEligibility.NOT_ELIGIBLE -> getNextStepFlow() } } } } + @RequiresApi(Build.VERSION_CODES.R) + private fun getNextStepFlow(): Flow = + accessibilityServiceAdapter.state.flatMapLatest { accessibilityServiceState -> + combine( + isNotificationPermissionGranted, + isLocalNetworkPermissionGranted, + systemBridgeSetupController.isDeveloperOptionsEnabled, + networkAdapter.isWifiConnected, + systemBridgeSetupController.isWirelessDebuggingEnabled, + ) { + isNotificationGranted, + isLocalNetworkGranted, + isDeveloperOptionsEnabled, + isWifiConnected, + isWirelessDebuggingEnabled, + -> + getNextStep( + accessibilityServiceState = accessibilityServiceState, + isNotificationPermissionGranted = isNotificationGranted, + isLocalNetworkPermissionGranted = isLocalNetworkGranted, + isDeveloperOptionsEnabled = isDeveloperOptionsEnabled, + isWifiConnected = isWifiConnected, + isWirelessDebuggingEnabled = isWirelessDebuggingEnabled, + ) + } + } + override val isRootGranted: Flow = suAdapter.isRootGranted.map { it ?: false } override val shizukuSetupState: Flow = combine( @@ -167,6 +190,10 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( permissionAdapter.request(Permission.POST_NOTIFICATIONS) } + override fun requestLocalNetworkPermission() { + permissionAdapter.request(Permission.ACCESS_LOCAL_NETWORK) + } + override fun stopSystemBridge() { // Save that they've stopped the system bridge so when the app process launches again // it will set the isStoppedByUser to true. @@ -295,6 +322,7 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( private fun getNextStep( accessibilityServiceState: AccessibilityServiceState, isNotificationPermissionGranted: Boolean, + isLocalNetworkPermissionGranted: Boolean, isDeveloperOptionsEnabled: Boolean, isWifiConnected: Boolean, isWirelessDebuggingEnabled: Boolean, @@ -305,6 +333,9 @@ class SystemBridgeSetupUseCaseImpl @Inject constructor( !isNotificationPermissionGranted -> SystemBridgeSetupStep.NOTIFICATION_PERMISSION + !isLocalNetworkPermissionGranted -> + SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION + !isDeveloperOptionsEnabled -> SystemBridgeSetupStep.DEVELOPER_OPTIONS !isWifiConnected -> SystemBridgeSetupStep.WIFI_NETWORK @@ -353,6 +384,9 @@ interface SystemBridgeSetupUseCase { val isNotificationPermissionGranted: Flow fun requestNotificationPermission() + val isLocalNetworkPermissionGranted: Flow + fun requestLocalNetworkPermission() + fun stopSystemBridge() fun enableAccessibilityService() fun enableDeveloperOptions() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/xiaomi/XiaomiOptimizationScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/xiaomi/XiaomiOptimizationScreen.kt new file mode 100644 index 0000000000..68debc88b2 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/xiaomi/XiaomiOptimizationScreen.kt @@ -0,0 +1,391 @@ +package io.github.sds100.keymapper.base.expertmode.xiaomi + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.BatteryChargingFull +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme + +@Composable +fun XiaomiOptimizationScreen( + modifier: Modifier = Modifier, + viewModel: XiaomiOptimizationViewModel, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + val userMessage = viewModel.userMessage + + LaunchedEffect(userMessage) { + if (userMessage != null) { + snackbarHostState.showSnackbar(userMessage) + viewModel.onUserMessageShown() + } + } + + XiaomiOptimizationScreen( + modifier = modifier, + uiState = uiState, + snackbarHostState = snackbarHostState, + onBackClick = viewModel::onBackClick, + onApplyBatteryFixesClick = viewModel::onApplyBatteryFixesClick, + onMiuiOptimizationChange = viewModel::onMiuiOptimizationChange, + onOpenAutostartClick = viewModel::onOpenAutostartClick, + onOpenBatterySaverClick = viewModel::onOpenBatterySaverClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun XiaomiOptimizationScreen( + modifier: Modifier = Modifier, + uiState: XiaomiOptimizationUiState, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + onBackClick: () -> Unit = {}, + onApplyBatteryFixesClick: () -> Unit = {}, + onMiuiOptimizationChange: (Boolean) -> Unit = {}, + onOpenAutostartClick: () -> Unit = {}, + onOpenBatterySaverClick: () -> Unit = {}, +) { + Scaffold( + modifier = modifier.displayCutoutPadding(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.xiaomi_optimization_title)) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.action_go_back), + ) + } + }, + ) + }, + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + Snackbar(snackbarData = data) + } + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(R.string.xiaomi_optimization_hero_text), + style = MaterialTheme.typography.bodyMedium, + ) + + BatteryFixesCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + isBatteryFixesApplied = uiState.isBatteryFixesApplied, + isApplying = uiState.isApplyingBatteryFixes, + onApplyClick = onApplyBatteryFixesClick, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + MiuiOptimizationCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + isMiuiOptimizationDisabled = uiState.isMiuiOptimizationDisabled, + isToggling = uiState.isTogglingMiuiOptimization, + onCheckedChange = onMiuiOptimizationChange, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OpenSettingsStepCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + icon = Icons.Rounded.PlayArrow, + title = stringResource(R.string.xiaomi_optimization_autostart_title), + description = stringResource( + R.string.xiaomi_optimization_autostart_description, + ), + onOpenClick = onOpenAutostartClick, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OpenSettingsStepCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + icon = Icons.Rounded.Bolt, + title = stringResource(R.string.xiaomi_optimization_battery_saver_title), + description = stringResource( + R.string.xiaomi_optimization_battery_saver_description, + ), + onOpenClick = onOpenBatterySaverClick, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + StepCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + icon = Icons.Rounded.Lock, + title = stringResource(R.string.xiaomi_optimization_lock_recents_title), + description = stringResource( + R.string.xiaomi_optimization_lock_recents_description, + ), + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +@Composable +private fun BatteryFixesCard( + modifier: Modifier = Modifier, + isBatteryFixesApplied: Boolean?, + isApplying: Boolean, + onApplyClick: () -> Unit, +) { + StepCard( + modifier = modifier, + icon = Icons.Rounded.BatteryChargingFull, + title = stringResource(R.string.xiaomi_optimization_auto_apply_title), + description = stringResource(R.string.xiaomi_optimization_auto_apply_description), + ) { + if (isBatteryFixesApplied == null) { + Text( + text = stringResource(R.string.xiaomi_optimization_not_connected), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + FilledTonalButton( + modifier = Modifier.align(Alignment.End), + onClick = onApplyClick, + enabled = isBatteryFixesApplied == false && !isApplying, + ) { + if (isApplying) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = LocalContentColor.current, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + val buttonText = if (isBatteryFixesApplied == true) { + stringResource(R.string.xiaomi_optimization_applied) + } else { + stringResource(R.string.xiaomi_optimization_auto_apply_button) + } + + Text(buttonText) + } + } +} + +@Composable +private fun MiuiOptimizationCard( + modifier: Modifier = Modifier, + isMiuiOptimizationDisabled: Boolean?, + isToggling: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + StepCard( + modifier = modifier, + icon = Icons.Rounded.BatteryChargingFull, + title = stringResource(R.string.xiaomi_optimization_advanced_title), + description = stringResource(R.string.xiaomi_optimization_advanced_warning), + ) { + if (isMiuiOptimizationDisabled == null) { + Text( + text = stringResource(R.string.xiaomi_optimization_not_connected), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.xiaomi_optimization_advanced_switch), + style = MaterialTheme.typography.bodyMedium, + ) + + Switch( + checked = isMiuiOptimizationDisabled == true, + onCheckedChange = onCheckedChange, + enabled = isMiuiOptimizationDisabled != null && !isToggling, + ) + } + + Text( + text = stringResource(R.string.xiaomi_optimization_advanced_reboot_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun OpenSettingsStepCard( + modifier: Modifier = Modifier, + icon: ImageVector, + title: String, + description: String, + onOpenClick: () -> Unit, +) { + StepCard( + modifier = modifier, + icon = icon, + title = title, + description = description, + ) { + OutlinedButton( + modifier = Modifier.align(Alignment.End), + onClick = onOpenClick, + ) { + Text(stringResource(R.string.xiaomi_optimization_open_button)) + } + } +} + +@Composable +private fun StepCard( + modifier: Modifier = Modifier, + icon: ImageVector, + title: String, + description: String, + content: @Composable (ColumnScope.() -> Unit)? = null, +) { + OutlinedCard(modifier = modifier) { + Spacer(modifier = Modifier.height(16.dp)) + Row(modifier = Modifier.padding(horizontal = 16.dp)) { + Box(Modifier.size(24.dp)) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + ) + + content?.invoke(this) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Preview +@Composable +private fun PreviewConnected() { + KeyMapperTheme { + XiaomiOptimizationScreen( + uiState = XiaomiOptimizationUiState( + isMiuiOptimizationDisabled = false, + isBatteryFixesApplied = false, + ), + ) + } +} + +@Preview +@Composable +private fun PreviewDisconnected() { + KeyMapperTheme { + XiaomiOptimizationScreen( + uiState = XiaomiOptimizationUiState( + isMiuiOptimizationDisabled = null, + isBatteryFixesApplied = null, + ), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/xiaomi/XiaomiOptimizationUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/xiaomi/XiaomiOptimizationUseCase.kt new file mode 100644 index 0000000000..0fd2e8e637 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/xiaomi/XiaomiOptimizationUseCase.kt @@ -0,0 +1,203 @@ +package io.github.sds100.keymapper.base.expertmode.xiaomi + +import android.content.ActivityNotFoundException +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.ExecuteShellCommandUseCase +import io.github.sds100.keymapper.common.BuildConfigProvider +import io.github.sds100.keymapper.common.models.ShellExecutionMode +import io.github.sds100.keymapper.common.models.isSuccess +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf + +interface XiaomiOptimizationUseCase { + val isMiuiOptimizationDisabled: Flow + val isBatteryFixesApplied: Flow + suspend fun applyBatteryFixes(): Boolean + suspend fun setMiuiOptimizationDisabled(disable: Boolean): Boolean + fun openAutostartSettings(): Boolean + fun openBatterySaverSettings(): Boolean +} + +@OptIn(ExperimentalCoroutinesApi::class) +@ViewModelScoped +class XiaomiOptimizationUseCaseImpl @Inject constructor( + @ApplicationContext private val ctx: Context, + private val executeShellCommandUseCase: ExecuteShellCommandUseCase, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, + private val buildConfigProvider: BuildConfigProvider, +) : XiaomiOptimizationUseCase { + + companion object { + private const val COMMAND_TIMEOUT_MS = 5000L + } + + /** + * Emits whenever the battery fix/MIUI optimization state may have changed so the two + * flows below re-query the device, in addition to re-querying on (re)connection. + */ + private val refreshTrigger = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } + + override val isMiuiOptimizationDisabled: Flow = + combine( + systemBridgeConnectionManager.connectionState, + refreshTrigger, + ) { connectionState, _ -> connectionState } + .flatMapLatest { connectionState -> + if (connectionState is SystemBridgeConnectionState.Connected) { + flow { emit(queryMiuiOptimizationDisabled()) } + } else { + flowOf(null) + } + } + + override val isBatteryFixesApplied: Flow = + combine( + systemBridgeConnectionManager.connectionState, + refreshTrigger, + ) { connectionState, _ -> connectionState } + .flatMapLatest { connectionState -> + if (connectionState is SystemBridgeConnectionState.Connected) { + flow { emit(queryBatteryFixesApplied()) } + } else { + flowOf(null) + } + } + + override suspend fun applyBatteryFixes(): Boolean { + val packageName = buildConfigProvider.packageName + + val success = runCommand("dumpsys deviceidle whitelist +$packageName") && + runCommand("cmd appops set $packageName RUN_IN_BACKGROUND allow") && + runCommand("cmd appops set $packageName RUN_ANY_IN_BACKGROUND allow") + + refreshTrigger.tryEmit(Unit) + + return success + } + + override suspend fun setMiuiOptimizationDisabled(disable: Boolean): Boolean { + val value = if (disable) { + 0 + } else { + 1 + } + val success = runCommand("settings put global miui_optimization $value") + + refreshTrigger.tryEmit(Unit) + + return success + } + + override fun openAutostartSettings(): Boolean = safeLaunchActivity( + ComponentName( + "com.miui.securitycenter", + "com.miui.permcenter.autostart.AutoStartManagementActivity", + ), + ) + + override fun openBatterySaverSettings(): Boolean = safeLaunchActivity( + ComponentName( + "com.miui.powerkeeper", + "com.miui.powerkeeper.ui.HiddenAppsConfigActivity", + ), + ) { + putExtra("package_name", buildConfigProvider.packageName) + putExtra("package_label", ctx.getString(R.string.app_name)) + } + + private suspend fun queryBatteryFixesApplied(): Boolean { + val packageName = buildConfigProvider.packageName + + val isWhitelisted = queryOutput("dumpsys deviceidle whitelist") + ?.contains(packageName) == true + val isRunInBackgroundAllowed = + queryOutput("cmd appops get $packageName RUN_IN_BACKGROUND") + ?.contains("allow") == true + val isRunAnyInBackgroundAllowed = + queryOutput("cmd appops get $packageName RUN_ANY_IN_BACKGROUND") + ?.contains("allow") == true + + return isWhitelisted && isRunInBackgroundAllowed && isRunAnyInBackgroundAllowed + } + + private suspend fun queryMiuiOptimizationDisabled(): Boolean = + queryOutput("settings get global miui_optimization")?.trim() == "0" + + private suspend fun runCommand(command: String): Boolean { + val result = executeShellCommandUseCase.execute( + command, + ShellExecutionMode.ADB, + COMMAND_TIMEOUT_MS, + ) + + return result is Success && result.value.isSuccess() + } + + private suspend fun queryOutput(command: String): String? { + val result = executeShellCommandUseCase.execute( + command, + ShellExecutionMode.ADB, + COMMAND_TIMEOUT_MS, + ) + + return if (result is Success && result.value.isSuccess()) { + result.value.stdout + } else { + null + } + } + + /** + * Some Xiaomi ROMs remove or rename these activities, so fall back to the app's own + * details settings screen if the explicit component can't be launched. + */ + private fun safeLaunchActivity( + component: ComponentName, + putExtras: Intent.() -> Unit = {}, + ): Boolean { + val intent = Intent().apply { + this.component = component + flags = Intent.FLAG_ACTIVITY_NEW_TASK + putExtras() + } + + return try { + ctx.startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + openAppDetailsSettings() + } catch (e: SecurityException) { + openAppDetailsSettings() + } + } + + private fun openAppDetailsSettings(): Boolean { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = "package:${buildConfigProvider.packageName}".toUri() + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + return try { + ctx.startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + false + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/xiaomi/XiaomiOptimizationViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/xiaomi/XiaomiOptimizationViewModel.kt new file mode 100644 index 0000000000..c8acc37ec8 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/xiaomi/XiaomiOptimizationViewModel.kt @@ -0,0 +1,116 @@ +package io.github.sds100.keymapper.base.expertmode.xiaomi + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +@HiltViewModel +class XiaomiOptimizationViewModel @Inject constructor( + private val useCase: XiaomiOptimizationUseCase, + resourceProvider: ResourceProvider, + navigationProvider: NavigationProvider, +) : ViewModel(), + ResourceProvider by resourceProvider, + NavigationProvider by navigationProvider { + + private val isApplyingBatteryFixes = MutableStateFlow(false) + private val isTogglingMiuiOptimization = MutableStateFlow(false) + + val uiState: StateFlow = combine( + useCase.isMiuiOptimizationDisabled, + useCase.isBatteryFixesApplied, + isApplyingBatteryFixes, + isTogglingMiuiOptimization, + ) { + isMiuiOptimizationDisabled, + isBatteryFixesApplied, + isApplyingBatteryFixes, + isTogglingMiuiOptimization, + -> + XiaomiOptimizationUiState( + isMiuiOptimizationDisabled = isMiuiOptimizationDisabled, + isBatteryFixesApplied = isBatteryFixesApplied, + isApplyingBatteryFixes = isApplyingBatteryFixes, + isTogglingMiuiOptimization = isTogglingMiuiOptimization, + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + XiaomiOptimizationUiState(), + ) + + var userMessage: String? by mutableStateOf(null) + private set + + fun onBackClick() { + viewModelScope.launch { + popBackStack() + } + } + + fun onApplyBatteryFixesClick() { + viewModelScope.launch { + isApplyingBatteryFixes.value = true + + val success = useCase.applyBatteryFixes() + + if (!success) { + userMessage = getString(R.string.xiaomi_optimization_apply_error) + } + + isApplyingBatteryFixes.value = false + } + } + + fun onMiuiOptimizationChange(disable: Boolean) { + viewModelScope.launch { + isTogglingMiuiOptimization.value = true + + val success = useCase.setMiuiOptimizationDisabled(disable) + + userMessage = if (success) { + getString(R.string.xiaomi_optimization_advanced_reboot_note) + } else { + getString(R.string.xiaomi_optimization_apply_error) + } + + isTogglingMiuiOptimization.value = false + } + } + + fun onOpenAutostartClick() { + if (!useCase.openAutostartSettings()) { + userMessage = getString(R.string.xiaomi_optimization_open_settings_error) + } + } + + fun onOpenBatterySaverClick() { + if (!useCase.openBatterySaverSettings()) { + userMessage = getString(R.string.xiaomi_optimization_open_settings_error) + } + } + + fun onUserMessageShown() { + userMessage = null + } +} + +data class XiaomiOptimizationUiState( + val isMiuiOptimizationDisabled: Boolean? = null, + val isBatteryFixesApplied: Boolean? = null, + val isApplyingBatteryFixes: Boolean = false, + val isTogglingMiuiOptimization: Boolean = false, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt index f9e4e6ae49..a38e78f492 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt @@ -2,7 +2,6 @@ package io.github.sds100.keymapper.base.groups import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -11,8 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.rounded.Add @@ -31,7 +28,6 @@ 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.LocalDensity import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -57,103 +53,99 @@ fun GroupConstraintRow( onFixConstraintClick: (KMError) -> Unit = {}, enabled: Boolean = true, ) { - BoxWithConstraints(modifier = modifier) { - val maxChipWidth = LocalDensity.current.run { - (this@BoxWithConstraints.constraints.maxWidth / 2).toDp() - } - - FlowRow( - Modifier.verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - itemVerticalAlignment = Alignment.CenterVertically, - ) { - for ((index, constraint) in constraints.withIndex()) { - when (constraint) { - is ComposeChipModel.Normal -> - CompositionLocalProvider( - LocalContentColor provides MaterialTheme.colorScheme.onSurface, - ) { - ConstraintButton( - modifier = Modifier.widthIn(max = maxChipWidth), - text = constraint.text, - onRemoveClick = { onRemoveConstraintClick(constraint.id) }, - // Only allow clicking on error chips - enabled = enabled, - icon = { - if (constraint.icon is ComposeIconInfo.Vector) { - Icon( - modifier = Modifier - .size(24.dp) - .padding(end = 8.dp), - imageVector = constraint.icon.imageVector, - contentDescription = null, - ) - } else if (constraint.icon is ComposeIconInfo.Drawable) { - Icon( - modifier = Modifier - .size(24.dp) - .padding(end = 8.dp), - painter = rememberDrawablePainter( - constraint.icon.drawable, - ), - contentDescription = null, - tint = Color.Unspecified, - ) - } - }, - ) - } + val maxChipWidth = 300.dp - is ComposeChipModel.Error -> - CompositionLocalProvider( - LocalContentColor provides MaterialTheme.colorScheme.onErrorContainer, - ) { - ConstraintErrorButton( - modifier = Modifier.widthIn(max = maxChipWidth), - text = constraint.text, - onClick = { onFixConstraintClick(constraint.error) }, - onRemoveClick = { onRemoveConstraintClick(constraint.id) }, - // Only allow clicking on error chips - enabled = enabled, - ) - } - } - - if (index < constraints.lastIndex) { - when (mode) { - ConstraintMode.AND -> Text( - text = stringResource(R.string.constraint_mode_and), - style = MaterialTheme.typography.labelMedium, + FlowRow( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + for ((index, constraint) in constraints.withIndex()) { + when (constraint) { + is ComposeChipModel.Normal -> + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSurface, + ) { + ConstraintButton( + modifier = Modifier.widthIn(max = maxChipWidth), + text = constraint.text, + onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + // Only allow clicking on error chips + enabled = enabled, + icon = { + if (constraint.icon is ComposeIconInfo.Vector) { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + imageVector = constraint.icon.imageVector, + contentDescription = null, + ) + } else if (constraint.icon is ComposeIconInfo.Drawable) { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + painter = rememberDrawablePainter( + constraint.icon.drawable, + ), + contentDescription = null, + tint = Color.Unspecified, + ) + } + }, ) + } - ConstraintMode.OR -> Text( - text = stringResource(R.string.constraint_mode_or), - style = MaterialTheme.typography.labelMedium, + is ComposeChipModel.Error -> + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onErrorContainer, + ) { + ConstraintErrorButton( + modifier = Modifier.widthIn(max = maxChipWidth), + text = constraint.text, + onClick = { onFixConstraintClick(constraint.error) }, + onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + // Only allow clicking on error chips + enabled = enabled, ) } - } } - if (parentConstraintCount > 0) { - Text( - modifier = Modifier - .padding(horizontal = 8.dp), - text = pluralStringResource( - R.plurals.home_groups_inherited_constraints, - parentConstraintCount, - parentConstraintCount, - ), - style = MaterialTheme.typography.labelMedium, - ) + if (index < constraints.lastIndex) { + when (mode) { + ConstraintMode.AND -> Text( + text = stringResource(R.string.constraint_mode_and), + style = MaterialTheme.typography.labelMedium, + ) + + ConstraintMode.OR -> Text( + text = stringResource(R.string.constraint_mode_or), + style = MaterialTheme.typography.labelMedium, + ) + } } + } - NewConstraintButton( - onClick = onNewConstraintClick, - showText = constraints.isEmpty(), - enabled = enabled, + if (parentConstraintCount > 0) { + Text( + modifier = Modifier + .padding(horizontal = 8.dp), + text = pluralStringResource( + R.plurals.home_groups_inherited_constraints, + parentConstraintCount, + parentConstraintCount, + ), + style = MaterialTheme.typography.labelMedium, ) } + + NewConstraintButton( + onClick = onNewConstraintClick, + showText = constraints.isEmpty(), + enabled = enabled, + ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt index 151051f500..11a7b1b197 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt @@ -16,8 +16,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.rounded.Add @@ -68,7 +66,6 @@ fun GroupRow( FlowRow( Modifier .fillMaxWidth() - .verticalScroll(rememberScrollState()) .animateContentSize(), horizontalArrangement = Arrangement.spacedBy(8.dp), maxLines = if (viewAllState) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/BackupFilePickerDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/BackupFilePickerDialog.kt new file mode 100644 index 0000000000..7fdb6b42ae --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/BackupFilePickerDialog.kt @@ -0,0 +1,127 @@ +package io.github.sds100.keymapper.base.home + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.FolderOpen +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.backup.BackupFileListItem +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.CustomDialog + +@Composable +fun BackupFilePickerDialog( + files: List, + canRequestFullAccess: Boolean, + onFileClick: (String) -> Unit, + onRequestFullAccessClick: () -> Unit, + onDismissRequest: () -> Unit, +) { + CustomDialog( + title = stringResource(R.string.home_import_choose_backup_dialog_title), + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.neg_cancel)) + } + }, + onDismissRequest = onDismissRequest, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + if (files.isEmpty()) { + Text( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + text = stringResource(R.string.home_import_no_backups_found_in_downloads), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn { + items(files, key = { it.uri }) { file -> + BackupFileListRow( + modifier = Modifier.fillMaxWidth(), + name = file.name, + onClick = { onFileClick(file.uri) }, + ) + } + } + } + + if (canRequestFullAccess) { + FilledTonalButton( + modifier = Modifier + .padding(horizontal = 8.dp) + .align(Alignment.CenterHorizontally), + onClick = onRequestFullAccessClick, + ) { + Text(stringResource(R.string.home_import_grant_full_access_button)) + } + } + } + } +} + +@Composable +private fun BackupFileListRow(modifier: Modifier = Modifier, name: String, onClick: () -> Unit) { + Surface(modifier = modifier, color = Color.Transparent) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon(imageVector = Icons.Outlined.FolderOpen, contentDescription = null) + Text(text = name, style = MaterialTheme.typography.bodyLarge) + } + } +} + +@Preview +@Composable +private fun PreviewBackupFilePickerDialog() { + KeyMapperTheme { + BackupFilePickerDialog( + files = listOf( + BackupFileListItem(uri = "content://0", name = "key_maps_20260101-120000.zip"), + BackupFileListItem(uri = "content://1", name = "key_maps_20260215-093000.zip"), + ), + canRequestFullAccess = true, + onFileClick = {}, + onRequestFullAccessClick = {}, + onDismissRequest = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewBackupFilePickerDialogEmpty() { + KeyMapperTheme { + BackupFilePickerDialog( + files = emptyList(), + canRequestFullAccess = false, + onFileClick = {}, + onRequestFullAccessClick = {}, + onDismissRequest = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt index 964825e123..85b6af1a55 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.github.sds100.keymapper.base.BaseMainActivity import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.keyevent.FixKeyEventActionBottomSheet import io.github.sds100.keymapper.base.backup.ImportExportState @@ -75,6 +76,7 @@ import io.github.sds100.keymapper.base.utils.ui.drawable import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.files.FileUtils +import io.github.sds100.keymapper.system.leanback.LeanbackUtils @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -92,7 +94,7 @@ fun HomeKeyMapListScreen( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val importFileLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri ?: return@rememberLauncherForActivityResult viewModel.onChooseImportFile(uri.toString()) @@ -105,6 +107,8 @@ fun HomeKeyMapListScreen( snackbarState = snackbarState, setIdleState = viewModel::setImportExportIdle, onConfirmImport = viewModel::onConfirmImport, + onChooseImportFile = viewModel::onChooseImportFile, + onRequestFullFileAccessClick = viewModel::onRequestFullFileAccessClick, ) if (viewModel.showSortBottomSheet) { @@ -161,6 +165,7 @@ fun HomeKeyMapListScreen( val helpUrl = stringResource(R.string.url_quick_start_guide) var keyMapListBottomPadding by remember { mutableStateOf(100.dp) } + val lazyListState = rememberLazyListState() HomeKeyMapListScreen( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -185,8 +190,22 @@ fun HomeKeyMapListScreen( listContent = { KeyMapList( modifier = Modifier.animateContentSize(), - lazyListState = rememberLazyListState(), + lazyListState = lazyListState, listItems = state.listItems, + header = { + KeyMapListHeader( + state = state.appBarState, + scrollBehavior = scrollBehavior, + onFixWarningClick = viewModel::onFixWarningClick, + onNewGroupClick = viewModel::onNewGroupClick, + onGroupClick = viewModel::onGroupClick, + onNewConstraintClick = viewModel::onNewGroupConstraintClick, + onRemoveConstraintClick = viewModel::onRemoveGroupConstraintClick, + onConstraintModeChanged = viewModel::onGroupConstraintModeChanged, + onFixConstraintClick = viewModel::onFixClick, + onKeyMapsEnabledChange = viewModel::onGroupKeyMapsEnabledChanged, + ) + }, footerText = stringResource(R.string.home_key_map_list_footer_text), isSelectable = state.appBarState is KeyMapAppBarState.Selecting, onClickKeyMap = viewModel::onKeyMapCardClick, @@ -206,26 +225,24 @@ fun HomeKeyMapListScreen( onSortClick = { viewModel.showSortBottomSheet = true }, onHelpClick = { uriHandler.openUriSafe(ctx, helpUrl) }, onExportClick = viewModel::onExportClick, - onImportClick = { importFileLauncher.launch(FileUtils.MIME_TYPE_ALL) }, + onImportClick = { + if (LeanbackUtils.isTvDevice(ctx)) { + viewModel.onImportClick() + } else { + importFileLauncher.launch(arrayOf(FileUtils.MIME_TYPE_ALL)) + } + }, onInputMethodPickerClick = viewModel::showInputMethodPicker, onTogglePausedClick = viewModel::onTogglePausedClick, - onFixWarningClick = viewModel::onFixWarningClick, onBackClick = { if (!viewModel.onBackClick()) { finishActivity() } }, onSelectAllClick = viewModel::onSelectAllClick, - onNewGroupClick = viewModel::onNewGroupClick, onRenameGroupClick = viewModel::onRenameGroupClick, onEditGroupNameClick = viewModel::onEditGroupNameClick, - onGroupClick = viewModel::onGroupClick, onDeleteGroupClick = viewModel::onDeleteGroupClick, - onNewConstraintClick = viewModel::onNewGroupConstraintClick, - onRemoveConstraintClick = viewModel::onRemoveGroupConstraintClick, - onConstraintModeChanged = viewModel::onGroupConstraintModeChanged, - onFixConstraintClick = viewModel::onFixClick, - onKeyMapsEnabledChange = viewModel::onGroupKeyMapsEnabledChanged, onReportBugClick = { showBugReportDialog = true }, @@ -249,8 +266,8 @@ fun HomeKeyMapListScreen( SelectionBottomSheet( modifier = Modifier.onSizeChanged { size -> - keyMapListBottomPadding = - ((size.height.dp / 2) - 100.dp).coerceAtLeast(0.dp) +// keyMapListBottomPadding = +// ((size.height.dp / 2) - 100.dp).coerceAtLeast(0.dp) }, enabled = selectionState.selectionCount > 0, groups = selectionState.groups, @@ -300,6 +317,8 @@ fun HandleImportExportState( snackbarState: SnackbarHostState, setIdleState: () -> Unit, onConfirmImport: (RestoreType) -> Unit, + onChooseImportFile: (String) -> Unit = {}, + onRequestFullFileAccessClick: () -> Unit = {}, ) { when (val state = state) { is ImportExportState.Error -> { @@ -327,16 +346,45 @@ fun HandleImportExportState( is ImportExportState.FinishedExport -> { snackbarState.currentSnackbarData?.dismiss() - LocalActivity.current?.let { - ShareUtils.shareFile( - it, + LocalActivity.current?.let { activity -> + val shared = ShareUtils.shareFile( + activity, state.uri.toUri(), packageName = LocalContext.current.packageName, ) + + // Fall back to a direct file picker if there is no app installed that + // can receive a shared file, for example on Android TV. + if (!shared) { + (activity as? BaseMainActivity)?.saveFileToUserChosenLocation( + state.uri.toUri(), + ) + } } setIdleState() } + is ImportExportState.FinishedExportToDownloads -> { + val text = + stringResource(R.string.home_export_finished_downloads_snackbar, state.fileName) + LaunchedEffect(state) { + snackbarState.currentSnackbarData?.dismiss() + snackbarState.showSnackbar(text, duration = SnackbarDuration.Short) + setIdleState() + } + } + + is ImportExportState.ChooseImportFileFromDownloads -> { + snackbarState.currentSnackbarData?.dismiss() + BackupFilePickerDialog( + files = state.files, + canRequestFullAccess = state.canRequestFullAccess, + onFileClick = onChooseImportFile, + onRequestFullAccessClick = onRequestFullFileAccessClick, + onDismissRequest = setIdleState, + ) + } + is ImportExportState.FinishedImport -> { val text = stringResource(R.string.home_importing_finished_snackbar) LaunchedEffect(state) { @@ -578,6 +626,7 @@ private fun PreviewSelectingKeyMaps() { KeyMapList( lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = 4), listItems = listState, + header = { KeyMapListHeader(state = appBarState) }, footerText = stringResource(R.string.home_key_map_list_footer_text), isSelectable = true, ) @@ -621,6 +670,7 @@ private fun PreviewKeyMapsRunning() { KeyMapList( lazyListState = rememberLazyListState(), listItems = listState, + header = { KeyMapListHeader(state = appBarState) }, footerText = stringResource(R.string.home_key_map_list_footer_text), isSelectable = false, ) @@ -657,6 +707,7 @@ private fun PreviewKeyMapsPaused() { KeyMapList( lazyListState = rememberLazyListState(), listItems = listState, + header = { KeyMapListHeader(state = appBarState) }, footerText = stringResource(R.string.home_key_map_list_footer_text), isSelectable = false, ) @@ -712,6 +763,7 @@ private fun PreviewKeyMapsWarnings() { KeyMapList( lazyListState = rememberLazyListState(), listItems = listState, + header = { KeyMapListHeader(state = appBarState) }, footerText = stringResource(R.string.home_key_map_list_footer_text), isSelectable = false, ) @@ -759,6 +811,45 @@ private fun PreviewKeyMapsWarningsEmpty() { KeyMapList( lazyListState = rememberLazyListState(), listItems = listState, + header = { KeyMapListHeader(state = appBarState) }, + footerText = stringResource(R.string.home_key_map_list_footer_text), + isSelectable = false, + + ) + }, + appBarContent = { + KeyMapListAppBar(state = appBarState) + }, + selectionBottomSheet = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(device = Devices.PIXEL) +@Composable +private fun PreviewKeyMapsLoading() { + val appBarState = KeyMapAppBarState.RootGroup( + subGroups = emptyList(), + warnings = emptyList(), + isPaused = true, + ) + + val listState = State.Loading + + KeyMapperTheme { + HomeKeyMapListScreen( + floatingActionButton = { + CollapsableFloatingActionButton( + showText = true, + text = stringResource(R.string.home_fab_new_key_map), + ) + }, + listContent = { + KeyMapList( + lazyListState = rememberLazyListState(), + listItems = listState, + header = { KeyMapListHeader(state = appBarState) }, footerText = stringResource(R.string.home_key_map_list_footer_text), isSelectable = false, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt index cd64514d4e..c895fca2e3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt @@ -5,18 +5,13 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.FastOutLinearInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -36,7 +31,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.HelpOutline import androidx.compose.material.icons.automirrored.rounded.Sort -import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Done @@ -62,16 +56,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -83,9 +74,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange @@ -99,17 +88,8 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.compose.LocalCustomColorsPalette import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.groups.DeleteGroupDialog -import io.github.sds100.keymapper.base.groups.GroupBreadcrumbRow -import io.github.sds100.keymapper.base.groups.GroupConstraintRow -import io.github.sds100.keymapper.base.groups.GroupListItemModel -import io.github.sds100.keymapper.base.groups.GroupRow -import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel -import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo -import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText import io.github.sds100.keymapper.base.utils.ui.compose.icons.Import import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons -import io.github.sds100.keymapper.base.utils.ui.drawable -import io.github.sds100.keymapper.common.utils.KMError import kotlinx.coroutines.launch @Composable @@ -122,204 +102,178 @@ fun KeyMapListAppBar( onSortClick: () -> Unit = {}, onHelpClick: () -> Unit = {}, onTogglePausedClick: () -> Unit = {}, - onFixWarningClick: (String) -> Unit = {}, onExportClick: () -> Unit = {}, onImportClick: () -> Unit = {}, onInputMethodPickerClick: () -> Unit = {}, onBackClick: () -> Unit = {}, onSelectAllClick: () -> Unit = {}, scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), - onNewGroupClick: () -> Unit = {}, - onGroupClick: (String?) -> Unit = {}, onRenameGroupClick: suspend (String) -> Boolean = { true }, onEditGroupNameClick: () -> Unit = {}, onDeleteGroupClick: () -> Unit = {}, - onNewConstraintClick: () -> Unit = {}, - onRemoveConstraintClick: (String) -> Unit = {}, - onConstraintModeChanged: (ConstraintMode) -> Unit = {}, - onFixConstraintClick: (KMError) -> Unit = {}, - onKeyMapsEnabledChange: (Boolean) -> Unit = {}, onReportBugClick: () -> Unit = {}, ) { BackHandler(onBack = onBackClick) // Use the class as the content key so the content is animated if the data inside the // same state class changes. - AnimatedContent(state, contentKey = { it::class }) { state -> - when (state) { - is KeyMapAppBarState.RootGroup -> RootGroupAppBar( - modifier = modifier, - state = state, - scrollBehavior = scrollBehavior, - onTogglePausedClick = onTogglePausedClick, - onFixWarningClick = onFixWarningClick, - onNewGroupClick = onNewGroupClick, - onGroupClick = onGroupClick, - navigationIcon = { - IconButton(onClick = onSortClick) { - Icon( - Icons.AutoMirrored.Rounded.Sort, - contentDescription = stringResource(R.string.home_app_bar_sort), - ) - } - }, - actions = { - var expandedDropdown by rememberSaveable { mutableStateOf(false) } - - AppBarActions( - onHelpClick, - onMenuClick = { expandedDropdown = true }, - dropdownMenuContent = { - RootGroupDropdownMenu( - expanded = expandedDropdown, - onSettingsClick = { - expandedDropdown = false - onSettingsClick() - }, - onAboutClick = { - expandedDropdown = false - onAboutClick() - }, - onExportClick = { - expandedDropdown = false - onExportClick() - }, - onImportClick = { - expandedDropdown = false - onImportClick() - }, - onInputMethodPickerClick = { - expandedDropdown = false - onInputMethodPickerClick() - }, - onReportBugClick = { - expandedDropdown = false - onReportBugClick() - }, - onDismissRequest = { expandedDropdown = false }, - ) - }, + when (state) { + is KeyMapAppBarState.RootGroup -> RootGroupAppBar( + modifier = modifier, + state = state, + scrollBehavior = scrollBehavior, + onTogglePausedClick = onTogglePausedClick, + navigationIcon = { + IconButton(onClick = onSortClick) { + Icon( + Icons.AutoMirrored.Rounded.Sort, + contentDescription = stringResource(R.string.home_app_bar_sort), ) - }, - ) + } + }, + actions = { + var expandedDropdown by rememberSaveable { mutableStateOf(false) } + + AppBarActions( + onHelpClick, + onMenuClick = { expandedDropdown = true }, + dropdownMenuContent = { + RootGroupDropdownMenu( + expanded = expandedDropdown, + onSettingsClick = { + expandedDropdown = false + onSettingsClick() + }, + onAboutClick = { + expandedDropdown = false + onAboutClick() + }, + onExportClick = { + expandedDropdown = false + onExportClick() + }, + onImportClick = { + expandedDropdown = false + onImportClick() + }, + onInputMethodPickerClick = { + expandedDropdown = false + onInputMethodPickerClick() + }, + onReportBugClick = { + expandedDropdown = false + onReportBugClick() + }, + onDismissRequest = { expandedDropdown = false }, + ) + }, + ) + }, + ) - is KeyMapAppBarState.Selecting -> SelectingAppBar( - modifier = modifier, - state = state, - onBackClick = onBackClick, - onSelectAllClick = onSelectAllClick, - ) + is KeyMapAppBarState.Selecting -> SelectingAppBar( + modifier = modifier, + state = state, + onBackClick = onBackClick, + onSelectAllClick = onSelectAllClick, + ) - is KeyMapAppBarState.ChildGroup -> { - val scope = rememberCoroutineScope() - val uniqueErrorText = stringResource(R.string.home_app_bar_group_name_unique_error) - var error: String? by rememberSaveable { mutableStateOf(null) } - var newName by remember { - mutableStateOf( - TextFieldValue( - state.groupName, - selection = TextRange(state.groupName.length), - ), - ) - } - var showDeleteGroupDialog by remember { mutableStateOf(false) } + is KeyMapAppBarState.ChildGroup -> { + val scope = rememberCoroutineScope() + val uniqueErrorText = stringResource(R.string.home_app_bar_group_name_unique_error) + var error: String? by rememberSaveable { mutableStateOf(null) } + var newName by remember { + mutableStateOf( + TextFieldValue( + state.groupName, + selection = TextRange(state.groupName.length), + ), + ) + } + var showDeleteGroupDialog by remember { mutableStateOf(false) } - LaunchedEffect(state.groupName) { - showDeleteGroupDialog = false - error = null - val endPosition = state.groupName.length + LaunchedEffect(state.groupName) { + showDeleteGroupDialog = false + error = null + val endPosition = state.groupName.length - if (state.isEditingGroupName) { - if (state.isNewGroup) { - newName = TextFieldValue() - } else { - newName = - TextFieldValue(state.groupName, selection = TextRange(endPosition)) - } + if (state.isEditingGroupName) { + if (state.isNewGroup) { + newName = TextFieldValue() } else { newName = TextFieldValue(state.groupName, selection = TextRange(endPosition)) } + } else { + newName = + TextFieldValue(state.groupName, selection = TextRange(endPosition)) } + } - if (showDeleteGroupDialog) { - DeleteGroupDialog( - groupName = state.groupName, - onDismissRequest = { showDeleteGroupDialog = false }, - onDeleteClick = onDeleteGroupClick, - ) - } - - ChildGroupAppBar( - modifier = modifier, - groupName = if (state.isEditingGroupName) { - newName - } else { - TextFieldValue(state.groupName) - }, - placeholder = state.groupName, - error = error, - onValueChange = { - newName = it - error = null - }, - onRenameClick = { - scope.launch { - if (!onRenameGroupClick(newName.text)) { - error = uniqueErrorText - } - } - }, - onBackClick = onBackClick, - onNewGroupClick = onNewGroupClick, - onEditClick = onEditGroupNameClick, - isEditingGroupName = state.isEditingGroupName, - subGroups = state.subGroups, - parentGroups = state.breadcrumbs, - onGroupClick = onGroupClick, - constraints = state.constraints, - constraintMode = state.constraintMode, - parentConstraintCount = state.parentConstraintCount, - onNewConstraintClick = onNewConstraintClick, - onRemoveConstraintClick = onRemoveConstraintClick, - onConstraintModeChanged = onConstraintModeChanged, - onFixConstraintClick = onFixConstraintClick, - keyMapsEnabled = state.keyMapsEnabled, - onKeyMapsEnabledChange = onKeyMapsEnabledChange, - actions = { - AnimatedVisibility(!state.isEditingGroupName) { - var expandedDropdown by rememberSaveable { mutableStateOf(false) } - - AppBarActions( - onHelpClick, - onMenuClick = { expandedDropdown = true }, - dropdownMenuContent = { - ChildGroupDropdownMenu( - expanded = expandedDropdown, - onSortClick = { - expandedDropdown = false - onSortClick() - }, - onSettingsClick = { - expandedDropdown = false - onSettingsClick() - }, - onAboutClick = { - expandedDropdown = false - onAboutClick() - }, - onDismissRequest = { expandedDropdown = false }, - onDeleteGroupClick = { - expandedDropdown = false - showDeleteGroupDialog = true - }, - ) - }, - ) - } - }, + if (showDeleteGroupDialog) { + DeleteGroupDialog( + groupName = state.groupName, + onDismissRequest = { showDeleteGroupDialog = false }, + onDeleteClick = onDeleteGroupClick, ) } + + ChildGroupAppBar( + modifier = modifier, + groupName = if (state.isEditingGroupName) { + newName + } else { + TextFieldValue(state.groupName) + }, + placeholder = state.groupName, + error = error, + onValueChange = { + newName = it + error = null + }, + onRenameClick = { + scope.launch { + if (!onRenameGroupClick(newName.text)) { + error = uniqueErrorText + } + } + }, + onBackClick = onBackClick, + onEditClick = onEditGroupNameClick, + isEditingGroupName = state.isEditingGroupName, + actions = { + AnimatedVisibility(!state.isEditingGroupName) { + var expandedDropdown by rememberSaveable { mutableStateOf(false) } + + AppBarActions( + onHelpClick, + onMenuClick = { expandedDropdown = true }, + dropdownMenuContent = { + ChildGroupDropdownMenu( + expanded = expandedDropdown, + onSortClick = { + expandedDropdown = false + onSortClick() + }, + onSettingsClick = { + expandedDropdown = false + onSettingsClick() + }, + onAboutClick = { + expandedDropdown = false + onAboutClick() + }, + onDismissRequest = { expandedDropdown = false }, + onDeleteGroupClick = { + expandedDropdown = false + showDeleteGroupDialog = true + }, + ) + }, + ) + } + }, + ) } } } @@ -343,72 +297,23 @@ private fun RootGroupAppBar( state: KeyMapAppBarState.RootGroup, scrollBehavior: TopAppBarScrollBehavior, onTogglePausedClick: () -> Unit, - onFixWarningClick: (String) -> Unit, - onNewGroupClick: () -> Unit, - onGroupClick: (String) -> Unit, navigationIcon: @Composable () -> Unit, actions: @Composable RowScope.() -> Unit, ) { - // This is taken from the AppBar color code. - val colorTransitionFraction by - remember(scrollBehavior) { - // derivedStateOf to prevent redundant recompositions when the content scrolls. - derivedStateOf { - val overlappingFraction = scrollBehavior.state.overlappedFraction - if (overlappingFraction > 0.01f) 1f else 0f - } - } - - val appBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors() - - val appBarContainerColor by animateColorAsState( - targetValue = lerp( - appBarColors.containerColor, - appBarColors.scrolledContainerColor, - FastOutLinearInEasing.transform(colorTransitionFraction), - ), - animationSpec = spring(stiffness = Spring.StiffnessMediumLow), - ) - - Column(modifier) { - CenterAlignedTopAppBar( - scrollBehavior = scrollBehavior, - title = { - AppBarStatus( - isPaused = state.isPaused, - warnings = state.warnings, - onTogglePausedClick = onTogglePausedClick, - ) - }, - navigationIcon = navigationIcon, - actions = actions, - colors = appBarColors, - ) - - AnimatedVisibility(visible = state.warnings.isNotEmpty()) { - // Use separate Surfaces so the animation doesn't jump when they both disappear - // going into selection mode. - Surface(color = appBarContainerColor) { - HomeWarningList( - modifier = Modifier.padding(bottom = 8.dp), - warnings = state.warnings, - onFixClick = onFixWarningClick, - ) - } - } - - Surface(color = appBarContainerColor) { - GroupRow( - modifier = Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - groups = state.subGroups, - onNewGroupClick = onNewGroupClick, - onGroupClick = onGroupClick, - isSubgroups = false, + CenterAlignedTopAppBar( + modifier = modifier, + scrollBehavior = scrollBehavior, + title = { + AppBarStatus( + isPaused = state.isPaused, + warnings = state.warnings, + onTogglePausedClick = onTogglePausedClick, ) - } - } + }, + navigationIcon = navigationIcon, + actions = actions, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors(), + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -423,161 +328,43 @@ private fun ChildGroupAppBar( onEditClick: () -> Unit = {}, onRenameClick: () -> Unit = {}, isEditingGroupName: Boolean = false, - subGroups: List, - parentGroups: List, - onNewGroupClick: () -> Unit = {}, - onGroupClick: (String?) -> Unit = {}, - constraints: List = emptyList(), - constraintMode: ConstraintMode, - parentConstraintCount: Int, - onNewConstraintClick: () -> Unit = {}, - onRemoveConstraintClick: (String) -> Unit = {}, - onConstraintModeChanged: (ConstraintMode) -> Unit = {}, - onFixConstraintClick: (KMError) -> Unit = {}, - keyMapsEnabled: SelectedKeyMapsEnabled?, - onKeyMapsEnabledChange: (Boolean) -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, ) { // Make custom top app bar because the height can not be set to fix the text field error in. - Column { - Surface( - modifier = modifier, - color = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Row( + Modifier + .windowInsetsPadding(TopAppBarDefaults.windowInsets) + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(vertical = 8.dp) + .height(intrinsicSize = IntrinsicSize.Min), + verticalAlignment = Alignment.Top, ) { - Column { - Row( - Modifier - .windowInsetsPadding(TopAppBarDefaults.windowInsets) - .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(vertical = 8.dp) - .height(intrinsicSize = IntrinsicSize.Min), - verticalAlignment = Alignment.Top, - ) { - IconButton(onClick = onBackClick) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.home_app_bar_pop_group), - ) - } - - GroupNameRow( - modifier = Modifier.weight(1f), - value = groupName, - onValueChange = onValueChange, - placeholder = placeholder, - onRenameClick = onRenameClick, - error = error, - isEditing = isEditingGroupName, - onEditClick = onEditClick, - ) - - AnimatedVisibility(visible = !isEditingGroupName) { - actions() - } - } - - GroupConstraintRow( - modifier = Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - constraints = constraints, - mode = constraintMode, - parentConstraintCount = parentConstraintCount, - onFixConstraintClick = onFixConstraintClick, - onNewConstraintClick = onNewConstraintClick, - onRemoveConstraintClick = onRemoveConstraintClick, - enabled = !isEditingGroupName, + IconButton(onClick = onBackClick) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.home_app_bar_pop_group), ) - - Spacer(Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - androidx.compose.animation.AnimatedVisibility( - visible = constraints.size > 1, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - RadioButtonText( - text = stringResource(R.string.constraint_mode_and), - isSelected = constraintMode == ConstraintMode.AND, - isEnabled = !isEditingGroupName, - onSelected = { - onConstraintModeChanged(ConstraintMode.AND) - }, - ) - - RadioButtonText( - text = stringResource(R.string.constraint_mode_or), - isSelected = constraintMode == ConstraintMode.OR, - isEnabled = !isEditingGroupName, - onSelected = { - onConstraintModeChanged(ConstraintMode.OR) - }, - ) - - VerticalDivider( - modifier = Modifier.height(24.dp), - color = MaterialTheme.colorScheme.onPrimaryContainer, - ) - } - } - - Spacer(Modifier.width(16.dp)) - - val text = when (keyMapsEnabled) { - SelectedKeyMapsEnabled.ALL -> stringResource( - R.string.home_enabled_key_maps_enabled, - ) - - SelectedKeyMapsEnabled.MIXED -> stringResource( - R.string.home_enabled_key_maps_mixed, - ) - - SelectedKeyMapsEnabled.NONE, null -> stringResource( - R.string.home_enabled_key_maps_disabled, - ) - } - - Switch( - checked = keyMapsEnabled == SelectedKeyMapsEnabled.ALL, - onCheckedChange = onKeyMapsEnabledChange, - enabled = keyMapsEnabled != null, - ) - - Spacer(Modifier.width(16.dp)) - - Text(text = text, style = MaterialTheme.typography.bodyMedium) - - Spacer(Modifier.width(16.dp)) - } } - } - Surface { - Column { - GroupBreadcrumbRow( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - groups = parentGroups, - onGroupClick = onGroupClick, - ) + GroupNameRow( + modifier = Modifier.weight(1f), + value = groupName, + onValueChange = onValueChange, + placeholder = placeholder, + onRenameClick = onRenameClick, + error = error, + isEditing = isEditingGroupName, + onEditClick = onEditClick, + ) - GroupRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - groups = subGroups, - onNewGroupClick = onNewGroupClick, - onGroupClick = onGroupClick, - enabled = !isEditingGroupName, - isSubgroups = true, - ) + AnimatedVisibility(visible = !isEditingGroupName) { + actions() } } } @@ -956,52 +743,6 @@ private fun ChildGroupDropdownMenu( } } -@Composable -private fun constraintsSampleList(): List { - val ctx = LocalContext.current - - return listOf( - ComposeChipModel.Normal( - id = "1", - text = "Device is locked", - icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), - ), - ComposeChipModel.Normal( - id = "2", - text = "Key Mapper is open", - icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), - ), - ComposeChipModel.Error( - id = "2", - text = "Key Mapper not found", - error = KMError.AppNotFound("io.github.sds100.keymapper"), - ), - ) -} - -@Composable -private fun groupSampleList(): List { - val ctx = LocalContext.current - - return listOf( - GroupListItemModel( - uid = "1", - name = "Lockscreen", - icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), - ), - GroupListItemModel( - uid = "2", - name = "Key Mapper", - icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), - ), - GroupListItemModel( - uid = "3", - name = "Key Mapper", - icon = null, - ), - ) -} - @OptIn(ExperimentalMaterial3Api::class) @Preview @Composable @@ -1058,12 +799,6 @@ private fun KeyMapsChildGroupEditingPreview() { placeholder = "Untitled group 23", error = stringResource(R.string.home_app_bar_group_name_unique_error), isEditingGroupName = true, - subGroups = emptyList(), - parentGroups = emptyList(), - constraints = emptyList(), - constraintMode = ConstraintMode.AND, - parentConstraintCount = 1, - keyMapsEnabled = SelectedKeyMapsEnabled.NONE, ) } } @@ -1112,12 +847,6 @@ private fun KeyMapsChildGroupErrorPreview() { placeholder = "Untitled group 23", error = stringResource(R.string.home_app_bar_group_name_unique_error), isEditingGroupName = true, - subGroups = emptyList(), - parentGroups = emptyList(), - constraints = emptyList(), - constraintMode = ConstraintMode.AND, - parentConstraintCount = 0, - keyMapsEnabled = null, ) } } @@ -1176,32 +905,6 @@ private fun HomeStateWarningsPreview() { } } -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun HomeStateWarningsDarkPreview() { - val warnings = listOf( - HomeWarningListItem( - id = "0", - text = stringResource(R.string.home_error_accessibility_service_is_disabled), - ), - HomeWarningListItem( - id = "1", - text = stringResource(R.string.home_error_is_battery_optimised), - ), - ) - - val state = - KeyMapAppBarState.RootGroup( - subGroups = emptyList(), - warnings = warnings, - isPaused = true, - ) - KeyMapperTheme(darkTheme = true) { - KeyMapListAppBar(state = state) - } -} - @OptIn(ExperimentalMaterial3Api::class) @Preview @Composable diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt new file mode 100644 index 0000000000..18ce75a488 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt @@ -0,0 +1,417 @@ +package io.github.sds100.keymapper.base.home + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +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.Lock +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.constraints.ConstraintMode +import io.github.sds100.keymapper.base.groups.GroupBreadcrumbRow +import io.github.sds100.keymapper.base.groups.GroupConstraintRow +import io.github.sds100.keymapper.base.groups.GroupListItemModel +import io.github.sds100.keymapper.base.groups.GroupRow +import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel +import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText +import io.github.sds100.keymapper.base.utils.ui.drawable +import io.github.sds100.keymapper.common.utils.KMError + +/** + * The warnings, groups, breadcrumbs and group constraints that are shown above the key maps. + * This is placed in the key map list rather than the app bar so that it scrolls away and does not + * permanently take up vertical space on small screens or in landscape. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KeyMapListHeader( + modifier: Modifier = Modifier, + state: KeyMapAppBarState, + scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + onFixWarningClick: (String) -> Unit = {}, + onNewGroupClick: () -> Unit = {}, + onGroupClick: (String?) -> Unit = {}, + onNewConstraintClick: () -> Unit = {}, + onRemoveConstraintClick: (String) -> Unit = {}, + onConstraintModeChanged: (ConstraintMode) -> Unit = {}, + onFixConstraintClick: (KMError) -> Unit = {}, + onKeyMapsEnabledChange: (Boolean) -> Unit = {}, +) { + // This is taken from the AppBar color code so the header is the same color as the app bar + // above it. + val colorTransitionFraction by + remember(scrollBehavior) { + // derivedStateOf to prevent redundant recompositions when the content scrolls. + derivedStateOf { + val overlappingFraction = scrollBehavior.state.overlappedFraction + if (overlappingFraction > 0.01f) 1f else 0f + } + } + + val appBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors() + + val appBarContainerColor by animateColorAsState( + targetValue = lerp( + appBarColors.containerColor, + appBarColors.scrolledContainerColor, + FastOutLinearInEasing.transform(colorTransitionFraction), + ), + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ) + + when (state) { + is KeyMapAppBarState.RootGroup -> RootGroupHeader( + modifier = modifier.fillMaxWidth(), + state = state, + containerColor = appBarContainerColor, + onFixWarningClick = onFixWarningClick, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + ) + + is KeyMapAppBarState.ChildGroup -> ChildGroupHeader( + modifier = modifier.fillMaxWidth(), + state = state, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + onNewConstraintClick = onNewConstraintClick, + onRemoveConstraintClick = onRemoveConstraintClick, + onConstraintModeChanged = onConstraintModeChanged, + onFixConstraintClick = onFixConstraintClick, + onKeyMapsEnabledChange = onKeyMapsEnabledChange, + ) + + // The groups and breadcrumbs are shown in the selection bottom sheet instead. + is KeyMapAppBarState.Selecting -> Spacer(modifier.fillMaxWidth()) + } +} + +@Composable +private fun RootGroupHeader( + modifier: Modifier = Modifier, + state: KeyMapAppBarState.RootGroup, + containerColor: Color, + onFixWarningClick: (String) -> Unit, + onNewGroupClick: () -> Unit, + onGroupClick: (String) -> Unit, +) { + Column(modifier) { + AnimatedVisibility(visible = state.warnings.isNotEmpty()) { + // Use separate Surfaces so the animation doesn't jump when they both disappear + // going into selection mode. + Surface(color = containerColor) { + HomeWarningList( + modifier = Modifier.padding(bottom = 8.dp), + warnings = state.warnings, + onFixClick = onFixWarningClick, + ) + } + } + + Surface(color = containerColor) { + GroupRow( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + groups = state.subGroups, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + isSubgroups = false, + ) + } + } +} + +@Composable +private fun ChildGroupHeader( + modifier: Modifier = Modifier, + state: KeyMapAppBarState.ChildGroup, + onNewGroupClick: () -> Unit, + onGroupClick: (String?) -> Unit, + onNewConstraintClick: () -> Unit, + onRemoveConstraintClick: (String) -> Unit, + onConstraintModeChanged: (ConstraintMode) -> Unit, + onFixConstraintClick: (KMError) -> Unit, + onKeyMapsEnabledChange: (Boolean) -> Unit, +) { + val enabled = !state.isEditingGroupName + + Column(modifier) { + // The constraints and the enabled switch are part of the app bar so they use the same + // color as it. + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Column { + GroupConstraintRow( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + constraints = state.constraints, + mode = state.constraintMode, + parentConstraintCount = state.parentConstraintCount, + onFixConstraintClick = onFixConstraintClick, + onNewConstraintClick = onNewConstraintClick, + onRemoveConstraintClick = onRemoveConstraintClick, + enabled = enabled, + ) + + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedVisibility(visible = state.constraints.size > 1) { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButtonText( + text = stringResource(R.string.constraint_mode_and), + isSelected = state.constraintMode == ConstraintMode.AND, + isEnabled = enabled, + onSelected = { + onConstraintModeChanged(ConstraintMode.AND) + }, + ) + + RadioButtonText( + text = stringResource(R.string.constraint_mode_or), + isSelected = state.constraintMode == ConstraintMode.OR, + isEnabled = enabled, + onSelected = { + onConstraintModeChanged(ConstraintMode.OR) + }, + ) + + VerticalDivider( + modifier = Modifier.height(24.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Spacer(Modifier.width(16.dp)) + + val text = when (state.keyMapsEnabled) { + SelectedKeyMapsEnabled.ALL -> stringResource( + R.string.home_enabled_key_maps_enabled, + ) + + SelectedKeyMapsEnabled.MIXED -> stringResource( + R.string.home_enabled_key_maps_mixed, + ) + + SelectedKeyMapsEnabled.NONE, null -> stringResource( + R.string.home_enabled_key_maps_disabled, + ) + } + + Switch( + checked = state.keyMapsEnabled == SelectedKeyMapsEnabled.ALL, + onCheckedChange = onKeyMapsEnabledChange, + enabled = state.keyMapsEnabled != null, + ) + + Spacer(Modifier.width(16.dp)) + + Text(text = text, style = MaterialTheme.typography.bodyMedium) + + Spacer(Modifier.width(16.dp)) + } + } + } + + Surface { + Column { + GroupBreadcrumbRow( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + groups = state.breadcrumbs, + onGroupClick = onGroupClick, + enabled = enabled, + ) + + GroupRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + groups = state.subGroups, + onNewGroupClick = onNewGroupClick, + onGroupClick = onGroupClick, + enabled = enabled, + isSubgroups = true, + ) + } + } + } +} + +@Composable +internal fun constraintsSampleList(): List { + val ctx = LocalContext.current + + return listOf( + ComposeChipModel.Normal( + id = "1", + text = "Device is locked", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + ComposeChipModel.Normal( + id = "2", + text = "Key Mapper is open", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + ComposeChipModel.Error( + id = "2", + text = "Key Mapper not found", + error = KMError.AppNotFound("io.github.sds100.keymapper"), + ), + ) +} + +@Composable +internal fun groupSampleList(): List { + val ctx = LocalContext.current + + return listOf( + GroupListItemModel( + uid = "1", + name = "Lockscreen", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + ), + GroupListItemModel( + uid = "2", + name = "Key Mapper", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + ), + GroupListItemModel( + uid = "3", + name = "Key Mapper", + icon = null, + ), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun RootGroupHeaderPreview() { + val state = KeyMapAppBarState.RootGroup( + subGroups = groupSampleList(), + warnings = listOf( + HomeWarningListItem( + id = "0", + text = stringResource(R.string.home_error_accessibility_service_is_disabled), + ), + HomeWarningListItem( + id = "1", + text = stringResource(R.string.home_error_is_battery_optimised), + ), + ), + isPaused = true, + ) + + KeyMapperTheme { + Surface { + KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun RootGroupHeaderNoWarningsPreview() { + val state = KeyMapAppBarState.RootGroup( + subGroups = groupSampleList(), + warnings = emptyList(), + isPaused = false, + ) + + KeyMapperTheme(darkTheme = true) { + Surface { + KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun ChildGroupHeaderPreview() { + val state = KeyMapAppBarState.ChildGroup( + groupName = "Lockscreen", + subGroups = groupSampleList(), + constraints = constraintsSampleList(), + parentConstraintCount = 1, + constraintMode = ConstraintMode.AND, + breadcrumbs = groupSampleList(), + isEditingGroupName = false, + isNewGroup = false, + keyMapsEnabled = SelectedKeyMapsEnabled.ALL, + ) + + KeyMapperTheme { + Surface { + KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun ChildGroupHeaderDarkPreview() { + val state = KeyMapAppBarState.ChildGroup( + groupName = "Lockscreen", + subGroups = emptyList(), + constraints = emptyList(), + parentConstraintCount = 0, + constraintMode = ConstraintMode.AND, + breadcrumbs = emptyList(), + isEditingGroupName = false, + isNewGroup = false, + keyMapsEnabled = SelectedKeyMapsEnabled.MIXED, + ) + + KeyMapperTheme(darkTheme = true) { + Surface { + KeyMapListHeader(modifier = Modifier.fillMaxWidth(), state = state) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt index 1ed0b69eaa..89321395b2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt @@ -3,6 +3,7 @@ package io.github.sds100.keymapper.base.home import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues @@ -34,12 +35,18 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.Placeholder @@ -71,6 +78,7 @@ fun KeyMapList( modifier: Modifier = Modifier, lazyListState: LazyListState = rememberLazyListState(), listItems: State>, + header: (@Composable () -> Unit)? = null, footerText: String? = stringResource(R.string.home_key_map_list_footer_text), isSelectable: Boolean = false, onClickKeyMap: (String) -> Unit = {}, @@ -80,35 +88,113 @@ fun KeyMapList( onTriggerErrorClick: (TriggerError) -> Unit = {}, bottomListPadding: Dp = 100.dp, ) { - when (listItems) { - is State.Loading -> { - Surface(modifier = modifier) { - LoadingList(modifier = Modifier.fillMaxSize()) - } - } + val haptics = LocalHapticFeedback.current + val density = LocalDensity.current + val itemSpacing = 8.dp + // The header is flush with the app bar because it is the same color as it. + val topPadding = if (header == null) 8.dp else 0.dp + val bottomPadding = 8.dp + + // The loading and empty states must fill the space left over by the header, like they would + // with a weight of 1f in a Column, so the header is measured. + var headerHeight by remember { mutableStateOf(0.dp) } + + Surface(modifier = modifier) { + BoxWithConstraints { + val remainingHeight = ( + maxHeight - headerHeight - topPadding - bottomListPadding - bottomPadding - + if (header == null) 0.dp else itemSpacing + ).coerceAtLeast(0.dp) + + // Wait for the header to be measured so the loading and empty states are not laid out + // a header too tall on the first frame and then jump into place. + val isRemainingHeightMeasured = header == null || headerHeight > 0.dp + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + contentPadding = PaddingValues(top = topPadding, bottom = bottomPadding), + verticalArrangement = Arrangement.spacedBy(itemSpacing), + ) { + // The header is in the list rather than the app bar so that it scrolls away and + // does not take up vertical space on small screens or in landscape. + if (header != null) { + item(key = "header") { + Box( + Modifier + .fillMaxWidth() + .onSizeChanged { + headerHeight = with(density) { it.height.toDp() } + }, + ) { + header() + } + } + } - is State.Data -> { - Surface(modifier = modifier) { - if (listItems.data.isEmpty()) { - EmptyKeyMapList( - modifier = Modifier - .fillMaxSize() - .padding(bottom = bottomListPadding), - ) - } else { - LoadedKeyMapList( - Modifier.fillMaxSize(), - lazyListState, - listItems.data, - footerText, - isSelectable, - onClickKeyMap, - onLongClickKeyMap, - onSelectedChange, - onFixClick, - onTriggerErrorClick, - bottomListPadding, - ) + when (listItems) { + is State.Loading -> { + if (isRemainingHeightMeasured) { + item(key = "loading") { + LoadingList( + Modifier + .fillMaxWidth() + .height(remainingHeight), + ) + } + } + } + + is State.Data -> { + if (listItems.data.isEmpty()) { + if (isRemainingHeightMeasured) { + item(key = "empty") { + EmptyKeyMapList( + Modifier + .fillMaxWidth() + .height(remainingHeight), + ) + } + } + } else { + items(listItems.data, key = { it.uid }) { model -> + KeyMapListItem( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + isSelectable = isSelectable, + model = model, + onClickKeyMap = { onClickKeyMap(model.content.uid) }, + onLongClickKeyMap = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClickKeyMap(model.content.uid) + }, + onSelectedChange = { onSelectedChange(model.content.uid, it) }, + onFixClick = onFixClick, + onTriggerErrorClick = onTriggerErrorClick, + ) + } + + if (footerText != null) { + item(key = "footer") { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + text = footerText, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + // Give some space at the end of the list so that the FAB doesn't block + // the items. + item(key = "bottom_padding") { + Spacer(Modifier.height(bottomListPadding)) + } + } + } } } } @@ -146,62 +232,6 @@ private fun EmptyKeyMapList(modifier: Modifier = Modifier) { } } -@Composable -private fun LoadedKeyMapList( - modifier: Modifier = Modifier, - lazyListState: LazyListState, - listItems: List, - footerText: String?, - isSelectable: Boolean, - onClickKeyMap: (String) -> Unit, - onLongClickKeyMap: (String) -> Unit, - onSelectedChange: (String, Boolean) -> Unit, - onFixClick: (KMError) -> Unit, - onTriggerErrorClick: (TriggerError) -> Unit, - bottomListPadding: Dp, -) { - val haptics = LocalHapticFeedback.current - - LazyColumn( - modifier = modifier, - state = lazyListState, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(listItems, key = { it.uid }) { model -> - KeyMapListItem( - modifier = Modifier.fillMaxWidth(), - isSelectable = isSelectable, - model = model, - onClickKeyMap = { onClickKeyMap(model.content.uid) }, - onLongClickKeyMap = { - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - onLongClickKeyMap(model.content.uid) - }, - onSelectedChange = { onSelectedChange(model.content.uid, it) }, - onFixClick = onFixClick, - onTriggerErrorClick = onTriggerErrorClick, - ) - } - - if (footerText != null) { - item { - Text( - modifier = Modifier.fillMaxWidth(), - text = footerText, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - - // Give some space at the end of the list so that the FAB doesn't block the items. - item { - Spacer(Modifier.height(bottomListPadding)) - } - } -} - val chipHeight = 28.dp @Composable @@ -479,33 +509,43 @@ private fun ActionConstraintChip(model: ComposeChipModel, onFixClick: (KMError) private fun getTriggerErrorMessage(error: TriggerError): String { return when (error) { TriggerError.DND_ACCESS_DENIED -> stringResource(R.string.trigger_error_dnd_access_denied) + TriggerError.CANT_DETECT_IN_PHONE_CALL -> stringResource( R.string.trigger_error_cant_detect_in_phone_call, ) + TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> stringResource( R.string.trigger_error_assistant_not_purchased, ) + TriggerError.DPAD_IME_NOT_SELECTED -> stringResource( R.string.trigger_error_dpad_ime_not_selected, ) + TriggerError.FLOATING_BUTTON_DELETED -> stringResource( R.string.trigger_error_floating_button_deleted, ) + TriggerError.FLOATING_BUTTONS_NOT_PURCHASED -> stringResource( R.string.trigger_error_floating_buttons_not_purchased, ) + TriggerError.PURCHASE_VERIFICATION_FAILED -> stringResource( R.string.trigger_error_product_verification_failed, ) + TriggerError.SYSTEM_BRIDGE_UNSUPPORTED -> stringResource( R.string.trigger_error_system_bridge_unsupported, ) + TriggerError.SYSTEM_BRIDGE_DISCONNECTED -> stringResource( R.string.trigger_error_system_bridge_disconnected, ) + TriggerError.EVDEV_DEVICE_NOT_FOUND -> stringResource( R.string.trigger_error_evdev_device_not_found, ) + TriggerError.MIGRATE_SCREEN_OFF_TRIGGER -> stringResource( R.string.trigger_error_migrate_screen_off_key_map, ) @@ -673,7 +713,11 @@ private fun sampleList(): List { @Composable private fun ListPreview() { KeyMapperTheme { - KeyMapList(modifier = Modifier.fillMaxSize(), listItems = State.Data(sampleList())) + KeyMapList( + modifier = Modifier.fillMaxSize(), + listItems = State.Data(sampleList()), + bottomListPadding = 100.dp, + ) } } @@ -685,6 +729,7 @@ private fun SelectableListPreview() { modifier = Modifier.fillMaxSize(), listItems = State.Data(sampleList()), isSelectable = true, + bottomListPadding = 100.dp, ) } } @@ -693,7 +738,11 @@ private fun SelectableListPreview() { @Composable private fun EmptyPreview() { KeyMapperTheme { - KeyMapList(modifier = Modifier.fillMaxSize(), listItems = State.Data(emptyList())) + KeyMapList( + modifier = Modifier.fillMaxSize(), + listItems = State.Data(emptyList()), + bottomListPadding = 100.dp, + ) } } @@ -701,6 +750,10 @@ private fun EmptyPreview() { @Composable private fun LoadingPreview() { KeyMapperTheme { - KeyMapList(modifier = Modifier.fillMaxSize(), listItems = State.Loading) + KeyMapList( + modifier = Modifier.fillMaxSize(), + listItems = State.Loading, + bottomListPadding = 100.dp, + ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt index 6a2100b5a0..9633aca76a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt @@ -6,7 +6,9 @@ import androidx.compose.runtime.setValue import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.ActionErrorSnapshot import io.github.sds100.keymapper.base.actions.keyevent.FixKeyEventActionDelegate +import io.github.sds100.keymapper.base.backup.BackupFileListItem import io.github.sds100.keymapper.base.backup.BackupRestoreMappingsUseCase +import io.github.sds100.keymapper.base.backup.ExportedBackupLocation import io.github.sds100.keymapper.base.backup.ImportExportState import io.github.sds100.keymapper.base.backup.RestoreType import io.github.sds100.keymapper.base.constraints.ConstraintErrorSnapshot @@ -658,7 +660,7 @@ class KeyMapListViewModel( val selectedIds = selectionState.selectedIds listKeyMaps.backupKeyMaps(*selectedIds.toTypedArray()).onSuccess { - _importExportState.value = ImportExportState.FinishedExport(it) + _importExportState.value = it.toImportExportState() }.onFailure { _importExportState.value = ImportExportState.Error(it.getFullMessage(this@KeyMapListViewModel)) @@ -745,7 +747,7 @@ class KeyMapListViewModel( _importExportState.value = ImportExportState.Exporting backupRestore.backupEverything().onSuccess { - _importExportState.value = ImportExportState.FinishedExport(it) + _importExportState.value = it.toImportExportState() }.onFailure { _importExportState.value = ImportExportState.Error(it.getFullMessage(this@KeyMapListViewModel)) @@ -753,6 +755,11 @@ class KeyMapListViewModel( } } + private fun ExportedBackupLocation.toImportExportState(): ImportExportState = when (this) { + is ExportedBackupLocation.PublicUri -> ImportExportState.FinishedExport(uri) + is ExportedBackupLocation.Downloads -> ImportExportState.FinishedExportToDownloads(fileName) + } + fun onChooseImportFile(uri: String) { coroutineScope.launch { backupRestore.getKeyMapCountInBackup(uri).onSuccess { @@ -764,6 +771,31 @@ class KeyMapListViewModel( } } + /** + * Show a list of backups found in Downloads to pick from, for a device with no usable + * system file picker (Android TV). + */ + fun onImportClick() { + coroutineScope.launch { + backupRestore.getDownloadedBackups().onSuccess { files -> + _importExportState.value = ImportExportState.ChooseImportFileFromDownloads( + files = files.map { + BackupFileListItem(uri = it.uri, name = it.name ?: it.uri) + }, + canRequestFullAccess = backupRestore.canRequestFullFileAccess(), + ) + }.onFailure { + _importExportState.value = + ImportExportState.Error(it.getFullMessage(this@KeyMapListViewModel)) + } + } + } + + fun onRequestFullFileAccessClick() { + backupRestore.requestFullFileAccess() + setImportExportIdle() + } + fun onConfirmImport(restoreType: RestoreType) { val state = _importExportState.value as? ImportExportState.ConfirmImport state ?: return diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt index 684c8a6e81..351dc92631 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt @@ -4,7 +4,9 @@ import android.database.sqlite.SQLiteConstraintException import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.backup.BackupManager import io.github.sds100.keymapper.base.backup.BackupManagerImpl +import io.github.sds100.keymapper.base.backup.BackupRestoreMappingsUseCase import io.github.sds100.keymapper.base.backup.BackupUtils +import io.github.sds100.keymapper.base.backup.ExportedBackupLocation import io.github.sds100.keymapper.base.constraints.Constraint import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.constraints.ConstraintEntityMapper @@ -19,7 +21,6 @@ import io.github.sds100.keymapper.base.keymaps.KeyMapEntityMapper import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.State -import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.common.utils.dataOrNull import io.github.sds100.keymapper.data.entities.GroupEntity import io.github.sds100.keymapper.data.repositories.FloatingButtonRepository @@ -50,6 +51,7 @@ class ListKeyMapsUseCaseImpl @Inject constructor( private val floatingButtonRepository: FloatingButtonRepository, private val fileAdapter: FileAdapter, private val backupManager: BackupManager, + private val backupRestoreMappingsUseCase: BackupRestoreMappingsUseCase, private val resourceProvider: ResourceProvider, displayKeyMapUseCase: DisplayKeyMapUseCase, ) : ListKeyMapsUseCase, @@ -363,18 +365,18 @@ class ListKeyMapsUseCaseImpl @Inject constructor( keyMapRepository.duplicate(*uid) } - override suspend fun backupKeyMaps(vararg uid: String): KMResult { + override suspend fun backupKeyMaps(vararg uid: String): KMResult { val fileName = BackupUtils.createBackupFileName() // Share in private files so the share sheet can show the file name. This is some quirk // of the storage access framework https://issuetracker.google.com/issues/268079113. // Saving it directly to Downloads with the MediaStore returns a content URI // that only contains a numerical ID, not the file name. - return fileAdapter.getPrivateFile("${BackupManagerImpl.BACKUP_DIR}/$fileName").let { file -> - file.createFile() - backupManager.backupKeyMaps(file, uid.asList()) - Success(fileAdapter.getPublicUriForPrivateFile(file)) - } + val file = fileAdapter.getPrivateFile("${BackupManagerImpl.BACKUP_DIR}/$fileName") + file.createFile() + backupManager.backupKeyMaps(file, uid.asList()) + + return backupRestoreMappingsUseCase.exportToUserAccessibleLocation(file, fileName) } } @@ -403,5 +405,5 @@ interface ListKeyMapsUseCase : DisplayKeyMapUseCase { fun enableKeyMap(vararg uid: String) fun disableKeyMap(vararg uid: String) fun duplicateKeyMap(vararg uid: String) - suspend fun backupKeyMaps(vararg uid: String): KMResult + suspend fun backupKeyMaps(vararg uid: String): KMResult } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTipDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTipDelegate.kt index d3ab87eed6..82eba88058 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTipDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/OnboardingTipDelegate.kt @@ -5,7 +5,10 @@ import dagger.hilt.android.scopes.ViewModelScoped import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.Action import io.github.sds100.keymapper.base.actions.ActionData +import io.github.sds100.keymapper.base.actions.ActionUtils import io.github.sds100.keymapper.base.actions.ConfigActionsUseCase +import io.github.sds100.keymapper.base.actions.keyevent.getDedicatedKeyCodeAction +import io.github.sds100.keymapper.base.onboarding.OnboardingTipDelegateImpl.Companion.KEY_CODE_DEDICATED_ACTION_TIP_ID import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCase import io.github.sds100.keymapper.base.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey @@ -56,6 +59,7 @@ class OnboardingTipDelegateImpl @Inject constructor( const val SCREEN_PINNING_TIP_ID = "screen_pinning_tip" const val IME_DETECTION_TIP_ID = "ime_detection_tip" const val RINGER_MODE_TIP_ID = "ringer_mode_tip" + const val KEY_CODE_DEDICATED_ACTION_TIP_ID = "key_code_dedicated_action_tip" } override val triggerTip: MutableStateFlow = MutableStateFlow(null) @@ -101,6 +105,13 @@ class OnboardingTipDelegateImpl @Inject constructor( false, ) + /** + * The uid and suggested replacement of the action that [KEY_CODE_DEDICATED_ACTION_TIP_ID] + * is currently being shown for. This tip is not dismissable so it doesn't need a persisted + * "shown" preference like the other tips. + */ + private var keyCodeTipTarget: Pair? = null + init { viewModelScope.launch { configTriggerUseCase.keyMap @@ -152,6 +163,15 @@ class OnboardingTipDelegateImpl @Inject constructor( navigate("volume_buttons_expert_mode_tip", NavDestination.ExpertMode) } } + + KEY_CODE_DEDICATED_ACTION_TIP_ID -> { + val (actionUid, dedicatedAction) = keyCodeTipTarget ?: return + + // Remove the action and add it again. Do not replace the data so any flags + // such as repeat not retained from the key event action. + configActionsUseCase.removeAction(actionUid) + configActionsUseCase.addAction(dedicatedAction) + } } } @@ -338,9 +358,7 @@ class OnboardingTipDelegateImpl @Inject constructor( } } - if (hasRingerModeAction && - !shownRingerModeTip - ) { + if (hasRingerModeAction && !shownRingerModeTip) { val tip = OnboardingTipModel( id = RINGER_MODE_TIP_ID, title = getString(R.string.tip_ringer_mode_title), @@ -355,6 +373,45 @@ class OnboardingTipDelegateImpl @Inject constructor( }, ) actionsTip.value = tip + return + } + + val keyCodeAction = actionList.firstNotNullOfOrNull { action -> + val data = action.data + if (data is ActionData.InputKeyEvent && data.metaState == 0 && data.device == null) { + getDedicatedKeyCodeAction(data.keyCode)?.let { dedicatedAction -> + action.uid to dedicatedAction + } + } else { + null + } + } + + if (keyCodeAction != null) { + keyCodeTipTarget = keyCodeAction + val dedicatedActionTitle = getString(ActionUtils.getTitle(keyCodeAction.second.id)) + + actionsTip.value = OnboardingTipModel( + id = KEY_CODE_DEDICATED_ACTION_TIP_ID, + title = getString(R.string.tip_key_code_dedicated_action_title), + message = getString( + R.string.tip_key_code_dedicated_action_text, + dedicatedActionTitle, + ), + isDismissable = false, + buttonText = getString( + R.string.tip_key_code_dedicated_action_button, + dedicatedActionTitle, + ), + ) + } else { + keyCodeTipTarget = null + + // Don't clobber another tip, e.g. the ringer mode one, that's still waiting to be + // dismissed by the user. + if (actionsTip.value?.id == KEY_CODE_DEDICATED_ACTION_TIP_ID) { + actionsTip.value = null + } } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/TipCard.kt b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/TipCard.kt index 3956cf757f..649cba7891 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/onboarding/TipCard.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/onboarding/TipCard.kt @@ -47,7 +47,7 @@ fun TipCard( Box( modifier = Modifier.fillMaxWidth(), ) { - Column { + Column(modifier = Modifier.fillMaxWidth()) { Spacer(modifier = Modifier.height(16.dp)) Row( modifier = Modifier.padding(start = 16.dp, end = 48.dp), @@ -127,3 +127,15 @@ private fun TipCardPreview() { ) } } + +@Preview(widthDp = 300) +@Composable +private fun TipCardPreviewWide() { + KeyMapperTheme { + TipCard( + title = "Tip Title", + message = "Short message", + buttonText = "Button", + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt index b4eb895661..fa3e7d9a7a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt @@ -511,6 +511,7 @@ private fun SortBottomSheetContentPreview() { SortFieldOrder(SortField.ACTIONS, SortOrder.ASCENDING), SortFieldOrder(SortField.CONSTRAINTS, SortOrder.DESCENDING), SortFieldOrder(SortField.OPTIONS, SortOrder.NONE), + SortFieldOrder(SortField.ENABLED, SortOrder.NONE), ) KeyMapperTheme { @@ -539,6 +540,7 @@ private fun SortBottomSheetPreview() { SortFieldOrder(SortField.ACTIONS, SortOrder.ASCENDING), SortFieldOrder(SortField.CONSTRAINTS, SortOrder.DESCENDING), SortFieldOrder(SortField.OPTIONS, SortOrder.NONE), + SortFieldOrder(SortField.ENABLED, SortOrder.NONE), ) var size by remember { mutableIntStateOf(0) } @@ -573,5 +575,6 @@ private fun sortFieldText(sortField: SortField): String { SortField.ACTIONS -> stringResource(R.string.sort_bottom_sheet_actions) SortField.CONSTRAINTS -> stringResource(R.string.sort_bottom_sheet_constraints) SortField.OPTIONS -> stringResource(R.string.sort_bottom_sheet_options) + SortField.ENABLED -> stringResource(R.string.sort_bottom_sheet_enabled) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortField.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortField.kt index aac5250d86..0a3d05b42d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortField.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortField.kt @@ -8,4 +8,5 @@ enum class SortField { ACTIONS, CONSTRAINTS, OPTIONS, + ENABLED, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortKeyMapsUseCase.kt index c23c197d36..e878b9ba19 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortKeyMapsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortKeyMapsUseCase.kt @@ -4,6 +4,7 @@ import io.github.sds100.keymapper.base.keymaps.DisplayKeyMapUseCase import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.sorting.comparators.KeyMapActionsComparator import io.github.sds100.keymapper.base.sorting.comparators.KeyMapConstraintsComparator +import io.github.sds100.keymapper.base.sorting.comparators.KeyMapEnabledComparator import io.github.sds100.keymapper.base.sorting.comparators.KeyMapOptionsComparator import io.github.sds100.keymapper.base.sorting.comparators.KeyMapTriggerComparator import io.github.sds100.keymapper.data.Keys @@ -80,6 +81,7 @@ class SortKeyMapsUseCaseImpl @Inject constructor( ) SortField.OPTIONS -> KeyMapOptionsComparator(reverseOrder) + SortField.ENABLED -> KeyMapEnabledComparator(reverseOrder) } } @@ -89,6 +91,7 @@ class SortKeyMapsUseCaseImpl @Inject constructor( SortFieldOrder(SortField.ACTIONS), SortFieldOrder(SortField.CONSTRAINTS), SortFieldOrder(SortField.OPTIONS), + SortFieldOrder(SortField.ENABLED), ) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapEnabledComparator.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapEnabledComparator.kt new file mode 100644 index 0000000000..e96149603c --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapEnabledComparator.kt @@ -0,0 +1,27 @@ +package io.github.sds100.keymapper.base.sorting.comparators + +import io.github.sds100.keymapper.base.keymaps.KeyMap + +class KeyMapEnabledComparator( + /** + * Each comparator is reversed separately instead of the entire key map list + * and Comparator.reversed() requires API level 24 so use a custom reverse field. + */ + private val reverse: Boolean = false, +) : Comparator { + override fun compare(keyMap: KeyMap?, otherKeyMap: KeyMap?): Int { + if (keyMap == null || otherKeyMap == null) { + return 0 + } + + val result = compareValuesBy(keyMap, otherKeyMap) { !it.isEnabled } + + return invertIfReverse(result) + } + + private fun invertIfReverse(result: Int) = if (reverse) { + result * -1 + } else { + result + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityServiceUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityServiceUtils.kt index 900f46c2bf..027cba0120 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityServiceUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityServiceUtils.kt @@ -3,6 +3,13 @@ package io.github.sds100.keymapper.base.system.accessibility import android.os.Build import android.view.accessibility.AccessibilityNodeInfo +/** + * How many nodes to check when looking for the node that can perform an action on behalf + * of another node. This is only a safety net because the search normally stops at the + * scrolling container. + */ +private const val MAX_ACTION_TARGET_DEPTH = 10 + /** * @return The node to find. Returns null if the node doesn't match the predicate */ @@ -26,6 +33,44 @@ fun AccessibilityNodeInfo?.findNodeRecursively( return null } +/** + * Find the node that can perform [action] on behalf of this node. + * + * The node with the text or content description is often not the node that handles the + * interaction. In a list row the text usually sits in a child of the container that + * handles the click. Search up the tree for that container rather than assuming where it + * is, because the layouts change subtly between Android devices and ROMs. + * + * @return The node to perform the action on, or null if there isn't one. + */ +fun AccessibilityNodeInfo.findActionTarget( + action: Int, + maxDepth: Int = MAX_ACTION_TARGET_DEPTH, +): AccessibilityNodeInfo? { + var node: AccessibilityNodeInfo? = this + var depth = 0 + + while (node != null && depth <= maxDepth) { + if (node.supportsAction(action)) { + return node + } + + // Stop at the scrolling container because performing the action on the whole + // list would not do what the caller wants. + if (node.isScrollable) { + return null + } + + node = node.parent + depth++ + } + + return null +} + +private fun AccessibilityNodeInfo.supportsAction(action: Int): Boolean = + actionList.any { it.id == action } + fun AccessibilityNodeInfo.toModel(): AccessibilityNodeModel = AccessibilityNodeModel( packageName = packageName?.toString(), contentDescription = contentDescription?.toString(), diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt index 8e1af05ff5..88ce39f6b5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt @@ -568,7 +568,18 @@ abstract class BaseAccessibilityService : val (action, extras) = performAction(node.toModel()) ?: return Success(Unit) - node.performAction(action, bundleOf(*extras.toList().toTypedArray())) + // The node that matches is often not the node that handles the click, so find + // the node that can actually perform it. Only do this for clicks because + // performing an action like setting text on a different node would be wrong. + val targetNode = if (action == AccessibilityNodeInfo.ACTION_CLICK || + action == AccessibilityNodeInfo.ACTION_LONG_CLICK + ) { + node.findActionTarget(action) ?: node + } else { + node + } + + targetNode.performAction(action, bundleOf(*extras.toList().toTypedArray())) node.recycle() return Success(Unit) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityServiceController.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityServiceController.kt index 4ba31e2b9f..40e4b2d6ce 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityServiceController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityServiceController.kt @@ -154,10 +154,11 @@ abstract class BaseAccessibilityServiceController( private var serviceFlags: MutableStateFlow = MutableStateFlow(initialServiceFlags) /** - * FEEDBACK_GENERIC is for some reason required on Android 8.0 to get accessibility events. + * Feedback is required to get accessibility events. This used to use + * FEEDBACK_GENERIC but it broke the DPAD_CENTER button on Android TV. See issue #2210. */ private var serviceFeedbackType: MutableStateFlow = - MutableStateFlow(AccessibilityServiceInfo.FEEDBACK_GENERIC) + MutableStateFlow(AccessibilityServiceInfo.FEEDBACK_HAPTIC) val serviceEventTypes: MutableStateFlow = MutableStateFlow(AccessibilityEvent.TYPE_WINDOWS_CHANGED) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentViewModel.kt index 1473281ba6..c8ea64c986 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentViewModel.kt @@ -233,6 +233,8 @@ class ConfigIntentViewModel @Inject constructor( private val _returnResult = MutableSharedFlow() val returnResult = _returnResult.asSharedFlow() + private var isResultLoaded = false + fun setActivityTargetChecked(isChecked: Boolean) { if (isChecked) { target.value = IntentTarget.ACTIVITY @@ -402,6 +404,13 @@ class ConfigIntentViewModel @Inject constructor( } fun loadResult(result: ConfigIntentResult) { + // Only apply the initial argument once so that recreating the screen does not overwrite + // the user's edits with the original value. See issue #2160. + if (isResultLoaded) { + return + } + isResultLoaded = true + val intent = Intent.parseUri(result.uri, 0) description.value = result.description @@ -439,21 +448,37 @@ class ConfigIntentViewModel @Inject constructor( val extraType = when (value) { is Boolean -> BoolExtraType + is BooleanArray -> BoolArrayExtraType + is Int -> IntExtraType + is IntArray -> IntArrayExtraType + is Long -> LongExtraType + is LongArrayExtraType -> LongArrayExtraType + is Byte -> ByteExtraType + is ByteArrayExtraType -> ByteArrayExtraType + is Double -> DoubleExtraType + is DoubleArray -> DoubleArrayExtraType + is Float -> FloatExtraType + is FloatArray -> FloatArrayExtraType + is Short -> ShortExtraType + is ShortArray -> ShortArrayExtraType + is String -> StringExtraType + is Array<*> -> StringArrayExtraType + else -> throw IllegalArgumentException( "Don't know how to convert this extra (${value.javaClass.name}) to an IntentExtraType", ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt index 9fce955b46..ef591f2a75 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt @@ -10,9 +10,9 @@ import android.os.Build import android.provider.Settings import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat +import androidx.core.net.toUri import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider @@ -21,6 +21,7 @@ import io.github.sds100.keymapper.base.utils.ui.str import io.github.sds100.keymapper.common.BuildConfigProvider import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.system.DeviceAdmin +import io.github.sds100.keymapper.system.leanback.LeanbackUtils import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapterImpl import io.github.sds100.keymapper.system.permissions.AndroidPermissionAdapter import io.github.sds100.keymapper.system.permissions.Permission @@ -35,6 +36,7 @@ import splitties.alertdialog.appcompat.okButton import splitties.alertdialog.appcompat.positiveButton import splitties.alertdialog.appcompat.titleResource import splitties.alertdialog.material.materialAlertDialog +import timber.log.Timber class RequestPermissionDelegate( private val activity: AppCompatActivity, @@ -130,6 +132,56 @@ class RequestPermissionDelegate( } Permission.READ_LOGS -> permissionAdapter.grant(Manifest.permission.READ_LOGS) + + Permission.ACCESS_LOCAL_NETWORK -> if (Build.VERSION.SDK_INT >= + Build.VERSION_CODES.CINNAMON_BUN + ) { + requestPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } + + Permission.MANAGE_EXTERNAL_STORAGE -> requestManageExternalStorage() + } + } + + private fun requestManageExternalStorage() { + if (showDialogs) { + activity.materialAlertDialog { + titleResource = R.string.dialog_title_manage_external_storage + messageResource = R.string.dialog_message_manage_external_storage + + positiveButton(R.string.pos_grant_access) { + showManageExternalStorageSystemSettings() + } + + negativeButton(R.string.neg_cancel) { it.cancel() } + + show() + } + } else { + showManageExternalStorageSystemSettings() + } + } + + private fun showManageExternalStorageSystemSettings() { + val intent = Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse("package:${buildConfigProvider.packageName}"), + ) + + try { + startActivityForResultLauncher.launch(intent) + } catch (e: ActivityNotFoundException) { + try { + startActivityForResultLauncher.launch( + Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION), + ) + } catch (e: ActivityNotFoundException) { + Toast.makeText( + activity, + R.string.error_manage_external_storage_activity_not_found, + Toast.LENGTH_LONG, + ).show() + } } } @@ -255,7 +307,6 @@ class RequestPermissionDelegate( } } - @RequiresApi(Build.VERSION_CODES.M) private fun requestIgnoreBatteryOptimisations() { if (showDialogs) { activity.materialAlertDialog { @@ -263,7 +314,7 @@ class RequestPermissionDelegate( messageResource = R.string.dialog_message_disable_battery_optimisation positiveButton(R.string.pos_turn_off_stock_battery_optimisation) { - showBatteryOptimisationExemptionSystemDialog() + requestBatteryOptimisationExemption() } negativeButton(R.string.neg_cancel) { it.cancel() } @@ -278,20 +329,70 @@ class RequestPermissionDelegate( show() } } else { - showBatteryOptimisationExemptionSystemDialog() + requestBatteryOptimisationExemption() + } + } + + private fun requestBatteryOptimisationExemption() { + // Android TV devices fail silently when launching the dialog and activity so launch + // the activity with a manual intent. + if (LeanbackUtils.isTvDevice(activity)) { + val intent = Intent(Settings.ACTION_APPLICATION_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + try { + activity.startActivity(intent) + + Toast.makeText( + activity, + R.string.toast_tv_find_special_app_access, + Toast.LENGTH_LONG, + ).show() + } catch (e: ActivityNotFoundException) { + Timber.e(e, "Launch TV App settings failed") + + Toast.makeText( + activity, + R.string.toast_tv_can_not_find_special_app_access, + Toast.LENGTH_LONG, + ).show() + } + } else { + if (!showBatteryOptimisationExemptionSystemDialog()) { + launchBatteryOptimisationActivity() + } } } - @RequiresApi(Build.VERSION_CODES.M) - private fun showBatteryOptimisationExemptionSystemDialog() { + private fun showBatteryOptimisationExemptionSystemDialog(): Boolean { try { val intent = Intent( Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - Uri.parse("package:${buildConfigProvider.packageName}"), + "package:${buildConfigProvider.packageName}".toUri(), ) activity.startActivity(intent) + + return true } catch (e: ActivityNotFoundException) { + Timber.w(e, "Request battery optimisation exemption dialog failed") + return false + } + } + + private fun launchBatteryOptimisationActivity() { + try { + activity.startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + + Toast.makeText( + activity, + R.string.toast_find_keymapper_in_battery_optimisation_list, + Toast.LENGTH_LONG, + ).show() + } catch (e: ActivityNotFoundException) { + Timber.w(e, "Request battery optimisation exemption activity failed") + Toast.makeText( activity, R.string.error_battery_optimisation_activity_not_found, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt index 8518fe3eee..d0dd8793ea 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt @@ -74,6 +74,12 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { Permission.READ_LOGS -> R.string.error_read_logs_permission_denied + + Permission.ACCESS_LOCAL_NETWORK -> + R.string.error_local_network_permission_denied + + Permission.MANAGE_EXTERNAL_STORAGE -> + R.string.error_action_requires_manage_external_storage_permission } resourceProvider.getString(resId) @@ -383,6 +389,12 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { KMError.EnableImeFailed -> resourceProvider.getString(R.string.error_failed_to_enable_ime) + KMError.CycleImeSubtypeFailed -> + resourceProvider.getString(R.string.error_failed_to_cycle_ime_subtype) + + KMError.NotEnoughInputMethodSubtypes -> + resourceProvider.getString(R.string.error_not_enough_ime_subtypes) + KMError.NoCameraApp -> resourceProvider.getString(R.string.error_no_camera_app) @@ -455,6 +467,9 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { KMError.UnknownIOError -> resourceProvider.getString(R.string.error_io_error) + KMError.StarterFilesUnavailable -> + resourceProvider.getString(R.string.error_starter_files_unavailable) + KMError.ShizukuNotStarted -> resourceProvider.getString(R.string.error_shizuku_not_started) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ShareUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ShareUtils.kt index 50d6704825..255ea258af 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ShareUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ShareUtils.kt @@ -65,10 +65,26 @@ object ShareUtils { } } - fun shareFile(ctx: Context, file: Uri, packageName: String) { - try { - val type = ctx.contentResolver.getType(file) + /** + * @return whether a share target was found and the share sheet was shown. This is false + * if there is no app installed that can receive a shared file, for example on Android TV, + * so the caller can fall back to a direct file picker instead. + */ + fun shareFile(ctx: Context, file: Uri, packageName: String): Boolean { + val type = ctx.contentResolver.getType(file) + + // Check for a real share target before showing the chooser because on Android TV + // the launcher intercepts unresolvable ACTION_SEND intents and shows its own + // "not supported" toast without throwing ActivityNotFoundException back to us. + val hasShareTarget = ctx.packageManager + .queryIntentActivities(Intent(Intent.ACTION_SEND).setType(type), 0) + .isNotEmpty() + + if (!hasShareTarget) { + return false + } + return try { ShareCompat.IntentBuilder(ctx) .setType(type) .setStream(file) @@ -101,7 +117,9 @@ object ShareUtils { ctx.startActivity(intent) } + true } catch (_: ActivityNotFoundException) { + false } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index 6aae4a0db8..4849afedb6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -46,6 +46,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { const val ID_LOG = "log" const val ID_ADVANCED_TRIGGERS = "advanced_triggers" const val ID_GET_EVENT = "get_event" + const val ID_XIAOMI_OPTIMIZATION = "xiaomi_optimization" } @Serializable @@ -217,4 +218,9 @@ abstract class NavDestination(val isCompose: Boolean = false) { data object GetEvent : NavDestination(isCompose = true) { override val id: String = ID_GET_EVENT } + + @Serializable + data object XiaomiOptimization : NavDestination(isCompose = true) { + override val id: String = ID_XIAOMI_OPTIMIZATION + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt index 7c2c6772f5..185f90c1f6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.base.utils.ui.compose import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -86,6 +87,9 @@ fun SliderOptionText( Row(verticalAlignment = Alignment.CenterVertically) { val interactionSource = remember { MutableInteractionSource() } + + val isDragged: Boolean by interactionSource.collectIsDraggedAsState() + Slider( modifier = Modifier.weight(1f), value = value, @@ -104,7 +108,8 @@ fun SliderOptionText( Spacer(modifier = Modifier.width(8.dp)) ElevatedButton(onClick = { showDialog = true }) { - val text = if (value == defaultValue) { + // Do not show the text when dragging because it popping in/out moves the slider + val text = if (value == defaultValue && !isDragged) { stringResource(R.string.slider_default_button, valueText(value)) } else { valueText(value) @@ -113,7 +118,9 @@ fun SliderOptionText( Text(text) } - AnimatedVisibility(visible = value != defaultValue) { + // Always show the reset button because it popping in/out when dragging over + // the default value causes the slider to change size and jiggle. + AnimatedVisibility(visible = value != defaultValue || isDragged) { IconButton(onClick = { onValueChange(defaultValue) }) { Icon( Icons.Rounded.RestartAlt, diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 5ec3580a01..bb6384e0b9 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -195,7 +195,7 @@ Automatic back up successful! Automatic back up failed! Screenshot taken - Screenshot resolution doesn\'t match this device\'s resolution! + Screenshot doesn\'t have the same aspect ratio as this device\'s screen! Copied key map UUID to clipboard You\'ve triggered a key map Copied log @@ -470,6 +470,9 @@ Disable battery optimization You MUST read this all otherwise you will get frustrated in the future!\n\nTapping \"fix partially\" might prevent Android from stopping the app while it is in the background.\n\nThis is NOT ENOUGH. Your OEM\'s skin such as MIUI or Samsung Experience may have other app killing features so you MUST turn them off for Key Mapper as well by following the online guide at dontkillmyapp.com. + Grant full file access + Key Mapper needs "All files access" to show backup files copied here from another device, since Android TV has no built-in file picker. This permission is only requested in the F-Droid build because Google Play restricts it to file-manager apps. + Restart the accessibility service by turning it off and on. @@ -527,6 +530,7 @@ Understood Turn on Proceed + Grant access Turn off Cancel @@ -814,6 +818,7 @@ App %s is disabled! You need to grant Key Mapper permission to modify system settings. + Key Mapper needs "All files access" permission for this. This requires root permission! This action requires camera permission! Requires Android %s or newer @@ -860,6 +865,10 @@ This action needs setting up Battery optimization settings not found! If it exists, open it manually. + Find Key Mapper in the list and disable battery optimization for it + Go to Special app access -> Energy Optimization -> Disable Key Mapper + Go to Settings -> Special app access -> Energy Optimization -> Disable Key Mapper + Couldn\'t find the "All files access" settings page on this device. Extra (%s) not found! You can\'t have duplicate constraints! @@ -868,6 +877,7 @@ Empty JSON file! File access denied! %s Unknown IO error! + Storage is unavailable. Please try again in a moment. Canceled! Invalid number! Must be at least %s! @@ -899,6 +909,8 @@ You need to enable %s! Failed to change input method! Failed to enable input method! + Failed to cycle keyboard language! + The current keyboard doesn\'t have multiple languages enabled! Your device has no camera app! Your device has no assistant! Your device has no settings app! @@ -922,6 +934,7 @@ Denied permission to see paired Bluetooth devices! Denied permission to show notifications! Denied permission to read logs! + Denied permission to access the local network! Must be 2 or more! Must be %d or less! Must be greater than 0! @@ -1034,12 +1047,21 @@ Stop media for %s Step media forward + Step media forward (%ds) Step media forward for an app Step media forward for %s + Step media forward for %1$s (%2$ds) Step media backward + Step media backward (%ds) Step media backward for an app Step media backward for %s + Step media backward for %1$s (%2$ds) + + Custom seek amount + Seek by + %d secs + Not all apps support a custom seek amount. Some apps will use their own fixed skip duration instead. Go back Go home @@ -1112,6 +1134,7 @@ Switch keyboard Switch to %s + Cycle keyboard language Cut Copy @@ -1269,6 +1292,7 @@ 4-finger gestures + Activate the focused item Move reading control up or backwards Move reading control down or forwards Previous item @@ -1279,6 +1303,7 @@ Scroll forwards Start voice command Pause or resume speech + Play or pause media, answer or end calls Start or end selection mode Read from focused item Turn speech on or off @@ -1295,6 +1320,7 @@ Next container + Double-tap Swipe up Swipe down Swipe left @@ -1305,6 +1331,7 @@ Swipe right then left Swipe right then up Tap with 2 fingers + Double-tap with 2 fingers Double-tap and hold with 2 fingers Triple-tap with 2 fingers Triple-tap and hold with 2 fingers @@ -1609,6 +1636,9 @@ Ringer mode actions Consider using Expert Mode for ringer mode actions to avoid conflicts with Do Not Disturb settings. Use Expert Mode + Use a dedicated action instead + This will not work as you expect. Use the \"%1$s\" action instead. + Replace action Limit to specific apps? Add constraints in the Constraints tab. Choose a layout @@ -1629,6 +1659,7 @@ Actions Constraints Options + Enabled Key maps Floating buttons New key map @@ -1684,6 +1715,10 @@ Importing successful! Exporting… Failed: %s + Saved %s to Downloads + Choose a backup + No previous Key Mapper backups found in Downloads. Export a backup on this device first, then it will appear here. + Grant access to see files from other apps Loading file… Import successful! Importing… @@ -1900,6 +1935,10 @@ Key Mapper needs permission to notify you if there are any issues with the set up process. Give permission + Allow local network access + Key Mapper needs access to the local network to connect to the ADB service on your device. + Give permission + Incompatible USB configuration You must select \'No data transfer\' as your default USB configuration so that Expert Mode is not killed every time you lock your device. @@ -1907,6 +1946,33 @@ You need to enable \"USB debugging security settings\" in Developer options for Expert Mode to work properly. This is mainly required on Xiaomi devices. Open Developer options + Reduce app killing + Xiaomi devices (MIUI/HyperOS) aggressively close background apps. Apply a few settings to keep Expert Mode running. + + Reduce app killing + Xiaomi devices (MIUI/HyperOS) aggressively close background apps and revoke permissions. These settings help keep Expert Mode running. You may need to re-apply them after a system update. + Start Expert Mode to change this setting. + Something went wrong. Make sure Expert Mode is running and try again. + Couldn\'t open the settings screen on this device. + Applied + + Battery fixes + Whitelists the app from battery optimisation and allows it to run in the background. These are safe to apply. + Apply battery fixes + + MIUI optimization + Disabling MIUI optimization is the most effective fix, but it can change the behaviour of some MIUI features. + Disable MIUI optimization + You may need to restart your device for this to fully take effect. Turn it off again to revert. + + Open + Enable autostart + Allow the app to start automatically so it can run after your device restarts. + Battery saver + Set the app\'s battery saver to \"No restrictions\" so it isn\'t closed in the background. + Lock the app in recents + Open the recent apps screen, then swipe down on the app\'s card (or long-press it) and tap the padlock. This stops \"clear all\" from closing it. + Setup assistant Expert Mode is running diff --git a/base/src/test/AndroidManifest.xml b/base/src/test/AndroidManifest.xml new file mode 100644 index 0000000000..b54190d3a4 --- /dev/null +++ b/base/src/test/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt new file mode 100644 index 0000000000..593aa20497 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt @@ -0,0 +1,131 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.common.utils.PinchScreenType +import io.github.sds100.keymapper.common.utils.SizeKM +import io.github.sds100.keymapper.common.utils.valueOrNull +import io.github.sds100.keymapper.data.entities.ActionEntity +import io.github.sds100.keymapper.data.entities.EntityExtra +import io.github.sds100.keymapper.data.entities.getData +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue +import org.junit.Test + +/** + * Tests for saving the screen resolution with the coordinate actions in issue #2217. + */ +class ActionDataEntityMapperTest { + + @Test + fun `save and load the screen resolution of a tap screen action`() { + // GIVEN + val action = ActionData.TapScreen( + x = 540, + y = 1200, + description = "test", + screenResolution = SizeKM(1080, 2400), + ) + + // WHEN + val entity = ActionDataEntityMapper.toEntity(action) + + // THEN + assertThat( + entity.extras.getData(ActionEntity.EXTRA_SCREEN_RESOLUTION).valueOrNull(), + `is`("1080,2400"), + ) + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `save and load the screen resolution of a swipe screen action`() { + // GIVEN + val action = ActionData.SwipeScreen( + xStart = 270, + yStart = 600, + xEnd = 540, + yEnd = 1200, + fingerCount = 1, + duration = 250, + description = "test", + screenResolution = SizeKM(1080, 2400), + ) + + // WHEN + val entity = ActionDataEntityMapper.toEntity(action) + + // THEN + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `save and load the screen resolution of a pinch screen action`() { + // GIVEN + val action = ActionData.PinchScreen( + x = 540, + y = 1200, + distance = 300, + pinchType = PinchScreenType.PINCH_IN, + fingerCount = 2, + duration = 250, + description = "test", + screenResolution = SizeKM(1080, 2400), + ) + + // WHEN + val entity = ActionDataEntityMapper.toEntity(action) + + // THEN + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `dont save an extra when the action has no screen resolution`() { + // GIVEN + val action = ActionData.TapScreen(x = 540, y = 1200, description = null) + + // WHEN + val entity = ActionDataEntityMapper.toEntity(action) + + // THEN + assertThat( + entity.extras.getData(ActionEntity.EXTRA_SCREEN_RESOLUTION).valueOrNull(), + `is`(nullValue()), + ) + } + + @Test + fun `load no screen resolution for an action saved before it existed`() { + // GIVEN an entity saved by an older version of the app. + val entity = ActionEntity(type = ActionEntity.Type.TAP_COORDINATE, data = "540,1200") + + // WHEN + val action = ActionDataEntityMapper.fromEntity(entity) + + // THEN + assertThat((action as ActionData.TapScreen).screenResolution, `is`(nullValue())) + } + + @Test + fun `load no screen resolution when the extra can not be parsed`() { + // GIVEN + val malformedValues = listOf("abc", "1080", "1080,2400,3", "1080,abc", "0,2400", "-1,2400") + + for (malformedValue in malformedValues) { + val entity = ActionEntity( + type = ActionEntity.Type.TAP_COORDINATE, + data = "540,1200", + extras = listOf( + EntityExtra(ActionEntity.EXTRA_SCREEN_RESOLUTION, malformedValue), + ), + ) + + // WHEN + val action = ActionDataEntityMapper.fromEntity(entity) + + // THEN the action still loads rather than throwing. + assertThat(malformedValue, (action as ActionData.TapScreen).x, `is`(540)) + assertThat(malformedValue, action.screenResolution, `is`(nullValue())) + } + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModelTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModelTest.kt index 300b667a5a..07e8177c0f 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModelTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigShellCommandViewModelTest.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import kotlinx.serialization.json.Json import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.nullValue @@ -31,6 +32,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -263,6 +265,30 @@ class ConfigShellCommandViewModelTest { ).executeWithStreamingOutput(any(), any(), any()) } + @Test + fun `when clicking done replace windows line endings in the command`() = runTest { + // Scripts pasted from a computer can have Windows line endings, which a shell fails to + // parse. See issue #2209. + viewModel.onCommandChanged("if true; then\r\n echo hello\r\nelse\r\n echo bye\r\nfi") + viewModel.onDescriptionChanged("Test command") + + val result = viewModel.onDoneClick() + + advanceUntilIdle() + + assertThat(result, `is`(true)) + + val captor = argumentCaptor() + verify(mockNavigationProvider).popBackStackWithResult(captor.capture()) + + val action = Json.decodeFromString(captor.firstValue) + + assertThat( + action.command, + `is`("if true; then\n echo hello\nelse\n echo bye\nfi"), + ) + } + @Test fun `when clicking done show error when description is whitespace`() = runTest { viewModel.onCommandChanged("echo test") diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCaseTest.kt new file mode 100644 index 0000000000..03aa073a82 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCaseTest.kt @@ -0,0 +1,164 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.common.models.ShellExecutionMode +import io.github.sds100.keymapper.common.models.ShellResult +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.common.utils.success +import io.github.sds100.keymapper.sysbridge.ISystemBridge +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.system.root.SuAdapter +import io.github.sds100.keymapper.system.shell.ShellAdapter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * Regression tests for issue #2209. Scripts pasted from a computer can have Windows (\r\n) line + * endings, which a shell fails to parse because \r stops reserved words like "then" from being + * recognized. + */ +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class ExecuteShellCommandUseCaseTest { + + private companion object { + private const val TIMEOUT = 10000L + + private const val WINDOWS_SCRIPT = + "if true; then\r\n echo hello\r\nelse\r\n echo bye\r\nfi" + + private const val UNIX_SCRIPT = + "if true; then\n echo hello\nelse\n echo bye\nfi" + } + + private lateinit var useCase: ExecuteShellCommandUseCase + private lateinit var mockShellAdapter: ShellAdapter + private lateinit var mockSuAdapter: SuAdapter + private lateinit var mockSystemBridgeConnectionManager: SystemBridgeConnectionManager + private lateinit var mockSystemBridge: ISystemBridge + + private val shellResult: KMResult = + ShellResult(stdout = "", exitCode = 0).success() + + @Before + fun init() { + mockShellAdapter = mock() + mockSuAdapter = mock() + mockSystemBridge = mock() + mockSystemBridgeConnectionManager = mock() + + useCase = ExecuteShellCommandUseCase( + shellAdapter = mockShellAdapter, + suAdapter = mockSuAdapter, + systemBridgeConnectionManager = mockSystemBridgeConnectionManager, + ) + } + + @Test + fun `standard mode replaces windows line endings`() = runTest { + whenever(mockShellAdapter.execute(any(), any())).thenReturn(shellResult) + + useCase.execute(WINDOWS_SCRIPT, ShellExecutionMode.STANDARD, TIMEOUT) + + verify(mockShellAdapter).execute(eq(UNIX_SCRIPT), eq(TIMEOUT)) + } + + @Test + fun `root mode replaces windows line endings`() = runTest { + whenever(mockSuAdapter.execute(any(), any())).thenReturn(shellResult) + + useCase.execute(WINDOWS_SCRIPT, ShellExecutionMode.ROOT, TIMEOUT) + + verify(mockSuAdapter).execute(eq(UNIX_SCRIPT), eq(TIMEOUT)) + } + + @Test + fun `adb mode replaces windows line endings`() = runTest { + stubSystemBridge() + + useCase.execute(WINDOWS_SCRIPT, ShellExecutionMode.ADB, TIMEOUT) + + verify(mockSystemBridge).executeCommand(eq(UNIX_SCRIPT), eq(TIMEOUT)) + } + + @Test + fun `standard mode streaming replaces windows line endings`() = runTest { + whenever(mockShellAdapter.executeWithStreamingOutput(any(), any())) + .thenReturn(flowOf(shellResult)) + + useCase.executeWithStreamingOutput( + WINDOWS_SCRIPT, + ShellExecutionMode.STANDARD, + TIMEOUT, + ).first() + + verify(mockShellAdapter).executeWithStreamingOutput(eq(UNIX_SCRIPT), eq(TIMEOUT)) + } + + @Test + fun `root mode streaming replaces windows line endings`() = runTest { + whenever(mockSuAdapter.executeWithStreamingOutput(any(), any())) + .thenReturn(flowOf(shellResult)) + + useCase.executeWithStreamingOutput( + WINDOWS_SCRIPT, + ShellExecutionMode.ROOT, + TIMEOUT, + ).first() + + verify(mockSuAdapter).executeWithStreamingOutput(eq(UNIX_SCRIPT), eq(TIMEOUT)) + } + + @Test + fun `adb mode streaming replaces windows line endings`() = runTest { + stubSystemBridge() + + useCase.executeWithStreamingOutput( + WINDOWS_SCRIPT, + ShellExecutionMode.ADB, + TIMEOUT, + ).first() + + verify(mockSystemBridge).executeCommand(eq(UNIX_SCRIPT), eq(TIMEOUT)) + } + + @Test + fun `replace lone carriage returns with new lines`() = runTest { + whenever(mockShellAdapter.execute(any(), any())).thenReturn(shellResult) + + useCase.execute("echo hello\recho bye", ShellExecutionMode.STANDARD, TIMEOUT) + + verify(mockShellAdapter).execute(eq("echo hello\necho bye"), eq(TIMEOUT)) + } + + @Test + fun `do not change a command that already has unix line endings`() = runTest { + whenever(mockShellAdapter.execute(any(), any())).thenReturn(shellResult) + + useCase.execute(UNIX_SCRIPT, ShellExecutionMode.STANDARD, TIMEOUT) + + verify(mockShellAdapter).execute(eq(UNIX_SCRIPT), eq(TIMEOUT)) + } + + private fun stubSystemBridge() { + whenever(mockSystemBridge.executeCommand(any(), any())) + .thenReturn(ShellResult(stdout = "", exitCode = 0)) + + whenever(mockSystemBridgeConnectionManager.run(any())) + .thenAnswer { invocation -> + val block = invocation.getArgument<(ISystemBridge) -> ShellResult>(0) + Success(block(mockSystemBridge)) + } + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt index 7654fbeed8..7e98e38ec4 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt @@ -4,6 +4,10 @@ import io.github.sds100.keymapper.base.input.InputEventHub import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityService import io.github.sds100.keymapper.base.system.devices.FakeDevicesAdapter import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.PinchScreenType +import io.github.sds100.keymapper.common.utils.SizeKM +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.system.display.DisplayAdapter import io.github.sds100.keymapper.system.popup.ToastAdapter import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -15,6 +19,7 @@ import org.junit.runner.RunWith import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -32,6 +37,7 @@ class PerformActionsUseCaseTest { private lateinit var mockAccessibilityService: IAccessibilityService private lateinit var mockToastAdapter: ToastAdapter private lateinit var mockInputEventHub: InputEventHub + private lateinit var mockDisplayAdapter: DisplayAdapter @Before fun init() { @@ -39,6 +45,7 @@ class PerformActionsUseCaseTest { mockAccessibilityService = mock() mockToastAdapter = mock() mockInputEventHub = mock() + mockDisplayAdapter = mock() useCase = PerformActionsUseCaseImpl( service = mockAccessibilityService, @@ -57,7 +64,7 @@ class PerformActionsUseCaseTest { phoneAdapter = mock(), audioAdapter = mock(), cameraAdapter = mock(), - displayAdapter = mock(), + displayAdapter = mockDisplayAdapter, lockScreenAdapter = mock(), mediaAdapter = mock(), airplaneModeAdapter = mock(), @@ -101,4 +108,169 @@ class PerformActionsUseCaseTest { // THEN verify(mockToastAdapter, never()).show(any(), any()) } + + /** + * issue #2217 + */ + @Test + fun `scale tap screen action to the current display resolution`() = runTest(testDispatcher) { + // GIVEN the coordinate was picked on a 1080x2400 display and the display is now 1440x3200. + whenever(mockDisplayAdapter.size).doReturn(SizeKM(1440, 3200)) + whenever(mockAccessibilityService.tapScreen(any(), any(), any())).doReturn(Success(Unit)) + + val action = ActionData.TapScreen( + x = 540, + y = 1200, + description = null, + screenResolution = SizeKM(1080, 2400), + ) + + // WHEN + useCase.perform(action) + + // THEN + verify(mockAccessibilityService).tapScreen(eq(720), eq(1600), any()) + } + + /** + * issue #2217 + */ + @Test + fun `dont scale tap screen action created before the resolution was saved`() = + runTest(testDispatcher) { + // GIVEN + whenever(mockDisplayAdapter.size).doReturn(SizeKM(1440, 3200)) + whenever(mockAccessibilityService.tapScreen(any(), any(), any())) + .doReturn(Success(Unit)) + + val action = ActionData.TapScreen( + x = 540, + y = 1200, + description = null, + screenResolution = null, + ) + + // WHEN + useCase.perform(action) + + // THEN the coordinate is dispatched exactly as it was saved. + verify(mockAccessibilityService).tapScreen(eq(540), eq(1200), any()) + } + + /** + * issue #2217 + */ + @Test + fun `scale both ends of a swipe screen action to the current display resolution`() = + runTest(testDispatcher) { + // GIVEN + whenever(mockDisplayAdapter.size).doReturn(SizeKM(1440, 3200)) + whenever( + mockAccessibilityService.swipeScreen( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ), + ).doReturn(Success(Unit)) + + val action = ActionData.SwipeScreen( + xStart = 270, + yStart = 600, + xEnd = 540, + yEnd = 1200, + fingerCount = 1, + duration = 250, + description = null, + screenResolution = SizeKM(1080, 2400), + ) + + // WHEN + useCase.perform(action) + + // THEN + verify(mockAccessibilityService).swipeScreen( + eq(360), + eq(800), + eq(720), + eq(1600), + eq(1), + eq(250), + any(), + ) + } + + /** + * issue #2217 + */ + @Test + fun `scale the centre and the distance of a pinch screen action`() = runTest(testDispatcher) { + // GIVEN + whenever(mockDisplayAdapter.size).doReturn(SizeKM(1440, 3200)) + whenever( + mockAccessibilityService.pinchScreen( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ), + ).doReturn(Success(Unit)) + + val action = ActionData.PinchScreen( + x = 540, + y = 1200, + distance = 300, + pinchType = PinchScreenType.PINCH_IN, + fingerCount = 2, + duration = 250, + description = null, + screenResolution = SizeKM(1080, 2400), + ) + + // WHEN + useCase.perform(action) + + // THEN + verify(mockAccessibilityService).pinchScreen( + eq(720), + eq(1600), + eq(400), + eq(PinchScreenType.PINCH_IN), + eq(2), + eq(250), + any(), + ) + } + + /** + * issue #2217 + */ + @Test + fun `dont scale tap screen action when the display is only rotated`() = + runTest(testDispatcher) { + // GIVEN the coordinate was picked on a portrait display and the display is now + // landscape at the same resolution. + whenever(mockDisplayAdapter.size).doReturn(SizeKM(2400, 1080)) + whenever(mockAccessibilityService.tapScreen(any(), any(), any())) + .doReturn(Success(Unit)) + + val action = ActionData.TapScreen( + x = 540, + y = 1200, + description = null, + screenResolution = SizeKM(1080, 2400), + ) + + // WHEN + useCase.perform(action) + + // THEN + verify(mockAccessibilityService).tapScreen(eq(540), eq(1200), any()) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ScreenCoordinateScalingTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ScreenCoordinateScalingTest.kt new file mode 100644 index 0000000000..230a8c79a1 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ScreenCoordinateScalingTest.kt @@ -0,0 +1,172 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.common.utils.PointKM +import io.github.sds100.keymapper.common.utils.SizeKM +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.junit.Test + +/** + * Tests for the coordinate scaling in issue #2217. + */ +class ScreenCoordinateScalingTest { + + companion object { + private val PORTRAIT_1080 = SizeKM(1080, 2400) + private val PORTRAIT_1440 = SizeKM(1440, 3200) + private val LANDSCAPE_1080 = SizeKM(2400, 1080) + private val LANDSCAPE_1440 = SizeKM(3200, 1440) + } + + @Test + fun `dont scale coordinate when the resolution is unknown`() { + // GIVEN an action that was created before the resolution was saved + + // WHEN + val point = scaleCoordinate(x = 540, y = 1200, from = null, to = PORTRAIT_1440) + + // THEN + assertThat(point, `is`(PointKM(540, 1200))) + } + + @Test + fun `dont scale coordinate when the resolution has not changed`() { + // WHEN + val point = scaleCoordinate(x = 540, y = 1200, from = PORTRAIT_1080, to = PORTRAIT_1080) + + // THEN + assertThat(point, `is`(PointKM(540, 1200))) + } + + @Test + fun `scale coordinate up to a higher resolution`() { + // WHEN 1080x2400 -> 1440x3200 is a factor of 4 3 on both axes + val point = scaleCoordinate(x = 540, y = 1200, from = PORTRAIT_1080, to = PORTRAIT_1440) + + // THEN + assertThat(point, `is`(PointKM(720, 1600))) + } + + @Test + fun `scale coordinate down to a lower resolution`() { + // WHEN + val point = scaleCoordinate(x = 720, y = 1600, from = PORTRAIT_1440, to = PORTRAIT_1080) + + // THEN + assertThat(point, `is`(PointKM(540, 1200))) + } + + @Test + fun `dont scale coordinate when only the orientation is different`() { + // GIVEN the coordinate was picked from a portrait screenshot and the display is now + // landscape at the same resolution. + + // WHEN + val point = scaleCoordinate(x = 540, y = 1200, from = PORTRAIT_1080, to = LANDSCAPE_1080) + + // THEN the saved size is normalised to landscape first so nothing moves. + assertThat(point, `is`(PointKM(540, 1200))) + } + + @Test + fun `scale coordinate when the orientation and the resolution are both different`() { + // WHEN the saved portrait size normalises to 2400x1080, which is a factor of 4 3 to + // 3200x1440. + val point = scaleCoordinate(x = 540, y = 1200, from = PORTRAIT_1080, to = LANDSCAPE_1440) + + // THEN + assertThat(point, `is`(PointKM(720, 1600))) + } + + @Test + fun `scale coordinate saved in landscape to a portrait display of another resolution`() { + // WHEN the saved landscape size normalises to 1080x2400, which is a factor of 4 3 to + // 1440x3200. + val point = scaleCoordinate(x = 540, y = 1200, from = LANDSCAPE_1080, to = PORTRAIT_1440) + + // THEN + assertThat(point, `is`(PointKM(720, 1600))) + } + + @Test + fun `round the scaled coordinate to the nearest pixel`() { + // WHEN 100 -> 150 is a factor of 1 5 so 5 scales to 7 5 + val point = scaleCoordinate(x = 5, y = 5, from = SizeKM(100, 100), to = SizeKM(150, 150)) + + // THEN + assertThat(point, `is`(PointKM(8, 8))) + } + + @Test + fun `dont scale coordinate when a display size is invalid`() { + // WHEN + val zeroSource = + scaleCoordinate(x = 540, y = 1200, from = SizeKM(0, 2400), to = PORTRAIT_1440) + val negativeSource = + scaleCoordinate(x = 540, y = 1200, from = SizeKM(1080, -1), to = PORTRAIT_1440) + val zeroTarget = + scaleCoordinate(x = 540, y = 1200, from = PORTRAIT_1080, to = SizeKM(1440, 0)) + + // THEN + assertThat(zeroSource, `is`(PointKM(540, 1200))) + assertThat(negativeSource, `is`(PointKM(540, 1200))) + assertThat(zeroTarget, `is`(PointKM(540, 1200))) + } + + @Test + fun `dont scale distance when the resolution is unknown`() { + // WHEN + val distance = scaleDistance(distance = 300, from = null, to = PORTRAIT_1440) + + // THEN + assertThat(distance, `is`(300)) + } + + @Test + fun `dont scale distance when the resolution has not changed`() { + // WHEN + val distance = scaleDistance(distance = 300, from = PORTRAIT_1080, to = PORTRAIT_1080) + + // THEN + assertThat(distance, `is`(300)) + } + + @Test + fun `scale distance when both axes scale equally`() { + // WHEN + val distance = scaleDistance(distance = 300, from = PORTRAIT_1080, to = PORTRAIT_1440) + + // THEN + assertThat(distance, `is`(400)) + } + + @Test + fun `scale distance by the average ratio when the axes scale differently`() { + // GIVEN the width doubles and the height stays the same, so the average ratio is 1 5. + + // WHEN + val distance = + scaleDistance(distance = 300, from = SizeKM(1000, 2000), to = SizeKM(2000, 2000)) + + // THEN + assertThat(distance, `is`(450)) + } + + @Test + fun `dont scale distance when only the orientation is different`() { + // WHEN + val distance = scaleDistance(distance = 300, from = PORTRAIT_1080, to = LANDSCAPE_1080) + + // THEN + assertThat(distance, `is`(300)) + } + + @Test + fun `dont scale distance when a display size is invalid`() { + // WHEN + val distance = scaleDistance(distance = 300, from = SizeKM(1080, 0), to = PORTRAIT_1440) + + // THEN + assertThat(distance, `is`(300)) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarterTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarterTest.kt index 39f34d3b4a..16c18a32f1 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarterTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarterTest.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.expertmode import androidx.core.app.NotificationCompat +import androidx.test.core.app.ApplicationProvider import io.github.sds100.keymapper.base.BaseMainActivity import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.repositories.FakePreferenceRepository @@ -8,6 +9,7 @@ import io.github.sds100.keymapper.base.system.notifications.NotificationControll import io.github.sds100.keymapper.base.utils.TestBuildConfigProvider import io.github.sds100.keymapper.base.utils.TestScopeClock import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.base.utils.ui.ResourceProviderImpl import io.github.sds100.keymapper.common.notifications.KMNotificationAction import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager @@ -32,10 +34,14 @@ import kotlinx.coroutines.test.runTest import org.hamcrest.CoreMatchers.`is` import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.closeTo +import org.hamcrest.Matchers.nullValue import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.junit.MockitoJUnitRunner +import org.mockito.InOrder +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast @@ -43,13 +49,19 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.mockito.verification.VerificationMode +import org.robolectric.RobolectricTestRunner @ExperimentalCoroutinesApi -@RunWith(MockitoJUnitRunner::class) +@RunWith(RobolectricTestRunner::class) class SystemBridgeAutoStarterTest { + @get:Rule + val mockitoRule: MockitoRule = MockitoJUnit.rule() + private val testDispatcher = UnconfinedTestDispatcher() private val testCoroutineScope = TestScope(testDispatcher) @@ -64,7 +76,7 @@ class SystemBridgeAutoStarterTest { private lateinit var mockNetworkAdapter: NetworkAdapter private lateinit var mockPermissionAdapter: PermissionAdapter private lateinit var mockNotificationAdapter: NotificationAdapter - private lateinit var mockResourceProvider: ResourceProvider + private lateinit var resourceProvider: ResourceProvider private lateinit var testBuildConfig: TestBuildConfigProvider private lateinit var isRootGrantedFlow: MutableStateFlow @@ -98,6 +110,7 @@ class SystemBridgeAutoStarterTest { mockConnectionManager = mock { on { connectionState } doReturn connectionStateFlow + on { canStartSystemBridge() } doReturn true } mockSetupController = mock() @@ -119,9 +132,7 @@ class SystemBridgeAutoStarterTest { mockNotificationAdapter = mock() - mockResourceProvider = mock { - on { getString(any()) } doReturn "test_string" - } + resourceProvider = ResourceProviderImpl(ApplicationProvider.getApplicationContext()) testScopeClock = TestScopeClock(testCoroutineScope) @@ -138,11 +149,21 @@ class SystemBridgeAutoStarterTest { networkAdapter = mockNetworkAdapter, permissionAdapter = mockPermissionAdapter, notificationAdapter = mockNotificationAdapter, - resourceProvider = mockResourceProvider, + resourceProvider = resourceProvider, buildConfig = testBuildConfig, ) } + private fun InOrder.verifyNotificationText(verificationMode: VerificationMode, text: String) { + val argument = argumentCaptor() + verify(mockNotificationAdapter, verificationMode).showNotification(argument.capture()) + + assertThat( + argument.firstValue.text, + `is`(text), + ) + } + @Test fun `auto start time is saved as unix timestamp`() = runTest(testDispatcher) { fakePreferences.set(Keys.isSystemBridgeKeepAliveEnabled, true) @@ -328,6 +349,25 @@ class SystemBridgeAutoStarterTest { verify(mockConnectionManager, never()).startWithShizuku() } + @Test + fun `do not auto start or start the cooldown when the storage is unavailable`() = + runTest(testDispatcher) { + advanceTimeBy(1_000_000L) + whenever(mockConnectionManager.canStartSystemBridge()).thenReturn(false) + isRootGrantedFlow.value = true + fakePreferences.set(Keys.isSystemBridgeEmergencyKilled, false) + fakePreferences.set(Keys.isSystemBridgeUsed, true) + + systemBridgeAutoStarter.init() + advanceUntilIdle() + + verify(mockConnectionManager, never()).startWithRoot() + assertThat( + fakePreferences.get(Keys.systemBridgeLastAutoStartTime).first(), + `is`(nullValue()), + ) + } + @Test fun `auto start with root`() = runTest(testDispatcher) { advanceTimeBy(1_000_000L) @@ -482,8 +522,12 @@ class SystemBridgeAutoStarterTest { val expectedModel = NotificationModel( id = NotificationController.ID_SYSTEM_BRIDGE_STATUS, channel = NotificationController.CHANNEL_SETUP_ASSISTANT, - title = "test_string", - text = "test_string", + title = resourceProvider.getString( + R.string.system_bridge_wifi_disconnected_notification_title, + ), + text = resourceProvider.getString( + R.string.system_bridge_wifi_disconnected_notification_text, + ), icon = R.drawable.offline_bolt_24px, onClickAction = KMNotificationAction.Activity.MainActivity( action = BaseMainActivity.ACTION_START_SYSTEM_BRIDGE, @@ -618,9 +662,6 @@ class SystemBridgeAutoStarterTest { fakePreferences.set(Keys.isSystemBridgeKeepAliveEnabled, true) fakePreferences.set(Keys.isSystemBridgeUsed, true) - whenever( - mockResourceProvider.getString(R.string.system_bridge_died_notification_title), - ).thenReturn("died") whenever(mockSetupController.isAdbPaired()).thenReturn(true) isWifiConnectedFlow.value = true writeSecureSettingsGrantedFlow.value = true @@ -642,7 +683,12 @@ class SystemBridgeAutoStarterTest { val argument = argumentCaptor() verify(mockNotificationAdapter).showNotification(argument.capture()) - assertThat(argument.firstValue.title, `is`("died")) + assertThat( + argument.firstValue.title, + `is`( + resourceProvider.getString(R.string.system_bridge_died_notification_title), + ), + ) } } @@ -783,4 +829,98 @@ class SystemBridgeAutoStarterTest { verify(mockConnectionManager, never()).startWithRoot() } } + + /** + * See #2099 + */ + @Test + fun `Show notification that ADB pairing is broken the first time it connects to wifi and never again on subsequent (dis)connections`() { + runTest(testDispatcher) { + fakePreferences.set(Keys.isSystemBridgeKeepAliveEnabled, true) + fakePreferences.set(Keys.isSystemBridgeUsed, true) + + writeSecureSettingsGrantedFlow.value = true + + // WiFi is disconnected initially + isWifiConnectedFlow.value = false + + // Must not be stopped by user. This reproduces the case that it was previously working, but they rebooted and it is not any more. Or they deleted the ADB pairing. + connectionStateFlow.value = + SystemBridgeConnectionState.Disconnected(time = 0L, isStoppedByUser = false) + whenever(mockSetupController.isAdbPaired()).thenReturn(false) + + inOrder(mockNotificationAdapter) { + systemBridgeAutoStarter.init() + + advanceUntilIdle() + verifyNotificationText(times(1), "Your phone must be connected to a WiFi network") + + isWifiConnectedFlow.value = true + advanceUntilIdle() + + verifyNotificationText( + times(1), + "Tap to set up again. Try ADB pairing and rebooting your phone if it repeatedly fails.", + ) + + isWifiConnectedFlow.value = false + + advanceUntilIdle() + + // No more notifications should be sent when disconnecting from WiFi. + verifyNoMoreInteractions() + } + } + } + + /** + * See #2099. The system dialog asking whether to trust this wifi network for wireless ADB should + * not show every time the phone connects to a new wifi network. Key Mapper was causing + * this to show because it was checking whether ADB was paired by starting wireless ADB. + */ + @Test + fun `do not check ADB is paired if system bridge is running and connecting to a new network`() { + runTest(testDispatcher) { + fakePreferences.set(Keys.isSystemBridgeKeepAliveEnabled, true) + fakePreferences.set(Keys.isSystemBridgeUsed, true) + writeSecureSettingsGrantedFlow.value = true + + // WiFi is disconnected initially + isWifiConnectedFlow.value = false + + connectionStateFlow.value = + SystemBridgeConnectionState.Disconnected(time = 0L, isStoppedByUser = false) + + inOrder(mockSetupController) { + systemBridgeAutoStarter.init() + advanceUntilIdle() + + // Connect to WiFi. It should check whether ADB is paired + isWifiConnectedFlow.value = true + advanceUntilIdle() + verify(mockSetupController, times(1)).isAdbPaired() + + // Disconnect and connect WiFi again. It should not check ADB is paired again. + isWifiConnectedFlow.value = false + advanceUntilIdle() + isWifiConnectedFlow.value = true + advanceUntilIdle() + + // It should never check for ADB being paired + verify(mockSetupController, never()).isAdbPaired() + + // The user sets up the system bridge again + connectionStateFlow.value = + SystemBridgeConnectionState.Connected(time = 0L) + advanceUntilIdle() + + // The system bridge disconnects for some reason and the app then checks whether + // ADB is paired again because it was working again. + connectionStateFlow.value = + SystemBridgeConnectionState.Disconnected(time = 0L, isStoppedByUser = false) + advanceUntilIdle() + verify(mockSetupController, times(1)).isAdbPaired() + } + } + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCaseTest.kt index 8c1fb04ceb..f01832a60f 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupUseCaseTest.kt @@ -3,14 +3,20 @@ package io.github.sds100.keymapper.base.expertmode import io.github.sds100.keymapper.base.repositories.FakePreferenceRepository import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupController +import io.github.sds100.keymapper.sysbridge.service.SystemBridgeSetupStep import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter +import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceState import io.github.sds100.keymapper.system.network.NetworkAdapter +import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.system.shizuku.ShizukuAdapter import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` @@ -20,6 +26,7 @@ import org.junit.runner.RunWith import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @ExperimentalCoroutinesApi @RunWith(MockitoJUnitRunner::class) @@ -129,4 +136,49 @@ class SystemBridgeSetupUseCaseTest { `is`(false), ) } + + @Test + fun `next step is ACCESS_LOCAL_NETWORK_PERMISSION when notification permission granted but local network permission is not`() = + runTest { + whenever(mockAccessibilityServiceAdapter.state) + .thenReturn(MutableStateFlow(AccessibilityServiceState.ENABLED)) + whenever(mockPermissionAdapter.isGrantedFlow(Permission.POST_NOTIFICATIONS)) + .thenReturn(flowOf(true)) + whenever(mockPermissionAdapter.isGrantedFlow(Permission.ACCESS_LOCAL_NETWORK)) + .thenReturn(flowOf(false)) + whenever(mockNetworkAdapter.isWifiConnected).thenReturn(flowOf(false)) + whenever(mockSystemBridgeSetupController.isDeveloperOptionsEnabled) + .thenReturn(flowOf(false)) + whenever(mockSystemBridgeSetupController.isWirelessDebuggingEnabled) + .thenReturn(flowOf(false)) + whenever(mockSystemBridgeConnectionManager.connectionState) + .thenReturn( + MutableStateFlow( + SystemBridgeConnectionState.Disconnected( + time = 0L, + isStoppedByUser = false, + ), + ), + ) + + // isSystemBridgeConnected/adbAutoStartEligibility are computed eagerly from the + // constructor params, so the use case must be constructed after the mocks it reads + // from are stubbed. + val useCaseWithStubs = SystemBridgeSetupUseCaseImpl( + preferences = fakePreferences, + suAdapter = mockSuAdapter, + systemBridgeSetupController = mockSystemBridgeSetupController, + systemBridgeConnectionManager = mockSystemBridgeConnectionManager, + shizukuAdapter = mockShizukuAdapter, + permissionAdapter = mockPermissionAdapter, + accessibilityServiceAdapter = mockAccessibilityServiceAdapter, + networkAdapter = mockNetworkAdapter, + clock = mock(), + ) + + assertThat( + useCaseWithStubs.nextSetupStep.first(), + `is`(SystemBridgeSetupStep.ACCESS_LOCAL_NETWORK_PERMISSION), + ) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/system/files/FakeFileAdapter.kt b/base/src/test/java/io/github/sds100/keymapper/base/system/files/FakeFileAdapter.kt index c61444a990..5581b579dd 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/system/files/FakeFileAdapter.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/system/files/FakeFileAdapter.kt @@ -25,6 +25,10 @@ class FakeFileAdapter(private val tempFolder: TemporaryFolder) : FileAdapter { throw Exception() } + override fun getDownloads(): KMResult> { + throw Exception() + } + override fun getPrivateFile(path: String): IFile { val file = File(privateFolder, path) diff --git a/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt b/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt index 8410ff4c26..4fc6301f4f 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt @@ -36,4 +36,8 @@ class FakeInputMethodAdapter : InputMethodAdapter { ?.let { Success(it) } ?: KMError.InputMethodNotFound(packageName) } + + override fun cycleInputMethodSubtype(): KMResult { + return Success(Unit) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentViewModelRecreationTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentViewModelRecreationTest.kt new file mode 100644 index 0000000000..ac29369c54 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentViewModelRecreationTest.kt @@ -0,0 +1,83 @@ +package io.github.sds100.keymapper.base.system.intents + +import android.content.Intent +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.github.sds100.keymapper.base.utils.ui.DialogProviderImpl +import io.github.sds100.keymapper.base.utils.ui.FakeResourceProvider +import io.github.sds100.keymapper.system.apps.ActivityInfo +import io.github.sds100.keymapper.system.intents.IntentTarget +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Regression test for issue #2160. The fragment applies the initial argument by calling + * [ConfigIntentViewModel.loadResult] from onCreate, which runs again when the screen is recreated + * (for example on a configuration change) while the ViewModel survives. Loading must be applied + * only once so that a recreation does not discard the user's edits with the original value. + */ +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class ConfigIntentViewModelRecreationTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var viewModel: ConfigIntentViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + viewModel = ConfigIntentViewModel(FakeResourceProvider(), DialogProviderImpl()) + } + + @Test + fun loadResult_recreationAfterChangingActivity_keepsEditedActivity() = runTest(testDispatcher) { + val original = ConfigIntentResult( + uri = "#Intent;package=com.example.a;component=com.example.a/.MainActivity;end", + target = IntentTarget.ACTIVITY, + description = "Open App", + extras = emptyList(), + ) + + // Edit an existing intent action. + viewModel.loadResult(original) + + // The user picks a different activity. + viewModel.setActivity(ActivityInfo("com.example.b.SecondActivity", "com.example.b")) + + // The screen is recreated, so onCreate applies the original argument again. + viewModel.loadResult(original) + + assertThat(viewModel.targetPackage.value, `is`("com.example.b")) + assertThat(viewModel.targetClass.value, `is`("com.example.b.SecondActivity")) + + // The saved intent uri must contain the edited activity, not the original one. + val result = collectDoneResult() + val component = Intent.parseUri(result.uri, 0).component + assertThat(component?.packageName, `is`("com.example.b")) + assertThat(component?.className, `is`("com.example.b.SecondActivity")) + } + + private suspend fun collectDoneResult(): ConfigIntentResult { + var result: ConfigIntentResult? = null + val job = kotlinx.coroutines.CoroutineScope(testDispatcher).launch { + result = viewModel.returnResult.first() + } + viewModel.onDoneClick() + job.join() + return result!! + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/utils/TestBuildConfigProvider.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestBuildConfigProvider.kt index 877cb35bd3..a6fa9ac0e2 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/utils/TestBuildConfigProvider.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestBuildConfigProvider.kt @@ -10,4 +10,5 @@ class TestBuildConfigProvider(override var sdkInt: Int) : BuildConfigProvider { override val packageName: String = BuildConfig.LIBRARY_PACKAGE_NAME override val version: String = "1.0.0" override val versionCode: Int = 1 + override val canRequestAllFilesAccess: Boolean = false } diff --git a/common/src/main/java/io/github/sds100/keymapper/common/BuildConfigProvider.kt b/common/src/main/java/io/github/sds100/keymapper/common/BuildConfigProvider.kt index 104c3e6602..fc9adb0cef 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/BuildConfigProvider.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/BuildConfigProvider.kt @@ -7,4 +7,14 @@ interface BuildConfigProvider { val version: String val versionCode: Int val sdkInt: Int + + /** + * Whether this build is allowed to request MANAGE_EXTERNAL_STORAGE ("All files + * access"). Google Play restricts this permission to file-manager-type apps, so + * it's only requestable in the FOSS/F-Droid build — Android TV users predominantly + * sideload that build rather than install from Play. This only gates whether the + * *request* UI is ever shown; whether the permission is actually granted is a + * separate runtime check (`Permission.MANAGE_EXTERNAL_STORAGE`). + */ + val canRequestAllFilesAccess: Boolean } diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/BuildUtils.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/BuildUtils.kt index 6a44d3785f..c33f7be334 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/BuildUtils.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/BuildUtils.kt @@ -31,3 +31,5 @@ object BuildUtils { else -> "API $version" } } + +fun isXiaomiDevice(): Boolean = Build.BRAND.lowercase() in setOf("xiaomi", "redmi", "poco") diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt index abba6946ff..c5d3be8401 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt @@ -58,6 +58,8 @@ abstract class KMError : KMResult() { data class FailedToModifySystemSetting(val setting: String) : KMError() data object SwitchImeFailed : KMError() data object EnableImeFailed : KMError() + data object CycleImeSubtypeFailed : KMError() + data object NotEnoughInputMethodSubtypes : KMError() data object NoAppToOpenUrl : KMError() data object NoAppToPhoneCall : KMError() data object NoAppToSendSms : KMError() @@ -72,6 +74,7 @@ abstract class KMError : KMResult() { data class TargetFileNotFound(val uri: String) : KMError() data class TargetDirectoryNotFound(val uri: String) : KMError() data object UnknownIOError : KMError() + data object StarterFilesUnavailable : KMError() data object FileOperationCancelled : KMError() data object TargetDirectoryMatchesSourceDirectory : KMError() data class NoSpaceLeftOnTarget(val uri: String) : KMError() diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/PointKM.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/PointKM.kt new file mode 100644 index 0000000000..95c94580b7 --- /dev/null +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/PointKM.kt @@ -0,0 +1,10 @@ +package io.github.sds100.keymapper.common.utils + +import kotlinx.serialization.Serializable + +/** + * A Key Mapper point class that is serializable and can be used in unit tests, unlike + * android.graphics.Point. + */ +@Serializable +data class PointKM(val x: Int, val y: Int) diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/SizeKM.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/SizeKM.kt index 04e65dc7f7..e4f3fb3a75 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/SizeKM.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/SizeKM.kt @@ -1,9 +1,27 @@ package io.github.sds100.keymapper.common.utils +import kotlin.math.abs import kotlinx.serialization.Serializable /** * A Key Mapper size class that is serializable. */ @Serializable -data class SizeKM(val width: Int, val height: Int) +data class SizeKM(val width: Int, val height: Int) { + + /** + * Whether [other] has the same aspect ratio as this size, allowing for [other] to be + * rotated 90 degrees (e.g. portrait vs landscape). + */ + fun hasSameAspectRatio(other: SizeKM, epsilon: Float = 0.01f): Boolean { + if (width == 0 || height == 0 || other.width == 0 || other.height == 0) { + return false + } + + val ratio = width.toFloat() / height + val otherRatio = other.width.toFloat() / other.height + val otherRotatedRatio = other.height.toFloat() / other.width + + return abs(ratio - otherRatio) <= epsilon || abs(ratio - otherRotatedRatio) <= epsilon + } +} diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/StringUtils.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/StringUtils.kt index d9d37ff9db..2087e541e5 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/StringUtils.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/StringUtils.kt @@ -45,3 +45,12 @@ fun String.getWordBoundaries(cursorPosition: Int): Pair? { fun Float.toPercentString(): String { return "${(this * 100).roundToInt()}%" } + +/** + * Replace Windows (\r\n) and classic Mac (\r) line endings with Unix (\n) ones. A shell does not + * treat \r as whitespace so a trailing \r stops reserved words like "then" and "else" from being + * recognized, which breaks multi-line scripts pasted from a computer. See issue #2209. + */ +fun String.normalizeLineEndings(): String { + return replace("\r\n", "\n").replace('\r', '\n') +} diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index 8b0074dbb2..7d20d6dca6 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -75,6 +75,12 @@ data class ActionEntity( const val EXTRA_DND_MODE = "extra_do_not_disturb_mode" const val EXTRA_ORIENTATIONS = "extra_orientations" const val EXTRA_COORDINATE_DESCRIPTION = "extra_coordinate_description" + + /** + * The display size that the coordinates of a tap, swipe or pinch screen action were picked + * for, stored as a comma separated "width,height". See issue #2217. + */ + const val EXTRA_SCREEN_RESOLUTION = "extra_screen_resolution" const val EXTRA_INTENT_TARGET = "extra_intent_target" const val EXTRA_INTENT_DESCRIPTION = "extra_intent_description" const val EXTRA_SOUND_FILE_DESCRIPTION = "extra_sound_file_description" @@ -92,6 +98,7 @@ data class ActionEntity( const val EXTRA_NOTIFICATION_TITLE = "extra_notification_title" const val EXTRA_NOTIFICATION_TIMEOUT = "extra_notification_timeout" const val EXTRA_TOAST_DURATION = "extra_toast_duration" + const val EXTRA_STEP_MEDIA_DURATION = "extra_step_media_duration" // Accessibility node extras const val EXTRA_ACCESSIBILITY_PACKAGE_NAME = "extra_accessibility_package_name" diff --git a/fastlane/Fastfile b/fastlane/Fastfile index dac41aeb1b..bfe3fadcf8 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -103,7 +103,7 @@ lane :internal do version_code = get_properties_value(key: "VERSION_CODE", path: "./app/version.properties") version_name = get_properties_value(key: "VERSION_NAME", path: "./app/version.properties") - live_version_codes = google_play_track_version_codes(track: "alpha") + live_version_codes = google_play_track_version_codes(track: "internal") latest_live_version_code = live_version_codes.max || 0 if version_code.to_i <= latest_live_version_code diff --git a/gradle.properties b/gradle.properties index 6489c84142..01c27e9d08 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,3 +16,33 @@ android.nonFinalResIds=false android.nonTransitiveRClass=false android.useAndroidX=true org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" + +# The flags below opt out of behaviour changes introduced in AGP 9. Each one is +# here for a reason - see the comment above it before removing. + +# :app and :app-foss both declare the namespace "io.github.sds100.keymapper". +# AGP 9 rejects duplicate namespaces within a build, so this stays until one of +# the two application modules is given a namespace of its own. +android.uniquePackageNames=false + +# AGP 9 disallows in AndroidManifest.xml, but several manifests need +# it for tools:overrideLibrary: +# - app/ and foss/app/ override :sysbridge and :evdev, which are minSdk 29 +# while the apps are minSdk 26 +# - foss/base/ (main and test) overrides the Shizuku AARs +# Removing this requires dropping those modules to minSdk 26 and gating the +# newer APIs in code; the Shizuku override cannot be removed that way. +android.usesSdkInManifest.disallowed=false + +# We apply the Kotlin Gradle Plugin explicitly in every module, so AGP's +# built-in Kotlin support must stay off to avoid the two competing. +android.builtInKotlin=false + +# All build scripts use the pre-AGP 9 DSL. +android.newDsl=false + +# Both application modules ship minified and resource-shrunk release builds. +# The R8 behaviour changes in AGP 9 have not been validated against our +# ProGuard rules yet - remove these one at a time and smoke-test a release APK. +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 784eb823c6..f10514a86a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] -compile-sdk = "36" +compile-sdk = "37" min-sdk = "26" -build-tools = "36.1.0" -target-sdk = "36" +build-tools = "37.0.0" +target-sdk = "37" -android-gradle-plugin = "8.13.2" +android-gradle-plugin = "9.2.1" androidx-activity = "1.10.1" androidx-annotation = "1.9.1" androidx-appcompat = "1.7.0" @@ -20,11 +20,11 @@ androidx-lifecycle = "2.9.0" androidx-lifecycle-extensions = "2.2.0" # Note: lifecycle-extensions is deprecated androidx-multidex = "2.0.1" androidx-navigation = "2.9.6" # App level nav_version -androidx-navigation-safeargs-gradle-plugin = "2.6.0" # Project level nav_version +androidx-navigation-safeargs-gradle-plugin = "2.9.6" # Project level nav_version androidx-preference-ktx = "1.2.1" androidx-recyclerview = "1.4.0" androidx-room = "2.7.1" # room_version for dependencies -androidx-room-gradle-plugin = "2.6.1" # For plugin +androidx-room-gradle-plugin = "2.7.2" # For plugin androidx-test-core = "1.6.1" androidx-viewpager2 = "1.1.0" @@ -40,19 +40,20 @@ flexbox = "3.0.0" google-accompanist-drawablepainter = "0.35.0-alpha" hiddenapibypass = "4.3" introshowcaseview = "2.0.2" -conscrypt-android = "2.5.3" +# Needs to be 2.6 for 16KB alignment +conscrypt-android = "2.6.3" boringssl-ndk = "20250114" bouncycastle-bcpkix = "1.70" rikkax-core = "1.4.1" junit = "4.13.2" junit-params = "1.1.1" -kotlin = "2.1.0" +kotlin = "2.2.10" kotlin-serialization-json = "1.8.0" coroutines = "1.9.0" kotson = "2.5.0" -ksp-gradle-plugin = "2.1.0-1.0.28" +ksp-gradle-plugin = "2.3.2" ktlint-gradle = "13.1.0" #leakcanary = "2.6" # Commented out in original file lingala-zip4j = "2.8.0" @@ -65,7 +66,8 @@ mockito-inline = "5.2.0" mockito-kotlin = "4.0.0" okhttp = "4.12.0" -robolectric = "4.14.1" +# Need the beta for SDK 37 support +robolectric = "4.17-beta-4" shizuku = "13.1.5" splitties = "3.0.0" storage-anggrayudi = "0.8.1" @@ -77,7 +79,7 @@ ui-tooling = "1.8.1" # android.arch.persistence.room:testing libsu-core = "6.0.0" rikka-hidden = "4.4.0" -rust-android-gradle = "0.9.6" +rust-android-gradle = "0.10.0" [libraries] # Kotlin diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f6b961fd5a..eddabd2eef 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index eed656c6ea..d647be7f0d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sun Jun 20 18:26:00 BST 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index cccdd3d517..249efbb032 100755 --- a/gradlew +++ b/gradlew @@ -1,78 +1,128 @@ -#!/usr/bin/env sh +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,92 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" fi +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index f9553162f1..a51ec4f588 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,84 +1,82 @@ -@if "%DEBUG%" == "" @echo off +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 -:mainEnd -if "%OS%"=="Windows_NT" endlocal +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt index 3aaa0cbb74..21b524b80f 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt @@ -32,6 +32,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterIsInstance @@ -55,6 +56,13 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( companion object { private const val TAG = "SystemBridgeConnectionManagerImpl" private const val MIUI_OPTIMIZATION_SETTING = "miui_optimization" + + /** + * The shared storage that the starter files are copied to usually becomes available again + * within a minute, so stop retrying after that rather than trying forever. + */ + private const val REFRESH_STARTER_SCRIPT_ATTEMPTS = 6 + private const val REFRESH_STARTER_SCRIPT_RETRY_DELAY_MS = 10000L } private val systemBridgeLock: Any = Any() @@ -96,11 +104,27 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( // Refresh the starter script because the paths to the apk and libs may // have changed. coroutineScope.launch { - try { - starter.refreshStarterScript() - } catch (e: Exception) { - Timber.e("Failed to refresh system bridge starter script. $e") + repeat(REFRESH_STARTER_SCRIPT_ATTEMPTS) { attempt -> + if (attempt > 0) { + delay(REFRESH_STARTER_SCRIPT_RETRY_DELAY_MS) + } + + // There is nowhere to copy the starter files to while the shared storage is + // unavailable, so try again shortly instead of giving up. + if (!starter.canStartSystemBridge()) { + return@repeat + } + + try { + starter.refreshStarterScript() + } catch (e: Exception) { + Timber.e("Failed to refresh system bridge starter script. $e") + } + + return@launch } + + Timber.w("Gave up refreshing the system bridge starter script. Storage is unavailable.") } } @@ -289,6 +313,10 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( override suspend fun getShellStartCommand(): KMResult { return starter.getStartCommand() } + + override fun canStartSystemBridge(): Boolean { + return starter.canStartSystemBridge() + } } @SuppressLint("ObsoleteSdkInt") @@ -309,6 +337,13 @@ interface SystemBridgeConnectionManager { suspend fun startWithAdb() suspend fun getShellStartCommand(): KMResult + + /** + * Whether starting the system bridge can be attempted at all. This is false while there is + * nowhere to copy the starter files to because the shared storage is unavailable, which can + * happen at any point while the device is running. + */ + fun canStartSystemBridge(): Boolean } fun SystemBridgeConnectionManager.isConnected(): Boolean { diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt index 8cb911dda8..d65f35a33a 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupController.kt @@ -318,11 +318,17 @@ class SystemBridgeSetupControllerImpl @Inject constructor( val packageName = "com.android.settings" setPackage(packageName) + val qsTileClass = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + "com.android.settings.development.qstile.AdbWirelessDebuggingDevelopmentTile" + } else { + "com.android.settings.development.qstile.DevelopmentTiles\$WirelessDebugging" + } + putExtra( Intent.EXTRA_COMPONENT_NAME, ComponentName( packageName, - "com.android.settings.development.qstile.DevelopmentTiles\$WirelessDebugging", + qsTileClass, ), ) @@ -396,7 +402,7 @@ class SystemBridgeSetupControllerImpl @Inject constructor( private fun getKeyMapperAppTask(): ActivityManager.AppTask? { val task = activityManager.appTasks ?.firstOrNull { - it.taskInfo.topActivity?.className == + it.taskInfo?.topActivity?.className == keyMapperClassProvider.getMainActivity().name } return task diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupStep.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupStep.kt index f2b8478545..81733fe629 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupStep.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridgeSetupStep.kt @@ -3,10 +3,11 @@ package io.github.sds100.keymapper.sysbridge.service enum class SystemBridgeSetupStep(val stepIndex: Int) { ACCESSIBILITY_SERVICE(stepIndex = 0), NOTIFICATION_PERMISSION(stepIndex = 1), - DEVELOPER_OPTIONS(stepIndex = 2), - WIFI_NETWORK(stepIndex = 3), - WIRELESS_DEBUGGING(stepIndex = 4), - ADB_PAIRING(stepIndex = 5), - START_SERVICE(stepIndex = 6), - STARTED(stepIndex = 7), + ACCESS_LOCAL_NETWORK_PERMISSION(stepIndex = 2), + DEVELOPER_OPTIONS(stepIndex = 3), + WIFI_NETWORK(stepIndex = 4), + WIRELESS_DEBUGGING(stepIndex = 5), + ADB_PAIRING(stepIndex = 6), + START_SERVICE(stepIndex = 7), + STARTED(stepIndex = 8), } diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt index f1b15f0f7c..aac0f1608c 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/starter/SystemBridgeStarter.kt @@ -267,6 +267,15 @@ class SystemBridgeStarter @Inject constructor( writeStarterScript() } + /** + * Whether starting the system bridge can be attempted at all. This is false while there is + * nowhere to copy the starter files to because the shared storage is unavailable, which can + * happen at any point while the device is running. + */ + fun canStartSystemBridge(): Boolean { + return getStarterFilesDirectory() != null + } + /** * Get the shell command that can be used to start the system bridge manually. * This command should be executed with 'adb shell'. @@ -276,28 +285,37 @@ class SystemBridgeStarter @Inject constructor( } private suspend fun writeStarterScript(): KMResult { - val directory = if (buildConfigProvider.sdkInt > Build.VERSION_CODES.R) { - try { + val directory = getStarterFilesDirectory() ?: return KMError.StarterFilesUnavailable + + return copyStarterFiles(directory) + } + + /** + * @return The directory to copy the starter files to, or null if it is unavailable. + * getExternalFilesDir returns null while the shared storage is not mounted, which can happen + * at any point while the device is running and usually resolves itself within a minute. + */ + private fun getStarterFilesDirectory(): File? { + if (buildConfigProvider.sdkInt > Build.VERSION_CODES.R) { + return try { ctx.getExternalFilesDir(null)?.parentFile } catch (e: IOException) { - return KMError.UnknownIOError - } - } else { - // Adb on Android 11 has no permission to access Android/data so use /data/user_de. - val protectedStorageDir = - ctx.createDeviceProtectedStorageContext().filesDir.parentFile!! - - try { - // 0711 - Os.chmod(protectedStorageDir.absolutePath, 457) - } catch (e: ErrnoException) { - e.printStackTrace() + null } + } + + // Adb on Android 11 has no permission to access Android/data so use /data/user_de. + val protectedStorageDir = + ctx.createDeviceProtectedStorageContext().filesDir.parentFile!! - protectedStorageDir + try { + // 0711 + Os.chmod(protectedStorageDir.absolutePath, 457) + } catch (e: ErrnoException) { + e.printStackTrace() } - return copyStarterFiles(directory!!) + return protectedStorageDir } /** diff --git a/system/src/main/AndroidManifest.xml b/system/src/main/AndroidManifest.xml index 5825ce9263..adfddf9666 100644 --- a/system/src/main/AndroidManifest.xml +++ b/system/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + diff --git a/system/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt index 40fea585c5..3d4fc1e60c 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt @@ -13,7 +13,6 @@ import android.content.IntentFilter import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable -import android.net.Uri import android.os.BadParcelableException import android.os.Build import android.os.RemoteException @@ -23,6 +22,7 @@ import android.provider.Settings import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import androidx.core.content.pm.PackageInfoCompat +import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult @@ -103,7 +103,7 @@ class AndroidPackageManagerAdapter @Inject constructor( val packages = withContext(Dispatchers.Default) { try { - packageManager.getInstalledApplications(PackageManager.GET_META_DATA) + packageManager.getInstalledApplications(0) .mapNotNull { createPackageInfoModel(it) } } catch (_: BadParcelableException) { emptyList() @@ -134,13 +134,13 @@ class AndroidPackageManagerAdapter @Inject constructor( override fun downloadApp(packageName: String) { try { val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse("market://details?id=$packageName") + intent.data = "market://details?id=$packageName".toUri() intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK ctx.startActivity(intent) } catch (e: ActivityNotFoundException) { val intent = Intent(Intent.ACTION_VIEW) - intent.data = Uri.parse("https://play.google.com/store/apps/details?id=$packageName") + intent.data = "https://play.google.com/store/apps/details?id=$packageName".toUri() intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK ctx.startActivity(intent) @@ -186,7 +186,7 @@ class AndroidPackageManagerAdapter @Inject constructor( override fun enableApp(packageName: String) { Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.parse("package:$packageName") + data = "package:$packageName".toUri() flags = Intent.FLAG_ACTIVITY_NO_HISTORY or Intent.FLAG_ACTIVITY_NEW_TASK ctx.startActivity(this) @@ -338,7 +338,7 @@ class AndroidPackageManagerAdapter @Inject constructor( override fun getPackageInfo(packageName: String): PackageInfo? { try { val applicationInfo = - packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA) + packageManager.getApplicationInfo(packageName, 0) return createPackageInfoModel(applicationInfo) } catch (e: PackageManager.NameNotFoundException) { @@ -411,7 +411,7 @@ class AndroidPackageManagerAdapter @Inject constructor( val packageInfo = packageManager.getPackageInfo( packageName, - PackageManager.GET_ACTIVITIES or PackageManager.GET_META_DATA, + PackageManager.GET_ACTIVITIES, ) if (packageInfo == null) { diff --git a/system/src/main/java/io/github/sds100/keymapper/system/files/AndroidFileAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/files/AndroidFileAdapter.kt index 04a151647b..cbd1c1e684 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/files/AndroidFileAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/files/AndroidFileAdapter.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.system.files import android.content.ContentResolver +import android.content.ContentUris import android.content.ContentValues import android.content.Context import android.os.Build @@ -83,6 +84,55 @@ class AndroidFileAdapter @Inject constructor( override fun getPicturesFolder(): String = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).path + override fun getDownloads(): KMResult> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + Environment.isExternalStorageManager() + ) { + val downloadsDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + + val files = downloadsDir.listFiles() + .orEmpty() + .map { file -> DocumentFileWrapper(DocumentFile.fromFile(file), ctx) } + .sortedBy { it.name } + + return Success(files) + } + + // MediaStore.Downloads didn't exist before Q, and apps can only read entries + // they own without MANAGE_EXTERNAL_STORAGE, so there's nothing to list. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return Success(emptyList()) + } + + val files = mutableListOf() + + contentResolver.query( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.OWNER_PACKAGE_NAME} = ?", + arrayOf(buildConfigProvider.packageName), + "${MediaStore.MediaColumns.DATE_MODIFIED} DESC", + )?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + + while (cursor.moveToNext()) { + val uri = ContentUris.withAppendedId( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + cursor.getLong(idColumn), + ) + + DocumentFile.fromSingleUri(ctx, uri)?.let { + files.add(DocumentFileWrapper(it, ctx)) + } + } + } + + files.sortBy { it.name } + + return Success(files) + } + override fun createZipFile(destination: IFile, files: Set): KMResult<*> { val zipUid = UUID.randomUUID().toString() diff --git a/system/src/main/java/io/github/sds100/keymapper/system/files/DocumentFileWrapper.kt b/system/src/main/java/io/github/sds100/keymapper/system/files/DocumentFileWrapper.kt index 45a26f5be2..586de5c68a 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/files/DocumentFileWrapper.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/files/DocumentFileWrapper.kt @@ -34,13 +34,13 @@ class DocumentFileWrapper(val file: DocumentFile, context: Context) : IFile { get() = file.getAbsolutePath(ctx) override val name: String? - get() = file.name + get() = file.name ?: file.uri.lastPathSegment override val baseName: String? - get() = file.name?.substringBeforeLast('.') + get() = name?.substringBeforeLast('.') override val extension: String? - get() = file.name?.substringAfterLast('.') + get() = name?.substringAfterLast('.') override val isDirectory: Boolean get() = file.isDirectory || toJavaFile().isDirectory @@ -105,20 +105,26 @@ class DocumentFileWrapper(val file: DocumentFile, context: Context) : IFile { val error = when (errorCode) { ErrorCode.STORAGE_PERMISSION_DENIED -> KMError.StoragePermissionDenied + ErrorCode.CANNOT_CREATE_FILE_IN_TARGET -> KMError.CannotCreateFileInTarget(directory.uri) ErrorCode.SOURCE_FILE_NOT_FOUND -> KMError.SourceFileNotFound(this@DocumentFileWrapper.uri) + ErrorCode.TARGET_FILE_NOT_FOUND -> KMError.TargetFileNotFound(directory.uri) + ErrorCode.TARGET_FOLDER_NOT_FOUND -> KMError.TargetDirectoryNotFound(directory.uri) ErrorCode.UNKNOWN_IO_ERROR -> KMError.UnknownIOError + ErrorCode.CANCELED -> KMError.FileOperationCancelled + ErrorCode.TARGET_FOLDER_CANNOT_HAVE_SAME_PATH_WITH_SOURCE_FOLDER -> KMError.TargetDirectoryMatchesSourceDirectory + ErrorCode.NO_SPACE_LEFT_ON_TARGET_PATH -> KMError.NoSpaceLeftOnTarget( directory.uri, ) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/files/FileAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/files/FileAdapter.kt index fa7d4aa035..78d51d4453 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/files/FileAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/files/FileAdapter.kt @@ -9,6 +9,14 @@ interface FileAdapter { fun getPicturesFolder(): String fun openDownloadsFile(fileName: String, mimeType: String): KMResult + /** + * Lists the files currently in the Downloads folder. If MANAGE_EXTERNAL_STORAGE + * is granted, this lists everything in the public Downloads directory. Otherwise it + * only lists files this app itself previously wrote there via [openDownloadsFile], + * which needs no permission. + */ + fun getDownloads(): KMResult> + fun getPrivateFile(path: String): IFile fun getFile(parent: IFile, path: String): IFile fun getFileFromUri(uri: String): IFile diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt index e1c6b70e18..59d04d2a8d 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt @@ -182,4 +182,37 @@ class AndroidInputMethodAdapter @Inject constructor( Success(imeId) } } + + override fun cycleInputMethodSubtype(): KMResult { + val chosenImeId = getChosenImeId() + + val inputMethodInfo = inputMethodManager.inputMethodList.find { it.id == chosenImeId } + ?: return KMError.InputMethodNotFound(chosenImeId) + + val subtypes = + inputMethodManager.getEnabledInputMethodSubtypeList(inputMethodInfo, true) + + if (subtypes.size < 2) { + return KMError.NotEnoughInputMethodSubtypes + } + + val currentSubtypeHashCode = Settings.Secure.getInt( + ctx.contentResolver, + Settings.Secure.SELECTED_INPUT_METHOD_SUBTYPE, + -1, + ) + + val currentIndex = subtypes.indexOfFirst { it.hashCode() == currentSubtypeHashCode } + val nextSubtype = subtypes[(currentIndex + 1) % subtypes.size] + + return try { + @Suppress("DEPRECATION") + inputMethodManager.currentInputMethodSubtype = nextSubtype + + Success(Unit) + } catch (e: Exception) { + Timber.e(e, "Failed to cycle input method subtype") + KMError.CycleImeSubtypeFailed + } + } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt index 840038c1ea..a072a7d949 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt @@ -17,4 +17,10 @@ interface InputMethodAdapter { val chosenIme: StateFlow fun getChosenIme(): ImeInfo? + + /** + * Switches the chosen input method to the next enabled subtype (language), wrapping + * around to the first subtype after the last. + */ + fun cycleInputMethodSubtype(): KMResult } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/media/AndroidMediaAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/media/AndroidMediaAdapter.kt index 902f85ed7d..be377898e5 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/media/AndroidMediaAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/media/AndroidMediaAdapter.kt @@ -173,24 +173,24 @@ class AndroidMediaAdapter @Inject constructor( } } - override fun stepForward(packageName: String?): KMResult<*> { + override fun stepForward(packageName: String?, durationMs: Long?): KMResult<*> { val session = getPackageMediaSession(packageName) ?: return KMError.NoMediaSessions if (session.isPlaybackActionSupported(PlaybackState.ACTION_SEEK_TO)) { val position = session.playbackState?.position ?: return KMError.NoMediaSessions - session.transportControls.seekTo(position + SEEK_AMOUNT) + session.transportControls.seekTo(position + (durationMs ?: SEEK_AMOUNT)) return Success(Unit) } else { return sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_STEP_FORWARD, packageName) } } - override fun stepBackward(packageName: String?): KMResult<*> { + override fun stepBackward(packageName: String?, durationMs: Long?): KMResult<*> { val session = getPackageMediaSession(packageName) ?: return KMError.NoMediaSessions if (session.isPlaybackActionSupported(PlaybackState.ACTION_SEEK_TO)) { val position = session.playbackState?.position ?: return KMError.NoMediaSessions - session.transportControls.seekTo(max(0, position - SEEK_AMOUNT)) + session.transportControls.seekTo(max(0, position - (durationMs ?: SEEK_AMOUNT))) return Success(Unit) } else { return sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_STEP_BACKWARD, packageName) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/media/MediaAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/media/MediaAdapter.kt index 833058281e..338de714fc 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/media/MediaAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/media/MediaAdapter.kt @@ -33,8 +33,8 @@ interface MediaAdapter { fun previousTrack(packageName: String? = null): KMResult<*> fun nextTrack(packageName: String? = null): KMResult<*> fun stop(packageName: String? = null): KMResult<*> - fun stepForward(packageName: String? = null): KMResult<*> - fun stepBackward(packageName: String? = null): KMResult<*> + fun stepForward(packageName: String? = null, durationMs: Long? = null): KMResult<*> + fun stepBackward(packageName: String? = null, durationMs: Long? = null): KMResult<*> fun playFile(uri: String, stream: VolumeStream): KMResult<*> fun stopFileMedia(): KMResult<*> diff --git a/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt index fe7f925e70..503908e0ab 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt @@ -8,6 +8,7 @@ import android.content.Context import android.content.pm.IPackageManager import android.content.pm.PackageManager.PERMISSION_GRANTED import android.os.Build +import android.os.Environment import android.os.PowerManager import android.os.Process import android.permission.IPermissionManager @@ -335,6 +336,19 @@ class AndroidPermissionAdapter @Inject constructor( Manifest.permission.READ_LOGS, ) == PERMISSION_GRANTED } + + Permission.ACCESS_LOCAL_NETWORK -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { + ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.ACCESS_LOCAL_NETWORK, + ) == PERMISSION_GRANTED + } else { + true + } + + Permission.MANAGE_EXTERNAL_STORAGE -> + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager() } override fun isGrantedFlow(permission: Permission): Flow = channelFlow { diff --git a/system/src/main/java/io/github/sds100/keymapper/system/permissions/Permission.kt b/system/src/main/java/io/github/sds100/keymapper/system/permissions/Permission.kt index f243a54b0c..d1cf95aa00 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/permissions/Permission.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/permissions/Permission.kt @@ -18,4 +18,13 @@ enum class Permission { FIND_NEARBY_DEVICES, POST_NOTIFICATIONS, READ_LOGS, + ACCESS_LOCAL_NETWORK, + + /** + * Gives access to read all external files. Only requested + * on FOSS build because Google Play usually only permit it for + * file manager applications. This is needed on Android TV because + * it stubs the APIs for creating/reading documents. + */ + MANAGE_EXTERNAL_STORAGE, }