From aa735fd543ee6a69104add06c7e5cbc37fda49d1 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Aug 2026 12:57:33 +0200 Subject: [PATCH 01/46] bump version to 4.3.2 --- app/version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/version.properties b/app/version.properties index 3a7f8f09ad..61477936f4 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=4.3.1 -VERSION_CODE=259 +VERSION_NAME=4.3.2 +VERSION_CODE=260 From 9c3a3a867c5f52176aecbc217f4e1217e8de905b Mon Sep 17 00:00:00 2001 From: Seth Schroeder Date: Mon, 31 Aug 2026 14:07:06 +0200 Subject: [PATCH 02/46] Update README.md --- README.md | 4 ++++ 1 file changed, 4 insertions(+) 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! From f807aba736e6126335968d9fa7f99fd4e98d1b63 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Aug 2026 16:08:59 +0200 Subject: [PATCH 03/46] #2099 do not spam notifications that Expert mode failed to start on WiFi disconnection or the ADB pairing is broken --- CHANGELOG.md | 12 +- base/build.gradle.kts | 6 + .../expertmode/SystemBridgeAutoStarter.kt | 25 ++- base/src/test/AndroidManifest.xml | 10 ++ .../expertmode/SystemBridgeAutoStarterTest.kt | 145 ++++++++++++++++-- 5 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 base/src/test/AndroidManifest.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e70b59cba..d6c129ec21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [4.3.2](https://github.com/sds100/KeyMapper/releases/tag/v4.3.2) + +#### TO BE RELEASED + +## Fixed + +- #2099 do not spam notifications that Expert mode failed to start on WiFi disconnection or the ADB + pairing is broken. + ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) #### 11 August 2026 @@ -21,7 +30,8 @@ ## Added -- #2184 Add a display resolution constraint. Pick from the display's supported resolutions or enter a +- #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. diff --git a/base/build.gradle.kts b/base/build.gradle.kts index f9118e6def..e27e5c1159 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -68,6 +68,12 @@ android { composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { 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..24bb771769 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") 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/expertmode/SystemBridgeAutoStarterTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeAutoStarterTest.kt index 39f34d3b4a..768bf0522a 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 @@ -33,9 +35,12 @@ import org.hamcrest.CoreMatchers.`is` import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.closeTo 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 +48,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 +75,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 @@ -119,9 +130,7 @@ class SystemBridgeAutoStarterTest { mockNotificationAdapter = mock() - mockResourceProvider = mock { - on { getString(any()) } doReturn "test_string" - } + resourceProvider = ResourceProviderImpl(ApplicationProvider.getApplicationContext()) testScopeClock = TestScopeClock(testCoroutineScope) @@ -138,11 +147,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) @@ -482,8 +501,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 +641,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 +662,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 +808,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() + } + } + } } From 5f9752830bdfac1a05ae830b6f83b29978b7fbd5 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Aug 2026 16:50:49 +0200 Subject: [PATCH 04/46] #2220 fix: make invisible floating buttons more visible when editing --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6c129ec21..ec525c1d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - #2099 do not spam notifications that Expert mode failed to start on WiFi disconnection or the ADB pairing is broken. +- #2220 make invisible floating buttons more visible when editing. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) From a44db1fccec848fb100c4f93e99fca376756b040 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Aug 2026 16:56:29 +0200 Subject: [PATCH 05/46] fix: expert mode works on 16KB page size systems. --- CHANGELOG.md | 1 + gradle/libs.versions.toml | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec525c1d0a..6df483358c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - #2099 do not spam notifications that Expert mode failed to start on WiFi disconnection or the ADB pairing is broken. - #2220 make invisible floating buttons more visible when editing. +- Expert mode works on 16KB page size systems. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 784eb823c6..792cff064b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,7 +40,8 @@ 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" From a3241eefc51b75f20689bae71fe98734571699fb Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Aug 2026 17:05:09 +0200 Subject: [PATCH 06/46] fix: launching Wireless Debugging screen for Expert Mode setup works on Android 17+ --- CHANGELOG.md | 1 + gradle/libs.versions.toml | 6 +++--- .../sysbridge/service/SystemBridgeSetupController.kt | 10 ++++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df483358c..8f00161278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ pairing is broken. - #2220 make invisible floating buttons more visible when editing. - Expert mode works on 16KB page size systems. +- Launching Wireless Debugging screen for Expert Mode setup works on Android 17+. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 792cff064b..ffac346d02 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [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" androidx-activity = "1.10.1" 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 From 72923675127f28f4b5e4b00bef42f90104790190 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Aug 2026 17:08:15 +0200 Subject: [PATCH 07/46] fix compilation error --- .../base/expertmode/SystemBridgeSetupAssistantController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..fe00d4bbc6 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 @@ -392,7 +392,7 @@ 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 } From 86dfe5e51c0a5a2f6974190ef3539d349f7698f1 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Aug 2026 17:08:42 +0200 Subject: [PATCH 08/46] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f00161278..863128ca82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ #### TO BE RELEASED +## Added + +- Target Android 17 SDK. + ## Fixed - #2099 do not spam notifications that Expert mode failed to start on WiFi disconnection or the ADB From e45856ac0457e27585e46afd014bb1a835cdf211 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 31 Aug 2026 17:41:06 +0200 Subject: [PATCH 09/46] update gradle version and Android Gradle Plugin --- gradle.properties | 30 +++ gradle/libs.versions.toml | 12 +- gradle/wrapper/gradle-wrapper.jar | Bin 54329 -> 47505 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 302 ++++++++++++++--------- gradlew.bat | 100 ++++---- 6 files changed, 275 insertions(+), 171 deletions(-) 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 ffac346d02..3f8b173699 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ min-sdk = "26" 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" @@ -48,12 +48,12 @@ 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" @@ -78,7 +78,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 f6b961fd5a86aa5fbfe90f707c3138408be7c718..eddabd2eef8d94a5437d6168ff9c87a78ff725b3 100644 GIT binary patch literal 47505 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

<5G2M1WZP5 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 F{|7XC?P&l2 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% From b375aa86acd13550a1377cff512557b9c29e3494 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 1 Sep 2026 12:35:13 +0200 Subject: [PATCH 10/46] update Roboelectric version for SDK 37 support --- base/build.gradle.kts | 15 +++++++++++++++ gradle/libs.versions.toml | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/base/build.gradle.kts b/base/build.gradle.kts index e27e5c1159..8b0bf62100 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -73,6 +73,21 @@ android { 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", + ) + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3f8b173699..f10514a86a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,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" From 2fd58120a6868d49269bdbd1dc4f492dade6974f Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 1 Sep 2026 13:42:37 +0200 Subject: [PATCH 11/46] upgrade github actions workflows to JDK 21 so roboelectric works on SDK 37 --- .github/workflows/pull-request.yml | 18 +++++++++--------- .github/workflows/testing.yml | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) 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 From f5247a825bb35256e85a982c552c371441815d8c Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 2 Sep 2026 12:57:41 +0200 Subject: [PATCH 12/46] #2209 fix: multi-line shell command actions no longer fail with a syntax error when the script is pasted with Windows line endings --- CHANGELOG.md | 1 + .../actions/ConfigShellCommandViewModel.kt | 9 +- .../actions/ExecuteShellCommandUseCase.kt | 27 ++- .../ConfigShellCommandViewModelTest.kt | 26 +++ .../actions/ExecuteShellCommandUseCaseTest.kt | 164 ++++++++++++++++++ .../keymapper/common/utils/StringUtils.kt | 9 + 6 files changed, 228 insertions(+), 8 deletions(-) create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/actions/ExecuteShellCommandUseCaseTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 863128ca82..1a93b1cea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - #2099 do not spam notifications that Expert mode failed to start on WiFi disconnection or the ADB pairing is broken. - #2220 make invisible floating buttons more visible when editing. +- #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+. 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/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/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/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') +} From 4c34bad159649edfebefaaa39426e54c168bc8ed Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 2 Sep 2026 15:55:11 +0200 Subject: [PATCH 13/46] #2219 fix: scale floating buttons when screen resolution changes. Before, it was only doing it when the orientation did not match, but even for the same orientation it should do it because Samsung devices can change the resolution dynamically for example. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a93b1cea7..439dcf2b54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - #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 fix: scale floating buttons when screen resolution changes. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) From 77c6696b70e1f0e5a3c3483802163d6b6ad23b4f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 00:42:09 +0000 Subject: [PATCH 14/46] #2160 fix: keep send intent activity edits across screen recreation ConfigIntentFragment applies its 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. Re-applying the original argument discarded any edits the user had made, such as changing the chosen activity, so the activity appeared to revert to its original value. Apply the initial argument only once so a recreation no longer overwrites the in-progress edits. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FLMhZgAZLKvtgPGKXC9oKq --- CHANGELOG.md | 2 + .../system/intents/ConfigIntentViewModel.kt | 16 ++++ .../ConfigIntentViewModelRecreationTest.kt | 83 +++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/system/intents/ConfigIntentViewModelRecreationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 439dcf2b54..1cbf8e6a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Fixed +- #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 do not spam notifications that Expert mode failed to start on WiFi disconnection or the ADB pairing is broken. - #2220 make invisible floating buttons more visible when editing. 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..8c5a7e3bd5 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,15 @@ class ConfigIntentViewModel @Inject constructor( private val _returnResult = MutableSharedFlow() val returnResult = _returnResult.asSharedFlow() + /** + * Whether the initial [ConfigIntentResult] argument has already been applied. The fragment + * calls [loadResult] from onCreate, which runs again when the screen is recreated (for example + * on a configuration change) while this ViewModel survives. Re-applying the original argument + * then would discard the edits the user has made in the meantime, such as changing the chosen + * activity. See issue #2160. + */ + private var isResultLoaded = false + fun setActivityTargetChecked(isChecked: Boolean) { if (isChecked) { target.value = IntentTarget.ACTIVITY @@ -402,6 +411,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 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!! + } +} From 12841d7cc8ab90af0812e21f2cf9f7bbafdff8a7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 4 Sep 2026 21:11:21 +0200 Subject: [PATCH 15/46] #2217 fix: scale tap, swipe and pinch screen actions to the display resolution Samsung devices let you change the display resolution dynamically, which left the coordinate actions tapping the wrong place. The resolution the coordinates were picked for is now saved with the action and they are scaled to the current display size when the action is performed. The pinch distance is scaled too. If the saved resolution is in the opposite orientation to the current display then its width and height are swapped before scaling, so rotating the device on its own never moves a coordinate. Actions created before this change have no saved resolution and are never scaled. --- CHANGELOG.md | 2 + .../keymapper/base/actions/ActionData.kt | 23 ++- .../base/actions/ActionDataEntityMapper.kt | 54 +++++- .../base/actions/CreateActionDelegate.kt | 6 + .../base/actions/PerformActionsUseCase.kt | 105 ++++++++++- .../pinchscreen/PinchPickCoordinateResult.kt | 5 + .../PinchPickDisplayCoordinateFragment.kt | 9 +- .../PinchPickDisplayCoordinateViewModel.kt | 34 +++- .../swipescreen/SwipePickCoordinateResult.kt | 5 + .../SwipePickDisplayCoordinateFragment.kt | 9 +- .../SwipePickDisplayCoordinateViewModel.kt | 33 +++- .../actions/tapscreen/PickCoordinateResult.kt | 11 +- .../PickDisplayCoordinateFragment.kt | 9 +- .../PickDisplayCoordinateViewModel.kt | 36 +++- .../actions/ActionDataEntityMapperTest.kt | 131 +++++++++++++ .../base/actions/PerformActionsUseCaseTest.kt | 174 +++++++++++++++++- .../actions/ScreenCoordinateScalingTest.kt | 172 +++++++++++++++++ .../sds100/keymapper/common/utils/PointKM.kt | 10 + .../keymapper/data/entities/ActionEntity.kt | 6 + 19 files changed, 786 insertions(+), 48 deletions(-) create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt create mode 100644 base/src/test/java/io/github/sds100/keymapper/base/actions/ScreenCoordinateScalingTest.kt create mode 100644 common/src/main/java/io/github/sds100/keymapper/common/utils/PointKM.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 439dcf2b54..a8ef550ff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ - Expert mode works on 16KB page size systems. - Launching Wireless Debugging screen for Expert Mode setup works on Android 17+. - #2219 fix: scale floating buttons when screen resolution changes. +- #2217 tap, swipe and pinch screen actions now scale to the current display resolution, including + the pinch distance. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) 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..37421a29f1 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 @@ -457,7 +458,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 +492,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 +526,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..a813250fd2 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), ) } @@ -918,6 +926,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 @@ -1202,18 +1242,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() 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..46d453f44d 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 @@ -778,6 +778,7 @@ class CreateActionDelegate( oldData.x, oldData.y, oldData.description ?: "", + oldData.screenResolution, ) } else { null @@ -794,6 +795,7 @@ class CreateActionDelegate( result.x, result.y, description, + result.screenResolution, ) } @@ -807,6 +809,7 @@ class CreateActionDelegate( oldData.fingerCount, oldData.duration, oldData.description ?: "", + oldData.screenResolution, ) } else { null @@ -827,6 +830,7 @@ class CreateActionDelegate( result.fingerCount, result.duration, description, + result.screenResolution, ) } @@ -840,6 +844,7 @@ class CreateActionDelegate( oldData.fingerCount, oldData.duration, oldData.description ?: "", + oldData.screenResolution, ) } else { null @@ -860,6 +865,7 @@ class CreateActionDelegate( result.fingerCount, result.duration, description, + result.screenResolution, ) } 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..a764610ffe 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 @@ -352,15 +355,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 +385,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, @@ -1226,6 +1249,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/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..cceaad7fd1 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 @@ -15,6 +14,8 @@ import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog import io.github.sds100.keymapper.common.utils.PinchScreenType +import io.github.sds100.keymapper.common.utils.SizeKM +import io.github.sds100.keymapper.system.display.DisplayAdapter import javax.inject.Inject import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableSharedFlow @@ -30,6 +31,7 @@ import kotlinx.coroutines.launch @HiltViewModel class PinchPickDisplayCoordinateViewModel @Inject constructor( + private val displayAdapter: DisplayAdapter, resourceProvider: ResourceProvider, dialogProvider: DialogProvider, ) : ViewModel(), @@ -50,6 +52,15 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( private val description: MutableStateFlow = MutableStateFlow(null) + /** + * The display size that the coordinates and distance are for. See issue #2217. This is the size + * of the screenshot if one is chosen because the coordinates are in the screenshot's pixel + * space, otherwise the resolution of the action being edited, otherwise the current display + * size. + */ + private val screenshotResolution: MutableStateFlow = MutableStateFlow(null) + private val loadedResolution: MutableStateFlow = MutableStateFlow(null) + val xString = x.map { it ?: return@map "" @@ -164,10 +175,12 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( isCoordinatesValid && fingerCountError == null && durationError == null }.stateIn(viewModelScope, SharingStarted.Lazily, false) - fun selectedScreenshot(newBitmap: Bitmap, displaySize: Point) { + fun selectedScreenshot(newBitmap: Bitmap) { + val displaySize = displayAdapter.size + // 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) + if ((displaySize.width != newBitmap.width && displaySize.height != newBitmap.height) && + (displaySize.height != newBitmap.width && displaySize.width != newBitmap.height) ) { viewModelScope.launch { val snackBar = DialogModel.SnackBar( @@ -180,6 +193,7 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( return } + screenshotResolution.value = SizeKM(newBitmap.width, newBitmap.height) _bitmap.value = newBitmap } @@ -252,11 +266,22 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( fingerCount, duration, description, + screenResolution(), ), ) } } + /** + * 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. + */ + private fun screenResolution(): SizeKM { + return screenshotResolution.value ?: loadedResolution.value ?: displayAdapter.size + } + fun onPinchTypeSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { this.setPinchType(pinchTypes[position]) } @@ -270,6 +295,7 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( fingerCount.value = result.fingerCount duration.value = result.duration description.value = result.description + loadedResolution.value = result.screenResolution } } 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..d5a403d8ad 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,7 +2,6 @@ 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 @@ -11,6 +10,8 @@ import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.common.utils.SizeKM +import io.github.sds100.keymapper.system.display.DisplayAdapter import javax.inject.Inject import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableSharedFlow @@ -31,6 +32,7 @@ enum class ScreenshotTouchType { @HiltViewModel class SwipePickDisplayCoordinateViewModel @Inject constructor( + private val displayAdapter: DisplayAdapter, resourceProvider: ResourceProvider, dialogProvider: DialogProvider, ) : ViewModel(), @@ -52,6 +54,14 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( private val description: MutableStateFlow = MutableStateFlow(null) + /** + * The display size that the coordinates are for. See issue #2217. This is the size of the + * screenshot if one is chosen because the coordinates are in the screenshot's pixel space, + * otherwise the resolution of the action being edited, otherwise the current display size. + */ + private val screenshotResolution: MutableStateFlow = MutableStateFlow(null) + private val loadedResolution: MutableStateFlow = MutableStateFlow(null) + val xStartString = xStart.map { it ?: return@map "" @@ -157,12 +167,14 @@ 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 + val displaySize = displayAdapter.size + // 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) + if ((displaySize.width != newBitmap.width && displaySize.height != newBitmap.height) && + (displaySize.height != newBitmap.width && displaySize.width != newBitmap.height) ) { viewModelScope.launch { val snackBar = DialogModel.SnackBar( @@ -175,6 +187,7 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( return } + screenshotResolution.value = SizeKM(newBitmap.width, newBitmap.height) _bitmap.value = newBitmap } @@ -252,11 +265,22 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( fingerCount, duration, description, + screenResolution(), ), ) } } + /** + * 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. + */ + private fun screenResolution(): SizeKM { + return screenshotResolution.value ?: loadedResolution.value ?: displayAdapter.size + } + fun loadResult(result: SwipePickCoordinateResult) { viewModelScope.launch { xStart.value = result.xStart @@ -266,6 +290,7 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( fingerCount.value = result.fingerCount duration.value = result.duration description.value = result.description + loadedResolution.value = result.screenResolution } } 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..4ed4091571 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,7 +1,6 @@ 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 @@ -10,6 +9,8 @@ import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.common.utils.SizeKM +import io.github.sds100.keymapper.system.display.DisplayAdapter import javax.inject.Inject import kotlin.math.roundToInt import kotlinx.coroutines.flow.MutableSharedFlow @@ -25,6 +26,7 @@ import kotlinx.coroutines.launch @HiltViewModel class PickDisplayCoordinateViewModel @Inject constructor( + private val displayAdapter: DisplayAdapter, resourceProvider: ResourceProvider, dialogProvider: DialogProvider, ) : ViewModel(), @@ -61,10 +63,20 @@ class PickDisplayCoordinateViewModel @Inject constructor( private val description: MutableStateFlow = MutableStateFlow(null) - fun selectedScreenshot(newBitmap: Bitmap, displaySize: Point) { + /** + * The display size that the coordinate is for. See issue #2217. This is the size of the + * screenshot if one is chosen because the coordinate is in the screenshot's pixel space, + * otherwise the resolution of the action being edited, otherwise the current display size. + */ + private val screenshotResolution: MutableStateFlow = MutableStateFlow(null) + private val loadedResolution: MutableStateFlow = MutableStateFlow(null) + + fun selectedScreenshot(newBitmap: Bitmap) { + val displaySize = displayAdapter.size + // 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) + if ((displaySize.width != newBitmap.width && displaySize.height != newBitmap.height) && + (displaySize.height != newBitmap.width && displaySize.width != newBitmap.height) ) { viewModelScope.launch { val snackBar = DialogModel.SnackBar( @@ -77,6 +89,7 @@ class PickDisplayCoordinateViewModel @Inject constructor( return } + screenshotResolution.value = SizeKM(newBitmap.width, newBitmap.height) _bitmap.value = newBitmap } @@ -116,15 +129,28 @@ class PickDisplayCoordinateViewModel @Inject constructor( ), ) ?: return@launch - _returnResult.emit(PickCoordinateResult(x, y, description)) + _returnResult.emit( + PickCoordinateResult(x, y, description, screenResolution()), + ) } } + /** + * See issue #2217. Prefer the screenshot's resolution because the coordinate is 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. + */ + private fun screenResolution(): SizeKM { + return screenshotResolution.value ?: loadedResolution.value ?: displayAdapter.size + } + fun loadResult(result: PickCoordinateResult) { viewModelScope.launch { x.value = result.x y.value = result.y description.value = result.description + loadedResolution.value = result.screenResolution } } 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/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/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/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..3d3f5c060a 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" From 8c7d51451366d87835e134c01d6ffafc582c3631 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 6 Sep 2026 14:58:14 +0200 Subject: [PATCH 16/46] #2194 do not use tap screen for clicking pairing code button in Wireless Debugging settings This needs to be done before spinning out gestures into a separate accessibility service --- .../SystemBridgeSetupAssistantController.kt | 81 +++++++++++++++---- .../AccessibilityServiceUtils.kt | 45 +++++++++++ .../accessibility/BaseAccessibilityService.kt | 13 ++- 3 files changed, 124 insertions(+), 15 deletions(-) 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 fe00d4bbc6..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( @@ -398,4 +427,28 @@ class SystemBridgeSetupAssistantController @AssistedInject constructor( 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/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) From 28b425af25d38a414a9f90a1e6119c6dc36edd2f Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 7 Sep 2026 09:51:09 +0200 Subject: [PATCH 17/46] #2227 fix: request access local network permission on Android 17+ to start Expert Mode --- CHANGELOG.md | 1 + .../base/expertmode/ExpertModeSetupScreen.kt | 36 +++++++++++++ .../expertmode/SystemBridgeSetupDelegate.kt | 16 ++++++ .../expertmode/SystemBridgeSetupUseCase.kt | 50 +++++++++++++++--- .../permissions/RequestPermissionDelegate.kt | 6 +++ .../sds100/keymapper/base/utils/ErrorUtils.kt | 3 ++ base/src/main/res/values/strings.xml | 5 ++ .../SystemBridgeSetupUseCaseTest.kt | 52 +++++++++++++++++++ .../service/SystemBridgeSetupStep.kt | 13 ++--- system/src/main/AndroidManifest.xml | 1 + .../permissions/AndroidPermissionAdapter.kt | 10 ++++ .../system/permissions/Permission.kt | 1 + 12 files changed, 180 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 439dcf2b54..b3e75de0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## Added - Target Android 17 SDK. +- #2227 Add a step to the Expert Mode setup wizard to grant local network access permission, required for ADB on Android 17+. ## Fixed 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/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/system/permissions/RequestPermissionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt index 9fce955b46..f1378efe8a 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 @@ -130,6 +130,12 @@ 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) + } } } 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..ce7e9646dc 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,9 @@ 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 } resourceProvider.getString(resId) diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 5ec3580a01..3a0e79badd 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -922,6 +922,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! @@ -1900,6 +1901,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. 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/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/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/permissions/AndroidPermissionAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt index fe7f925e70..62cdfeb700 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 @@ -335,6 +335,16 @@ 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 + } } 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..62e2422491 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,5 @@ enum class Permission { FIND_NEARBY_DEVICES, POST_NOTIFICATIONS, READ_LOGS, + ACCESS_LOCAL_NETWORK, } From a075f46a9ff5d3208c74f92a2ca688a3feb9ae80 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 7 Sep 2026 16:23:50 +0200 Subject: [PATCH 18/46] #2160 simplify comments --- .../system/intents/ConfigIntentViewModel.kt | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) 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 8c5a7e3bd5..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,13 +233,6 @@ class ConfigIntentViewModel @Inject constructor( private val _returnResult = MutableSharedFlow() val returnResult = _returnResult.asSharedFlow() - /** - * Whether the initial [ConfigIntentResult] argument has already been applied. The fragment - * calls [loadResult] from onCreate, which runs again when the screen is recreated (for example - * on a configuration change) while this ViewModel survives. Re-applying the original argument - * then would discard the edits the user has made in the meantime, such as changing the chosen - * activity. See issue #2160. - */ private var isResultLoaded = false fun setActivityTargetChecked(isChecked: Boolean) { @@ -455,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", ) From 85784d625396638a1492a12af463ef2b5577ac98 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 8 Sep 2026 19:53:50 +0200 Subject: [PATCH 19/46] #2210 fix: Android TV DPAD center button behaves normally when accessibility service enabled. --- CHANGELOG.md | 1 + .../accessibility/BaseAccessibilityServiceController.kt | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fca90c0ea8..4ec0c0ae7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Expert mode works on 16KB page size systems. - Launching Wireless Debugging screen for Expert Mode setup works on Android 17+. - #2219 fix: scale floating buttons when screen resolution changes. +- #2210 Android TV DPAD center button behaves normally when accessibility service enabled. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) 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) From 40d284d85b9e9ddfe7c5e86a585c2942332fe898 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 8 Sep 2026 19:59:54 +0200 Subject: [PATCH 20/46] reformat CHANGELOG.md --- CHANGELOG.md | 1599 ++++++++++++++++---------------------------------- 1 file changed, 509 insertions(+), 1090 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec0c0ae7d..4aeaa83862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,18 @@ ## Added - Target Android 17 SDK. -- #2227 Add a step to the Expert Mode setup wizard to grant local network access permission, required for ADB on Android 17+. +- [#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+. ## Fixed -- #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 do not spam notifications that Expert mode failed to start on WiFi disconnection or the ADB - pairing is broken. -- #2220 make invisible floating buttons more visible when editing. -- #2209 multi-line shell command actions no longer fail with a syntax error when the script is pasted with Windows line endings. +- [#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. +- [#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 fix: scale floating buttons when screen resolution changes. -- #2210 Android TV DPAD center button behaves normally when accessibility service enabled. +- [#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. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) @@ -26,16 +24,13 @@ ## 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) @@ -43,15 +38,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) @@ -59,19 +52,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) @@ -79,16 +68,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) @@ -96,15 +82,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 @@ -112,8 +96,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) @@ -121,10 +105,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) @@ -132,18 +116,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) @@ -151,10 +134,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) @@ -166,8 +147,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) @@ -175,13 +155,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) @@ -195,39 +174,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) @@ -235,19 +199,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) @@ -257,15 +215,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) @@ -273,37 +228,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) @@ -312,28 +254,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) @@ -341,18 +273,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) @@ -360,63 +287,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) @@ -432,46 +335,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) @@ -480,16 +366,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) @@ -499,12 +382,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 @@ -512,14 +392,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) @@ -527,44 +403,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) @@ -572,8 +431,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) @@ -585,8 +443,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) @@ -596,11 +453,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 @@ -608,8 +462,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) @@ -622,13 +475,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 @@ -638,16 +487,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) @@ -656,68 +501,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) @@ -726,15 +546,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) @@ -742,16 +559,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) @@ -759,21 +572,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) @@ -782,42 +587,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) @@ -825,20 +616,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) @@ -846,12 +631,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) @@ -859,25 +641,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) @@ -887,43 +663,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) @@ -931,9 +691,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) @@ -941,8 +699,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) @@ -950,8 +707,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) @@ -959,24 +715,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) @@ -984,33 +730,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) @@ -1018,9 +749,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) @@ -1034,9 +763,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) @@ -1048,60 +775,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) @@ -1109,26 +810,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) @@ -1136,80 +826,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) @@ -1219,8 +877,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. @@ -1231,34 +888,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) @@ -1266,24 +909,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) @@ -1295,95 +931,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 @@ -1396,8 +994,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 @@ -1416,18 +1013,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) @@ -1435,25 +1028,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) @@ -1463,139 +1048,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) @@ -1606,43 +1139,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 @@ -1651,22 +1170,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. @@ -1674,19 +1185,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. @@ -1698,23 +1203,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 @@ -1726,12 +1224,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. @@ -1739,19 +1234,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. @@ -1763,47 +1252,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) @@ -1820,15 +1292,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. @@ -1847,8 +1315,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. @@ -1856,8 +1323,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) @@ -1880,8 +1346,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) @@ -1897,16 +1362,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. @@ -1924,15 +1386,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. @@ -1955,8 +1413,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) @@ -1965,8 +1422,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. @@ -1985,11 +1441,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 @@ -2008,8 +1462,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. @@ -2020,8 +1473,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. @@ -2032,8 +1484,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 @@ -2083,13 +1534,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) @@ -2103,8 +1552,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. @@ -2118,8 +1566,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. @@ -2135,18 +1582,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. @@ -2160,8 +1604,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 @@ -2178,10 +1621,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 @@ -2216,8 +1657,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) @@ -2241,8 +1681,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. @@ -2250,8 +1689,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 @@ -2263,8 +1701,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) @@ -2295,8 +1732,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 @@ -2306,8 +1742,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 @@ -2354,8 +1789,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. @@ -2385,8 +1819,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) @@ -2401,21 +1834,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) @@ -2435,18 +1863,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) @@ -2474,16 +1898,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. @@ -2499,16 +1921,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 From 403f22658bd7f73f5e0fbc2a87db83987df9271a Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 9 Sep 2026 18:18:25 +0200 Subject: [PATCH 21/46] #2070 fix: long pressing a movable floating button now activates its long press key map instead of only being possible to drag. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aeaa83862..59cdff8719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - 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. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) From 37f07e34ec30cbbb4ecff4a3f5687e93252d5d4f Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 9 Sep 2026 18:20:49 +0200 Subject: [PATCH 22/46] fix: Floating Buttons immediately respond to toggling locked position. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59cdff8719..14b6880c27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - [#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. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) From da54e73423293fa1b0da238a9ef0ef7e8c666f37 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 9 Sep 2026 18:25:24 +0200 Subject: [PATCH 23/46] fix: Floating button options to show over keyboard and status bar apply immediately --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b6880c27..f2f6693af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - [#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. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) From 74ae0d7917a1a2121f300d7aac4b1048e0949f93 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 10 Sep 2026 10:29:29 +0200 Subject: [PATCH 24/46] #2098 #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). Closes #2098, #2233 --- CHANGELOG.md | 1 + app/src/main/AndroidManifest.xml | 14 ++ .../github/sds100/keymapper/AppHiltModule.kt | 6 + .../sds100/keymapper/base/BaseMainActivity.kt | 22 ++- .../keymapper/base/backup/BackupManager.kt | 6 +- .../backup/BackupRestoreMappingsUseCase.kt | 89 +++++++++++- .../base/backup/ImportExportState.kt | 18 +++ .../base/home/BackupFilePickerDialog.kt | 127 ++++++++++++++++++ .../base/home/HomeKeyMapListScreen.kt | 51 ++++++- .../base/home/KeyMapListViewModel.kt | 36 ++++- .../keymapper/base/home/ListKeyMapsUseCase.kt | 18 +-- .../permissions/RequestPermissionDelegate.kt | 44 ++++++ .../sds100/keymapper/base/utils/ErrorUtils.kt | 3 + .../sds100/keymapper/base/utils/ShareUtils.kt | 24 +++- base/src/main/res/values/strings.xml | 10 ++ .../base/system/files/FakeFileAdapter.kt | 4 + .../base/utils/TestBuildConfigProvider.kt | 1 + .../keymapper/common/BuildConfigProvider.kt | 10 ++ .../system/files/AndroidFileAdapter.kt | 50 +++++++ .../system/files/DocumentFileWrapper.kt | 12 +- .../keymapper/system/files/FileAdapter.kt | 8 ++ .../permissions/AndroidPermissionAdapter.kt | 4 + .../system/permissions/Permission.kt | 8 ++ 23 files changed, 532 insertions(+), 34 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/home/BackupFilePickerDialog.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f2f6693af3..50e9530ebd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - [#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). ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) 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/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/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/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..04c9600cda 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) { @@ -206,7 +210,13 @@ 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, @@ -300,6 +310,8 @@ fun HandleImportExportState( snackbarState: SnackbarHostState, setIdleState: () -> Unit, onConfirmImport: (RestoreType) -> Unit, + onChooseImportFile: (String) -> Unit = {}, + onRequestFullFileAccessClick: () -> Unit = {}, ) { when (val state = state) { is ImportExportState.Error -> { @@ -327,16 +339,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) { 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/system/permissions/RequestPermissionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/permissions/RequestPermissionDelegate.kt index f1378efe8a..3333163fa7 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 @@ -136,6 +136,50 @@ class RequestPermissionDelegate( ) { 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() + } } } 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 ce7e9646dc..47e6c78c96 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 @@ -77,6 +77,9 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { 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) 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/res/values/strings.xml b/base/src/main/res/values/strings.xml index 3a0e79badd..d502321150 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -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,7 @@ This action needs setting up Battery optimization settings not found! If it exists, open it manually. + Couldn\'t find the "All files access" settings page on this device. Extra (%s) not found! You can\'t have duplicate constraints! @@ -1685,6 +1691,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… 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/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/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/permissions/AndroidPermissionAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt index 62cdfeb700..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 @@ -345,6 +346,9 @@ class AndroidPermissionAdapter @Inject constructor( } 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 62e2422491..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 @@ -19,4 +19,12 @@ enum class Permission { 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, } From 9d9261159ca76a0bb4ecdda54adc1775a26cfd24 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 10 Sep 2026 16:08:26 +0200 Subject: [PATCH 25/46] #2231 fix: do not crash when trying to auto start system bridge and the starter scripts directory is unavailable Closes #2231 --- CHANGELOG.md | 1 + .../expertmode/SystemBridgeAutoStarter.kt | 7 +++ .../sds100/keymapper/base/utils/ErrorUtils.kt | 3 ++ base/src/main/res/values/strings.xml | 1 + .../expertmode/SystemBridgeAutoStarterTest.kt | 21 ++++++++ .../sds100/keymapper/common/utils/KMResult.kt | 1 + .../manager/SystemBridgeConnectionManager.kt | 43 ++++++++++++++-- .../sysbridge/starter/SystemBridgeStarter.kt | 50 +++++++++++++------ 8 files changed, 107 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50e9530ebd..b2a08636c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## Fixed +- [#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. - [#2220](https://github.com/keymapperorg/KeyMapper/issues/2220) make invisible floating buttons more visible when editing. 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 24bb771769..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 @@ -301,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/utils/ErrorUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt index 47e6c78c96..0e470145d5 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 @@ -461,6 +461,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/res/values/strings.xml b/base/src/main/res/values/strings.xml index d502321150..735d20476d 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -874,6 +874,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! 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 768bf0522a..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 @@ -34,6 +34,7 @@ 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 @@ -109,6 +110,7 @@ class SystemBridgeAutoStarterTest { mockConnectionManager = mock { on { connectionState } doReturn connectionStateFlow + on { canStartSystemBridge() } doReturn true } mockSetupController = mock() @@ -347,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) 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..b1e624eb35 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 @@ -72,6 +72,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/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/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 } /** From f2c28353f7a6c50a942922121f1bb9f250a2e832 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 10 Sep 2026 16:24:29 +0200 Subject: [PATCH 26/46] fix: check correct google play track version code when releasing internal testing --- fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 33398e047f9289b9d9afa5ce2991a725b6ab2ba6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 10 Sep 2026 16:31:46 +0200 Subject: [PATCH 27/46] bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index 61477936f4..1341536e52 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.3.2 -VERSION_CODE=260 +VERSION_CODE=261 From 589cfab890247393f967dd936f52464ea43d267b Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 10 Sep 2026 18:01:05 +0200 Subject: [PATCH 28/46] bump version to 4.4.0 --- CHANGELOG.md | 3 ++- app/version.properties | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a08636c0..0293828bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## [4.3.2](https://github.com/sds100/KeyMapper/releases/tag/v4.3.2) +## [4.4.0](https://github.com/sds100/KeyMapper/releases/tag/v4.3.2) #### TO BE RELEASED @@ -6,6 +6,7 @@ - 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+. +- Add one-finger and two-finger double-tap gestures to the TalkBack gesture action. ## Fixed diff --git a/app/version.properties b/app/version.properties index 1341536e52..01fbe8bbc1 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=4.3.2 +VERSION_NAME=4.4.0 VERSION_CODE=261 From 1ad2289fcc789f9e74c4b8d6850a984a6458a493 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 11 Sep 2026 13:42:38 +0200 Subject: [PATCH 29/46] #2223 feat: one-finger and two-finger double tap Talkback actions --- CHANGELOG.md | 2 +- .../talkback/PickTalkBackGestureDialog.kt | 2 ++ .../talkback/TalkBackGestureStrings.kt | 12 ++++++++++ .../actions/talkback/TalkBackGestureType.kt | 4 ++++ .../talkback/TalkbackGesturePerformer.kt | 22 +++++++++++++++++++ base/src/main/res/values/strings.xml | 4 ++++ 6 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0293828bdc..752ea4ab81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - 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+. -- Add one-finger and two-finger double-tap gestures to the TalkBack gesture action. +- #2223 Add one-finger and two-finger double-tap gestures to the TalkBack gesture action. ## Fixed 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/res/values/strings.xml b/base/src/main/res/values/strings.xml index 735d20476d..67c917123a 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1277,6 +1277,7 @@ 4-finger gestures + Activate the focused item Move reading control up or backwards Move reading control down or forwards Previous item @@ -1287,6 +1288,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 @@ -1303,6 +1305,7 @@ Next container + Double-tap Swipe up Swipe down Swipe left @@ -1313,6 +1316,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 From 20dc9eefadd26fbb65ce46c3b932771380ba39b3 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 11 Sep 2026 16:12:52 +0200 Subject: [PATCH 30/46] #2238 feat: add a "Reduce app killing" card to Expert Mode on Xiaomi/Redmi/Poco devices --- CHANGELOG.md | 1 + .../sds100/keymapper/base/BaseMainNavHost.kt | 8 + .../keymapper/base/BaseViewModelHiltModule.kt | 8 + .../base/expertmode/ExpertModeScreen.kt | 69 ++++ .../base/expertmode/ExpertModeViewModel.kt | 9 + .../xiaomi/XiaomiOptimizationScreen.kt | 391 ++++++++++++++++++ .../xiaomi/XiaomiOptimizationUseCase.kt | 203 +++++++++ .../xiaomi/XiaomiOptimizationViewModel.kt | 116 ++++++ .../base/utils/navigation/NavDestination.kt | 6 + base/src/main/res/values/strings.xml | 27 ++ .../keymapper/common/utils/BuildUtils.kt | 2 + 11 files changed, 840 insertions(+) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/expertmode/xiaomi/XiaomiOptimizationScreen.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/expertmode/xiaomi/XiaomiOptimizationUseCase.kt create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/expertmode/xiaomi/XiaomiOptimizationViewModel.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 752ea4ab81..363307e5fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## 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. 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/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/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/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/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/res/values/strings.xml b/base/src/main/res/values/strings.xml index 67c917123a..40f7dc96e8 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1927,6 +1927,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/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") From f2ae9fdfd4c3aa3078502395669155b478cba403 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 11 Sep 2026 16:33:19 +0200 Subject: [PATCH 31/46] #2211 feat: sort key maps by whether they are enabled --- CHANGELOG.md | 1 + app/version.properties | 2 +- .../base/sorting/SortBottomSheetContent.kt | 3 +++ .../keymapper/base/sorting/SortField.kt | 1 + .../base/sorting/SortKeyMapsUseCase.kt | 3 +++ .../comparators/KeyMapEnabledComparator.kt | 27 +++++++++++++++++++ base/src/main/res/values/strings.xml | 1 + 7 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapEnabledComparator.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 363307e5fd..ce4159e02e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - 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. ## Fixed diff --git a/app/version.properties b/app/version.properties index 01fbe8bbc1..f5b2c6cfc7 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.4.0 -VERSION_CODE=261 +VERSION_CODE=262 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/res/values/strings.xml b/base/src/main/res/values/strings.xml index 40f7dc96e8..3269b6c43c 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1641,6 +1641,7 @@ Actions Constraints Options + Enabled Key maps Floating buttons New key map From a07ca578de37465b8869094ad0c666b74601c46a Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 11 Sep 2026 17:17:04 +0200 Subject: [PATCH 32/46] #2234 feat: add a cycle keyboard action Closes #2234 --- CHANGELOG.md | 1 + .../keymapper/base/actions/ActionData.kt | 5 +++ .../base/actions/ActionDataEntityMapper.kt | 3 ++ .../sds100/keymapper/base/actions/ActionId.kt | 1 + .../keymapper/base/actions/ActionUiHelper.kt | 2 ++ .../keymapper/base/actions/ActionUtils.kt | 7 ++++ .../base/actions/CreateActionDelegate.kt | 2 ++ .../base/actions/PerformActionsUseCase.kt | 4 +++ .../sds100/keymapper/base/utils/ErrorUtils.kt | 6 ++++ base/src/main/res/values/strings.xml | 3 ++ .../inputmethod/FakeInputMethodAdapter.kt | 4 +++ .../sds100/keymapper/common/utils/KMResult.kt | 2 ++ .../inputmethod/AndroidInputMethodAdapter.kt | 33 +++++++++++++++++++ .../system/inputmethod/InputMethodAdapter.kt | 6 ++++ 14 files changed, 79 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce4159e02e..3ccbfac2f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - [#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. ## Fixed diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 0fbbf4a6fc..b5a3de1035 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 @@ -245,6 +245,11 @@ sealed class ActionData : Comparable { } } + @Serializable + data object CycleKeyboardLanguage : ActionData() { + override val id = ActionId.CYCLE_KEYBOARD_LANGUAGE + } + @Serializable sealed class DoNotDisturb : ActionData() { 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..d68fbbf8ec 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 @@ -619,6 +619,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 @@ -1503,6 +1505,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..f2089d6848 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 @@ -690,6 +690,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..f25043a954 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 @@ -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 @@ -902,6 +906,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, @@ -1051,6 +1057,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 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..e40e18319a 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 @@ -1099,6 +1099,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/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index eac8372e48..2233baa112 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 @@ -297,6 +297,10 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } } + is ActionData.CycleKeyboardLanguage -> { + result = inputMethodAdapter.cycleInputMethodSubtype() + } + is ActionData.Volume.Down -> { result = audioAdapter.lowerVolume( stream = action.volumeStream, 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 0e470145d5..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 @@ -389,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) diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 3269b6c43c..a2bba6fd09 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -906,6 +906,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! @@ -1120,6 +1122,7 @@ Switch keyboard Switch to %s + Cycle keyboard language Cut Copy 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/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 b1e624eb35..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() 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 } From 00c2b21e626cb430ff2d80db084c0622d1fd7b62 Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 11 Sep 2026 18:00:36 +0200 Subject: [PATCH 33/46] #2199 fix: fall back to opening the apps settings when fixing battery optimisation on TV --- CHANGELOG.md | 1 + .../permissions/RequestPermissionDelegate.kt | 65 +++++++++++++++++-- base/src/main/res/values/strings.xml | 3 + 3 files changed, 62 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ccbfac2f8..cb847251c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - 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. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) 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 3333163fa7..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, @@ -305,7 +307,6 @@ class RequestPermissionDelegate( } } - @RequiresApi(Build.VERSION_CODES.M) private fun requestIgnoreBatteryOptimisations() { if (showDialogs) { activity.materialAlertDialog { @@ -313,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() } @@ -328,20 +329,70 @@ class RequestPermissionDelegate( show() } } else { - showBatteryOptimisationExemptionSystemDialog() + requestBatteryOptimisationExemption() } } - @RequiresApi(Build.VERSION_CODES.M) - private fun showBatteryOptimisationExemptionSystemDialog() { + 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() + } + } + } + + 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/res/values/strings.xml b/base/src/main/res/values/strings.xml index a2bba6fd09..ddb263533b 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -865,6 +865,9 @@ 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! From ed71b44238689eb81261c9a264dc1f06379866b9 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 10:35:12 +0200 Subject: [PATCH 34/46] #2235 feat: show tip to use a dedicated action instead of some key codes --- CHANGELOG.md | 1 + .../keyevent/DedicatedActionKeyCodes.kt | 45 +++++++++++++ .../base/onboarding/OnboardingTipDelegate.kt | 63 ++++++++++++++++++- base/src/main/res/values/strings.xml | 3 + 4 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/keyevent/DedicatedActionKeyCodes.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index cb847251c0..ea53b526ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - #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. ## Fixed 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..ff8d62bb6a --- /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/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/res/values/strings.xml b/base/src/main/res/values/strings.xml index ddb263533b..6b2477120a 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1627,6 +1627,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 From 91be5968dcee7358cccbe478731505225091a210 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 10:53:33 +0200 Subject: [PATCH 35/46] fix: tip card content fills horizontal space --- .../keymapper/base/actions/ActionsScreen.kt | 251 ++++++++---------- .../keymapper/base/onboarding/TipCard.kt | 14 +- 2 files changed, 131 insertions(+), 134 deletions(-) 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..a2c12498bd 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 @@ -1,6 +1,5 @@ package io.github.sds100.keymapper.base.actions -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -133,10 +132,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 +155,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 +199,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. @@ -293,42 +234,79 @@ private fun ActionList( state = lazyListState, contentPadding = PaddingValues(vertical = 8.dp), ) { - 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 + .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)) } } - 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 +320,7 @@ private fun ActionList( modifier = Modifier .fillMaxWidth() .padding(horizontal = 32.dp), - shortcuts = shortcuts, + shortcuts = state.shortcuts, onClick = { onClickShortcut(it) }, ) } @@ -384,6 +362,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/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", + ) + } +} From 11e5c44beebccc5a9faa67a05e4ae8090ae5a324 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 10:57:31 +0200 Subject: [PATCH 36/46] fix: Actions screen scrolls when empty and tip does not fill up screen on small displays --- CHANGELOG.md | 1 + .../github/sds100/keymapper/base/actions/ActionsScreen.kt | 1 - .../sds100/keymapper/base/actions/ConfigActionsViewModel.kt | 6 ++++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea53b526ba..1e3292c9cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - [#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. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) 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 a2c12498bd..3029e342b2 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 @@ -239,7 +239,6 @@ private fun ActionList( item { TipCard( modifier = Modifier - .fillMaxWidth() .padding(horizontal = 16.dp), title = tip.title, message = tip.message, 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() } From 899eeee160a339a21ded9e8cc4095c127e4b719c Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 11:38:18 +0200 Subject: [PATCH 37/46] fix: request notification listener permission for step media actions --- CHANGELOG.md | 1 + .../io/github/sds100/keymapper/base/actions/ActionUtils.kt | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e3292c9cd..01046a7691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - [#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 ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) 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 f25043a954..1315b12800 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 @@ -840,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, From a400328f905c442d415441dee8dec2f041c7f883 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 11:42:34 +0200 Subject: [PATCH 38/46] fix: slider does not jiggle when sliding over the default value. --- CHANGELOG.md | 3 ++- .../base/utils/ui/compose/SliderOptionText.kt | 11 +++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01046a7691..0eb31a557c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,8 @@ - [#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 +- Request notification listener permission for step media actions. +- Slider does not jiggle when sliding over the default value. ## [4.3.1](https://github.com/sds100/KeyMapper/releases/tag/v4.3.1) 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, From 563364555403fc1caa9714dece15225512b45bdf Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 11:50:45 +0200 Subject: [PATCH 39/46] #2239 feat: add an option to set a custom seek amount for the Step media forward/backward actions, instead of the fixed 30 seconds. Closes #2239 --- CHANGELOG.md | 1 + .../keymapper/base/actions/ActionData.kt | 14 +- .../base/actions/ActionDataEntityMapper.kt | 50 +++- .../keymapper/base/actions/ActionUiHelper.kt | 82 +++++-- .../keymapper/base/actions/ActionUtils.kt | 14 +- .../base/actions/ChooseActionScreen.kt | 1 + .../base/actions/CreateActionDelegate.kt | 85 ++++++- .../base/actions/PerformActionsUseCase.kt | 8 +- .../actions/StepMediaActionBottomSheet.kt | 216 ++++++++++++++++++ .../keyevent/DedicatedActionKeyCodes.kt | 4 +- base/src/main/res/values/strings.xml | 9 + .../keymapper/data/entities/ActionEntity.kt | 1 + .../system/media/AndroidMediaAdapter.kt | 8 +- .../keymapper/system/media/MediaAdapter.kt | 4 +- 14 files changed, 436 insertions(+), 61 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/StepMediaActionBottomSheet.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb31a557c..16424c9563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - [#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 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 b5a3de1035..7f6b2aa740 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 @@ -383,12 +383,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 } } @@ -436,12 +442,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 } } 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 d68fbbf8ec..90d81ee895 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 @@ -436,6 +436,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 +466,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 +591,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() + + ActionData.ControlMedia.StepForward(stepDurationMs) + } - ActionId.STEP_BACKWARD -> ActionData.ControlMedia.StepBackward + ActionId.STEP_BACKWARD -> { + val stepDurationMs = entity.extras.getData( + ActionEntity.EXTRA_STEP_MEDIA_DURATION, + ).valueOrNull()?.toLongOrNull() + + ActionData.ControlMedia.StepBackward(stepDurationMs) + } ActionId.GO_BACK -> ActionData.GoBack @@ -1107,10 +1123,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, 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 f2089d6848..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) 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 1315b12800..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 @@ -1032,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 @@ -1129,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/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/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index e40e18319a..2473c713c4 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( @@ -1067,9 +1118,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 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 2233baa112..e8daadfa27 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 @@ -243,11 +243,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 -> { @@ -651,11 +651,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 -> { 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 index ff8d62bb6a..1cb4157fa7 100644 --- 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 @@ -32,8 +32,8 @@ fun getDedicatedKeyCodeAction(keyCode: Int): ActionData? = when (keyCode) { 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_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 diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 6b2477120a..636ebf4010 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1047,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 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..f71c54941f 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 @@ -92,6 +92,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/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<*> From 57f232447a1c347bc01b6dc9e4d3c920cc75cc2f Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 12:06:17 +0200 Subject: [PATCH 40/46] #2097 fix: potentially fix activities from some apps being missing from the Send Intent action's activity picker. --- CHANGELOG.md | 1 + .../system/apps/AndroidPackageManagerAdapter.kt | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16424c9563..f589b70032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - [#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. 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) { From 4264dedf42e0d494f6f7093fab3acd6accfa91fb Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 12:07:10 +0200 Subject: [PATCH 41/46] fix: center actions screen list content --- .../keymapper/base/actions/ActionsScreen.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 3029e342b2..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 @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.actions +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -233,6 +234,11 @@ private fun ActionList( modifier = modifier, state = lazyListState, contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = if (state is ConfigActionsState.Empty) { + Arrangement.Center + } else { + Arrangement.Top + }, ) { // Display action tip if available tipModel?.let { tip -> @@ -331,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( From 034c55351ad65f7b3dac3984e5b790367787950b Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 12:23:56 +0200 Subject: [PATCH 42/46] #2217 extract shared logic for picking screenshots when configuring gesture actions --- .../base/actions/ScreenshotPickerDelegate.kt | 73 +++++++++++++++++++ .../PinchPickDisplayCoordinateViewModel.kt | 58 +++------------ .../SwipePickDisplayCoordinateViewModel.kt | 58 +++------------ .../PickDisplayCoordinateViewModel.kt | 57 +++------------ base/src/main/res/values/strings.xml | 2 +- .../sds100/keymapper/common/utils/SizeKM.kt | 20 ++++- 6 files changed, 129 insertions(+), 139 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/actions/ScreenshotPickerDelegate.kt 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/pinchscreen/PinchPickDisplayCoordinateViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/pinchscreen/PinchPickDisplayCoordinateViewModel.kt index cceaad7fd1..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 @@ -9,12 +9,12 @@ 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.common.utils.SizeKM import io.github.sds100.keymapper.system.display.DisplayAdapter import javax.inject.Inject import kotlin.math.roundToInt @@ -23,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 @@ -47,20 +46,16 @@ 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) - /** - * The display size that the coordinates and distance are for. See issue #2217. This is the size - * of the screenshot if one is chosen because the coordinates are in the screenshot's pixel - * space, otherwise the resolution of the action being edited, otherwise the current display - * size. - */ - private val screenshotResolution: MutableStateFlow = MutableStateFlow(null) - private val loadedResolution: MutableStateFlow = MutableStateFlow(null) - val xString = x.map { it ?: return@map "" @@ -150,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 = @@ -176,25 +171,7 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.Lazily, false) fun selectedScreenshot(newBitmap: Bitmap) { - val displaySize = displayAdapter.size - - // check whether the height and width of the bitmap match the display size, even when it is rotated. - if ((displaySize.width != newBitmap.width && displaySize.height != newBitmap.height) && - (displaySize.height != newBitmap.width && displaySize.width != newBitmap.height) - ) { - viewModelScope.launch { - val snackBar = DialogModel.SnackBar( - message = getString(R.string.toast_incorrect_screenshot_resolution), - ) - - showDialog("incorrect_resolution", snackBar) - } - - return - } - - screenshotResolution.value = SizeKM(newBitmap.width, newBitmap.height) - _bitmap.value = newBitmap + screenshotDelegate.selectedScreenshot(newBitmap) } fun setX(x: String) { @@ -266,22 +243,12 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( fingerCount, duration, description, - screenResolution(), + screenshotDelegate.screenResolution(), ), ) } } - /** - * 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. - */ - private fun screenResolution(): SizeKM { - return screenshotResolution.value ?: loadedResolution.value ?: displayAdapter.size - } - fun onPinchTypeSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { this.setPinchType(pinchTypes[position]) } @@ -295,13 +262,12 @@ class PinchPickDisplayCoordinateViewModel @Inject constructor( fingerCount.value = result.fingerCount duration.value = result.duration description.value = result.description - loadedResolution.value = result.screenResolution + 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/SwipePickDisplayCoordinateViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/swipescreen/SwipePickDisplayCoordinateViewModel.kt index d5a403d8ad..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 @@ -6,11 +6,11 @@ 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.SizeKM import io.github.sds100.keymapper.system.display.DisplayAdapter import javax.inject.Inject import kotlin.math.roundToInt @@ -19,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 @@ -48,20 +47,17 @@ 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) private val description: MutableStateFlow = MutableStateFlow(null) - /** - * The display size that the coordinates are for. See issue #2217. This is the size of the - * screenshot if one is chosen because the coordinates are in the screenshot's pixel space, - * otherwise the resolution of the action being edited, otherwise the current display size. - */ - private val screenshotResolution: MutableStateFlow = MutableStateFlow(null) - private val loadedResolution: MutableStateFlow = MutableStateFlow(null) - val xStartString = xStart.map { it ?: return@map "" @@ -137,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) { @@ -169,26 +165,7 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( fun selectedScreenshot(newBitmap: Bitmap) { screenshotTouchType.value = ScreenshotTouchType.START - - val displaySize = displayAdapter.size - - // check whether the height and width of the bitmap match the display size, even when it is rotated. - if ((displaySize.width != newBitmap.width && displaySize.height != newBitmap.height) && - (displaySize.height != newBitmap.width && displaySize.width != newBitmap.height) - ) { - viewModelScope.launch { - val snackBar = DialogModel.SnackBar( - message = getString(R.string.toast_incorrect_screenshot_resolution), - ) - - showDialog("incorrect_resolution", snackBar) - } - - return - } - - screenshotResolution.value = SizeKM(newBitmap.width, newBitmap.height) - _bitmap.value = newBitmap + screenshotDelegate.selectedScreenshot(newBitmap) } fun setXStart(x: String) { @@ -265,22 +242,12 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( fingerCount, duration, description, - screenResolution(), + screenshotDelegate.screenResolution(), ), ) } } - /** - * 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. - */ - private fun screenResolution(): SizeKM { - return screenshotResolution.value ?: loadedResolution.value ?: displayAdapter.size - } - fun loadResult(result: SwipePickCoordinateResult) { viewModelScope.launch { xStart.value = result.xStart @@ -290,13 +257,12 @@ class SwipePickDisplayCoordinateViewModel @Inject constructor( fingerCount.value = result.fingerCount duration.value = result.duration description.value = result.description - loadedResolution.value = result.screenResolution + 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/tapscreen/PickDisplayCoordinateViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/tapscreen/PickDisplayCoordinateViewModel.kt index 4ed4091571..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 @@ -5,11 +5,11 @@ 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.SizeKM import io.github.sds100.keymapper.system.display.DisplayAdapter import javax.inject.Inject import kotlin.math.roundToInt @@ -18,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 @@ -55,42 +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) - /** - * The display size that the coordinate is for. See issue #2217. This is the size of the - * screenshot if one is chosen because the coordinate is in the screenshot's pixel space, - * otherwise the resolution of the action being edited, otherwise the current display size. - */ - private val screenshotResolution: MutableStateFlow = MutableStateFlow(null) - private val loadedResolution: MutableStateFlow = MutableStateFlow(null) - fun selectedScreenshot(newBitmap: Bitmap) { - val displaySize = displayAdapter.size - - // check whether the height and width of the bitmap match the display size, even when it is rotated. - if ((displaySize.width != newBitmap.width && displaySize.height != newBitmap.height) && - (displaySize.height != newBitmap.width && displaySize.width != newBitmap.height) - ) { - viewModelScope.launch { - val snackBar = DialogModel.SnackBar( - message = getString(R.string.toast_incorrect_screenshot_resolution), - ) - - showDialog("incorrect_resolution", snackBar) - } - - return - } - - screenshotResolution.value = SizeKM(newBitmap.width, newBitmap.height) - _bitmap.value = newBitmap + screenshotDelegate.selectedScreenshot(newBitmap) } fun setX(x: String) { @@ -130,33 +108,22 @@ class PickDisplayCoordinateViewModel @Inject constructor( ) ?: return@launch _returnResult.emit( - PickCoordinateResult(x, y, description, screenResolution()), + PickCoordinateResult(x, y, description, screenshotDelegate.screenResolution()), ) } } - /** - * See issue #2217. Prefer the screenshot's resolution because the coordinate is 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. - */ - private fun screenResolution(): SizeKM { - return screenshotResolution.value ?: loadedResolution.value ?: displayAdapter.size - } - fun loadResult(result: PickCoordinateResult) { viewModelScope.launch { x.value = result.x y.value = result.y description.value = result.description - loadedResolution.value = result.screenResolution + screenshotDelegate.setLoadedResolution(result.screenResolution) } } override fun onCleared() { - bitmap.value?.recycle() - _bitmap.value = null + screenshotDelegate.recycle() super.onCleared() } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 636ebf4010..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 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 + } +} From b3920b25dded06fc3bd06885121ff2afa670bc1c Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 14:33:34 +0200 Subject: [PATCH 43/46] #2212 fix: put warnings, group chips, breadcrumbs and group constraints in scrollable key map list so they do not block the list when space is tight --- CHANGELOG.md | 1 + .../base/groups/GroupConstraintRow.kt | 172 +++-- .../sds100/keymapper/base/groups/GroupRow.kt | 3 - .../base/home/HomeKeyMapListScreen.kt | 30 +- .../keymapper/base/home/KeyMapListAppBar.kt | 669 +++++------------- .../keymapper/base/home/KeyMapListHeader.kt | 417 +++++++++++ .../keymapper/base/home/KeyMapListScreen.kt | 161 ++--- 7 files changed, 785 insertions(+), 668 deletions(-) create mode 100644 base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index cb13b2371e..1fbd452ae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ## 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. 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/HomeKeyMapListScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt index 04c9600cda..efe49047d8 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 @@ -165,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), @@ -189,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, @@ -219,23 +234,15 @@ fun HomeKeyMapListScreen( }, 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 }, @@ -619,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, ) @@ -662,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, ) @@ -698,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, ) @@ -753,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, ) @@ -800,6 +811,7 @@ private fun PreviewKeyMapsWarningsEmpty() { 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..931205a0ab 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 @@ -71,6 +71,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 +81,85 @@ 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 + + Surface(modifier = modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyListState, + contentPadding = PaddingValues( + // The header is flush with the app bar because it is the same color as it. + top = if (header == null) 8.dp else 0.dp, + bottom = 8.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // 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") { + 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 -> { + item(key = "loading") { + LoadingList( + Modifier + .fillParentMaxWidth() + .fillParentMaxHeight(), + ) + } + } + + is State.Data -> { + if (listItems.data.isEmpty()) { + item(key = "empty") { + EmptyKeyMapList( + Modifier + .fillParentMaxWidth() + .fillParentMaxHeight(), + ) + } + } 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 +197,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 From 5a12775094a350c771c6239bc7f049c3ad368e3f Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 15:25:41 +0200 Subject: [PATCH 44/46] fix padding in empty/loading key map list --- .../base/home/HomeKeyMapListScreen.kt | 42 +++- .../keymapper/base/home/KeyMapListScreen.kt | 188 ++++++++++++------ 2 files changed, 163 insertions(+), 67 deletions(-) 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 efe49047d8..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 @@ -266,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, @@ -799,6 +799,44 @@ private fun PreviewKeyMapsWarningsEmpty() { val listState = State.Data(emptyList()) + 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, + + ) + }, + 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 = { 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 931205a0ab..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 @@ -82,82 +89,110 @@ fun KeyMapList( bottomListPadding: Dp = 100.dp, ) { 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 - Surface(modifier = modifier) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = lazyListState, - contentPadding = PaddingValues( - // The header is flush with the app bar because it is the same color as it. - top = if (header == null) 8.dp else 0.dp, - bottom = 8.dp, - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // 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") { - header() - } - } + // 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) } - when (listItems) { - is State.Loading -> { - item(key = "loading") { - LoadingList( + 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 - .fillParentMaxWidth() - .fillParentMaxHeight(), - ) + .fillMaxWidth() + .onSizeChanged { + headerHeight = with(density) { it.height.toDp() } + }, + ) { + header() + } } } - is State.Data -> { - if (listItems.data.isEmpty()) { - item(key = "empty") { - EmptyKeyMapList( - Modifier - .fillParentMaxWidth() - .fillParentMaxHeight(), - ) - } - } 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, - ) + when (listItems) { + is State.Loading -> { + if (isRemainingHeightMeasured) { + item(key = "loading") { + LoadingList( + Modifier + .fillMaxWidth() + .height(remainingHeight), + ) + } } + } - if (footerText != null) { - item(key = "footer") { - Text( + 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), - text = footerText, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium, + 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, ) } - } - // 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)) + 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)) + } } } } @@ -474,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, ) @@ -668,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, + ) } } @@ -680,6 +729,7 @@ private fun SelectableListPreview() { modifier = Modifier.fillMaxSize(), listItems = State.Data(sampleList()), isSelectable = true, + bottomListPadding = 100.dp, ) } } @@ -688,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, + ) } } @@ -696,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, + ) } } From 674bc75875a97e433975245f73888f1f9ece7769 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 15:26:19 +0200 Subject: [PATCH 45/46] bump version code --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index f5b2c6cfc7..5c0a6b7d6c 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ VERSION_NAME=4.4.0 -VERSION_CODE=262 +VERSION_CODE=264 From a692c1bca188131780f26711ef3618740fb6b18e Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Sep 2026 15:35:09 +0200 Subject: [PATCH 46/46] add release date to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fbd452ae0..e5dac9df9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## [4.4.0](https://github.com/sds100/KeyMapper/releases/tag/v4.3.2) -#### TO BE RELEASED +#### 12 September 2026 ## Added