From 76840fe9b2de120b971215af950b919ba7a98bf3 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 10 Aug 2024 21:26:14 -0600 Subject: [PATCH 001/118] chore: add pro build flavor --- app/build.gradle | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c9c3103a7d..cdaf54d76e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -75,6 +75,16 @@ android { } } + flavorDimensions = ["pro"] + productFlavors { + free { + dimension "pro" + } + pro { + dimension "pro" + } + } + buildFeatures { dataBinding true viewBinding true @@ -116,6 +126,8 @@ android.applicationVariants.all { variant -> } dependencies { + proImplementation project(":pro") + def room_version = "2.5.2" def coroutinesVersion = "1.5.0" def nav_version = '2.6.0' @@ -148,7 +160,7 @@ dependencies { implementation "dev.rikka.shizuku:api:$shizuku_version" implementation "dev.rikka.shizuku:provider:$shizuku_version" implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3' - + // splitties implementation "com.louiscad.splitties:splitties-bitflags:$splitties_version" implementation "com.louiscad.splitties:splitties-alertdialog-appcompat-coroutines:$splitties_version" @@ -202,7 +214,7 @@ dependencies { testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0" testImplementation "org.mockito:mockito-core:5.1.1" testImplementation 'org.mockito:mockito-inline:5.0.0' - + androidTestImplementation "androidx.test.ext:junit:$androidXTestExtKotlinRunnerVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" androidTestImplementation "androidx.arch.core:core-testing:2.2.0" From 09bd3c14f1c84b4e72f65e632c2e686739687a48 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 12 Aug 2024 18:35:32 -0600 Subject: [PATCH 002/118] #1274 add empty assistant trigger fragment --- .../sds100/keymapper/home/HomeFragment.kt | 4 ++-- .../sds100/keymapper/home/HomePagerAdapter.kt | 22 +++++++++++++++++-- .../github/sds100/keymapper/home/HomeTab.kt | 1 + .../sds100/keymapper/home/HomeViewModel.kt | 8 ++++--- app/src/main/res/values/strings.xml | 6 +---- 5 files changed, 29 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt index ea54c30689..ead9950f94 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt @@ -33,7 +33,6 @@ import io.github.sds100.keymapper.util.Inject import io.github.sds100.keymapper.util.QuickStartGuideTapTarget import io.github.sds100.keymapper.util.launchRepeatOnLifecycle import io.github.sds100.keymapper.util.str -import io.github.sds100.keymapper.util.strArray import io.github.sds100.keymapper.util.ui.TextListItem import io.github.sds100.keymapper.util.ui.setupNavigation import io.github.sds100.keymapper.util.ui.showPopups @@ -130,7 +129,8 @@ class HomeFragment : Fragment() { binding.viewPager.adapter = pagerAdapter TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> - tab.text = strArray(R.array.home_tab_titles)[position] + val tabId = homeViewModel.tabsState.value.tabs[position] + tab.text = str(HomePagerAdapter.TAB_NAMES[tabId]!!) }.apply { attach() } diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt index 75c15ed552..81344e94be 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt @@ -2,8 +2,10 @@ package io.github.sds100.keymapper.home import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter +import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.fingerprintmaps.FingerprintMapListFragment import io.github.sds100.keymapper.mappings.keymaps.KeyMapListFragment +import io.github.sds100.keymapperpro.AssistantTriggerFragment /** * Created by sds100 on 26/01/2020. @@ -13,7 +15,15 @@ class HomePagerAdapter( fragment: Fragment, ) : FragmentStateAdapter(fragment) { - private var tabs: Set = emptySet() + companion object { + val TAB_NAMES: Map = mapOf( + HomeTab.KEY_EVENTS to R.string.tab_keyevents, + HomeTab.FINGERPRINT_MAPS to R.string.tab_fingerprint, + HomeTab.ASSISTANT_TRIGGER to R.string.tab_assistant_trigger + ) + } + + private var tabs: List = emptyList() private val tabFragmentsCreators: List<() -> Fragment> get() = tabs.map { tab -> when (tab) { @@ -32,6 +42,14 @@ class HomePagerAdapter( } } } + + HomeTab.ASSISTANT_TRIGGER -> { + { + AssistantTriggerFragment().apply { + isAppBarVisible = false + } + } + } } } @@ -39,7 +57,7 @@ class HomePagerAdapter( override fun createFragment(position: Int) = tabFragmentsCreators[position].invoke() - fun invalidateFragments(tabs: Set) { + fun invalidateFragments(tabs: List) { if (this.tabs == tabs) return this.tabs = tabs diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeTab.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeTab.kt index 264083953c..bd07456154 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeTab.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeTab.kt @@ -6,4 +6,5 @@ package io.github.sds100.keymapper.home enum class HomeTab { KEY_EVENTS, FINGERPRINT_MAPS, + ASSISTANT_TRIGGER } diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt index a04c79afd9..22ed21db4e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt @@ -144,7 +144,9 @@ class HomeViewModel( if (showFingerprintMaps) { yield(HomeTab.FINGERPRINT_MAPS) } - }.toSet() + + yield(HomeTab.ASSISTANT_TRIGGER) + }.toList() val showTabs = when { tabs.size == 1 -> false @@ -165,7 +167,7 @@ class HomeViewModel( HomeTabsState( enableViewPagerSwiping = false, showTabs = false, - emptySet(), + emptyList(), ), ) @@ -541,7 +543,7 @@ enum class HomeAppBarState { data class HomeTabsState( val enableViewPagerSwiping: Boolean = true, val showTabs: Boolean = false, - val tabs: Set, + val tabs: List, ) data class HomeErrorListState( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3faa26d102..d0dc501da5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -209,11 +209,7 @@ Trigger Key events Fingerprint - - - @string/tab_keyevents - @string/tab_fingerprint - + Assistant trigger From bd4768d7a7c7a9ea2b038b13a0dcdf85db2d718f Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 12 Aug 2024 19:52:47 -0600 Subject: [PATCH 003/118] fix KeyMapController --- .../keymaps/detection/KeyMapController.kt | 59 ++++++++++--------- 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt index 7418f46e62..4988912022 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt @@ -57,12 +57,12 @@ class KeyMapController( * rather than the up event. */ fun performActionOnDown(trigger: KeyMapTrigger): Boolean = ( - trigger.keys.size <= 1 && - trigger.keys.getOrNull(0)?.clickType != ClickType.DOUBLE_PRESS && - trigger.mode == TriggerMode.Undefined - ) || + trigger.keys.size <= 1 && + trigger.keys.getOrNull(0)?.clickType != ClickType.DOUBLE_PRESS && + trigger.mode == TriggerMode.Undefined + ) || - trigger.mode is TriggerMode.Parallel + trigger.mode is TriggerMode.Parallel } /** @@ -127,9 +127,9 @@ class KeyMapController( } if (( - keyMap.trigger.mode == TriggerMode.Sequence || - keyMap.trigger.mode == TriggerMode.Undefined - ) && + keyMap.trigger.mode == TriggerMode.Sequence || + keyMap.trigger.mode == TriggerMode.Undefined + ) && key.clickType == ClickType.DOUBLE_PRESS ) { doublePressKeys.add(TriggerKeyLocation(triggerIndex, keyIndex)) @@ -155,9 +155,9 @@ class KeyMapController( if (keyMap.actionList.any { it.data is ActionData.InputKeyEvent && - isModifierKey( - it.data.keyCode, - ) + isModifierKey( + it.data.keyCode, + ) } ) { modifierKeyEventActions = true @@ -165,9 +165,9 @@ class KeyMapController( if (keyMap.actionList.any { it.data is ActionData.InputKeyEvent && - !isModifierKey( - it.data.keyCode, - ) + !isModifierKey( + it.data.keyCode, + ) } ) { notModifierKeyEventActions = true @@ -685,12 +685,17 @@ class KeyMapController( val action = actionMap[actionKey] ?: continue val result = performActionsUseCase.getError(action.data) - canActionBePerformed.put(actionKey, result) - if (result != null) { + if (result == null) { + canActionBePerformed.remove(actionKey) + } else { + // if there is an error when trying to perform an action then stop handling + // this trigger. + canActionBePerformed.put(actionKey, result) continue@triggerLoop } - } else if (canActionBePerformed.get(actionKey, null) is Error) { + + } else if (canActionBePerformed[actionKey] is Error) { continue@triggerLoop } } @@ -1469,30 +1474,30 @@ class KeyMapController( TriggerKeyDevice.Any -> this.keyCode == event.keyCode && this.clickType == event.clickType is TriggerKeyDevice.External -> this.keyCode == event.keyCode && - event.descriptor != null && - event.descriptor == this.device.descriptor && - this.clickType == event.clickType + event.descriptor != null && + event.descriptor == this.device.descriptor && + this.clickType == event.clickType TriggerKeyDevice.Internal -> this.keyCode == event.keyCode && - event.descriptor == null && - this.clickType == event.clickType + event.descriptor == null && + this.clickType == event.clickType } private fun TriggerKey.matchesWithOtherKey(otherKey: TriggerKey): Boolean = when (this.device) { TriggerKeyDevice.Any -> this.keyCode == otherKey.keyCode && - this.clickType == otherKey.clickType + this.clickType == otherKey.clickType is TriggerKeyDevice.External -> this.keyCode == otherKey.keyCode && - this.device == otherKey.device && - this.clickType == otherKey.clickType + this.device == otherKey.device && + this.clickType == otherKey.clickType TriggerKeyDevice.Internal -> this.keyCode == otherKey.keyCode && - otherKey.device == TriggerKeyDevice.Internal && - this.clickType == otherKey.clickType + otherKey.device == TriggerKeyDevice.Internal && + this.clickType == otherKey.clickType } private fun longPressDelay(trigger: KeyMapTrigger): Long = From b353671c7c43950433ecd81a296dc34c188e7e22 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 12 Aug 2024 20:07:47 -0600 Subject: [PATCH 004/118] chore: upgrade kotlin version --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 635069e226..b7f22be798 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ buildscript { - ext.kotlin_version = '1.8.20' + ext.kotlin_version = '1.9.22' repositories { google() From aa9986fc0bf1af983fd488ada17f146e07c784a4 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 12 Aug 2024 20:41:11 -0600 Subject: [PATCH 005/118] #1274 use build flavor source directory rather than gradle module --- .gitmodules | 4 +- app/build.gradle | 10 ++++- .../assistant/AssistantTriggerFragment.kt | 41 +++++++++++++++++++ .../sds100/keymapper/home/HomePagerAdapter.kt | 6 +-- .../main/res/layout/fragment_compose_view.xml | 16 ++++++++ settings.gradle | 1 - 6 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 app/src/free/java/io/github/sds100/keymapper/mappings/assistant/AssistantTriggerFragment.kt create mode 100644 app/src/main/res/layout/fragment_compose_view.xml diff --git a/.gitmodules b/.gitmodules index 29a9742694..69e73f5a6a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "pro"] - path = pro +[submodule "app/src/pro"] + path = app/src/pro url = https://github.com/keymapperorg/KeyMapperPro diff --git a/app/build.gradle b/app/build.gradle index 57cb78ae1b..a0039312b0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -90,6 +90,7 @@ android { viewBinding true aidl true buildConfig true + compose true } compileOptions { @@ -105,6 +106,10 @@ android { kapt { correctErrorTypes = true } + + composeOptions { + kotlinCompilerExtensionVersion "1.5.10" + } } android.sourceSets { @@ -129,7 +134,6 @@ dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') compileOnly project(':systemstubs') - proImplementation project(":pro") def room_version = "2.6.1" def coroutinesVersion = "1.5.0" @@ -195,6 +199,10 @@ dependencies { implementation "androidx.core:core-splashscreen:1.0.1" kapt "androidx.room:room-compiler:$room_version" + // Compose + implementation 'androidx.compose.ui:ui-android:1.6.8' + implementation 'androidx.compose.material3:material3-android:1.2.1' + // debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.6' def junitVersion = "4.13.2" diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/assistant/AssistantTriggerFragment.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/assistant/AssistantTriggerFragment.kt new file mode 100644 index 0000000000..2cb72e8554 --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/assistant/AssistantTriggerFragment.kt @@ -0,0 +1,41 @@ +package io.github.sds100.keymapper.mappings.assistant + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import io.github.sds100.keymapper.databinding.FragmentComposeViewBinding + +class AssistantTriggerFragment : Fragment() { + private var _binding: FragmentComposeViewBinding? = null + + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentComposeViewBinding.inflate(inflater, container, false) + val view = binding.root + binding.composeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MaterialTheme { + Text("I am free build variant.") + } + } + } + + return view + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt index 81344e94be..ae1e948393 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt @@ -3,9 +3,9 @@ package io.github.sds100.keymapper.home import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.mappings.assistant.AssistantTriggerFragment import io.github.sds100.keymapper.mappings.fingerprintmaps.FingerprintMapListFragment import io.github.sds100.keymapper.mappings.keymaps.KeyMapListFragment -import io.github.sds100.keymapperpro.AssistantTriggerFragment /** * Created by sds100 on 26/01/2020. @@ -45,9 +45,7 @@ class HomePagerAdapter( HomeTab.ASSISTANT_TRIGGER -> { { - AssistantTriggerFragment().apply { - isAppBarVisible = false - } + AssistantTriggerFragment() } } } diff --git a/app/src/main/res/layout/fragment_compose_view.xml b/app/src/main/res/layout/fragment_compose_view.xml new file mode 100644 index 0000000000..cf6151272e --- /dev/null +++ b/app/src/main/res/layout/fragment_compose_view.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 41ccca99a4..a2a582b50a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,2 @@ include ':app' include ':systemstubs' -include ':pro' From e92d6d35e8cbd0bad2131218cf6ce07c7c1a0061 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 12 Aug 2024 20:53:08 -0600 Subject: [PATCH 006/118] #1274 update text for free version --- .../keymapper/mappings/assistant/AssistantTriggerFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/assistant/AssistantTriggerFragment.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/assistant/AssistantTriggerFragment.kt index 2cb72e8554..9dd2e1c91d 100644 --- a/app/src/free/java/io/github/sds100/keymapper/mappings/assistant/AssistantTriggerFragment.kt +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/assistant/AssistantTriggerFragment.kt @@ -26,7 +26,7 @@ class AssistantTriggerFragment : Fragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { MaterialTheme { - Text("I am free build variant.") + Text("This feature is only available in the Play store version of the app because it is behind a paywall.") } } } From a7d3fbc4d6c3bcf3e7a8e92471320c11052097d4 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 12 Aug 2024 20:55:55 -0600 Subject: [PATCH 007/118] #1274 add billing library dependency for pro build --- app/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/build.gradle b/app/build.gradle index a0039312b0..8602c10f45 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -165,6 +165,8 @@ dependencies { implementation "dev.rikka.shizuku:api:$shizuku_version" implementation "dev.rikka.shizuku:provider:$shizuku_version" implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3' + proImplementation "com.android.billingclient:billing:7.0.0" + proImplementation "com.android.billingclient:billing-ktx:7.0.0" // splitties implementation "com.louiscad.splitties:splitties-bitflags:$splitties_version" From c27961650fa61a8b826a944fd4a3b706b7e77cd5 Mon Sep 17 00:00:00 2001 From: Seth Schroeder Date: Sat, 14 Sep 2024 21:52:42 +0200 Subject: [PATCH 008/118] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b17a7a0b5..92079b4128 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -**Project in maintenance mode.** +**Project under slower development.** > Well, working on this project was a fun ride 🎢! This project has taught me so much about Android, software development and how to collaborate with an online community. It has been my dream to lead a big FOSS project with people from all over the world so a **huge** thank you goes to everyone that spread the word and helped on GitHub along the way ☺. Unfortunately, I do not have any more time to work on this project - I'm now studying Computer Science at university and I have landed software-dev side jobs, which has taken up any free-time I did have to code on the side. > > A special thank you goes to everyone in the [Team](https://docs.keymapper.club/#our-team) for their long-term From e30e30e02297cf954a9ebe4e253054e1cdcc30bb Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 14 Sep 2024 22:27:47 +0200 Subject: [PATCH 009/118] delete home tab for assistant trigger --- .../io/github/sds100/keymapper/home/HomePagerAdapter.kt | 8 -------- .../main/java/io/github/sds100/keymapper/home/HomeTab.kt | 1 - .../java/io/github/sds100/keymapper/home/HomeViewModel.kt | 2 -- app/src/main/res/values/strings.xml | 1 - 4 files changed, 12 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt index ae1e948393..22b272df85 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomePagerAdapter.kt @@ -3,7 +3,6 @@ package io.github.sds100.keymapper.home import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import io.github.sds100.keymapper.R -import io.github.sds100.keymapper.mappings.assistant.AssistantTriggerFragment import io.github.sds100.keymapper.mappings.fingerprintmaps.FingerprintMapListFragment import io.github.sds100.keymapper.mappings.keymaps.KeyMapListFragment @@ -19,7 +18,6 @@ class HomePagerAdapter( val TAB_NAMES: Map = mapOf( HomeTab.KEY_EVENTS to R.string.tab_keyevents, HomeTab.FINGERPRINT_MAPS to R.string.tab_fingerprint, - HomeTab.ASSISTANT_TRIGGER to R.string.tab_assistant_trigger ) } @@ -42,12 +40,6 @@ class HomePagerAdapter( } } } - - HomeTab.ASSISTANT_TRIGGER -> { - { - AssistantTriggerFragment() - } - } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeTab.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeTab.kt index bd07456154..264083953c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeTab.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeTab.kt @@ -6,5 +6,4 @@ package io.github.sds100.keymapper.home enum class HomeTab { KEY_EVENTS, FINGERPRINT_MAPS, - ASSISTANT_TRIGGER } diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt index 22ed21db4e..b3f6f3d6c1 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeViewModel.kt @@ -144,8 +144,6 @@ class HomeViewModel( if (showFingerprintMaps) { yield(HomeTab.FINGERPRINT_MAPS) } - - yield(HomeTab.ASSISTANT_TRIGGER) }.toList() val showTabs = when { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0dc501da5..aa2cf48f14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -209,7 +209,6 @@ Trigger Key events Fingerprint - Assistant trigger From f7387762a84ce63e3c80752c8df0da241ebcd680 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 16 Sep 2024 21:01:01 +0200 Subject: [PATCH 010/118] tests: duplicate shared test utils across android and unit tests because testShared gradle config no longer works with flavors --- app/build.gradle | 5 -- .../keymapper/AppDatabaseMigrationTest.kt | 6 +- .../github/sds100/keymapper/JsonTestUtils.kt | 88 +++++++++++++++++++ .../github/sds100/keymapper/util/FlowUtils.kt | 0 .../sds100/keymapper/util/JsonTestUtils.kt | 4 + .../sds100/keymapper/util/KeyMapUtils.kt | 0 6 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 app/src/androidTest/java/io/github/sds100/keymapper/JsonTestUtils.kt rename app/src/{testShared => test/java}/io/github/sds100/keymapper/util/FlowUtils.kt (100%) rename app/src/{testShared => test/java}/io/github/sds100/keymapper/util/JsonTestUtils.kt (96%) rename app/src/{testShared => test/java}/io/github/sds100/keymapper/util/KeyMapUtils.kt (100%) diff --git a/app/build.gradle b/app/build.gradle index ed87410406..ec3fc44076 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -113,12 +113,7 @@ android { } android.sourceSets { - test { - java.srcDirs += "$projectDir/src/testShared" - } - androidTest { - java.srcDirs += "$projectDir/src/testShared" assets.srcDirs += files("$projectDir/schemas".toString()) resources.srcDirs += ['src/test/resources'] } diff --git a/app/src/androidTest/java/io/github/sds100/keymapper/AppDatabaseMigrationTest.kt b/app/src/androidTest/java/io/github/sds100/keymapper/AppDatabaseMigrationTest.kt index a9d25db0db..83dc9f3ce9 100644 --- a/app/src/androidTest/java/io/github/sds100/keymapper/AppDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/io/github/sds100/keymapper/AppDatabaseMigrationTest.kt @@ -11,8 +11,12 @@ import androidx.test.espresso.matcher.ViewMatchers import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.github.salomonbrys.kotson.get +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.JsonParser import io.github.sds100.keymapper.data.db.AppDatabase -import io.github.sds100.keymapper.util.JsonTestUtils import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.MainScope import kotlinx.coroutines.runBlocking diff --git a/app/src/androidTest/java/io/github/sds100/keymapper/JsonTestUtils.kt b/app/src/androidTest/java/io/github/sds100/keymapper/JsonTestUtils.kt new file mode 100644 index 0000000000..d79454b9df --- /dev/null +++ b/app/src/androidTest/java/io/github/sds100/keymapper/JsonTestUtils.kt @@ -0,0 +1,88 @@ +package io.github.sds100.keymapper + +import com.github.salomonbrys.kotson.contains +import com.github.salomonbrys.kotson.forEach +import com.github.salomonbrys.kotson.get +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.core.Is.`is` +import org.junit.Assert + +/** + * Created by sds100 on 25/01/21. + */ +object JsonTestUtils { + private const val NAME_SEPARATOR = '/' + + fun compareBothWays(element: JsonElement, elementName: String, other: JsonElement, otherName: String) { + compare("", element, elementName, other, otherName) + compare("", other, elementName, element, elementName) + } + + private fun compare(parentNamePath: String = "", element: JsonElement, elementName: String, rootToCompare: JsonElement, rootName: String) { + when (element) { + is JsonObject -> { + element.forEach { name, jsonElement -> + val newPath = if (parentNamePath.isBlank()) { + name + } else { + "$parentNamePath$NAME_SEPARATOR$name" + } + + compare(newPath, jsonElement, elementName, rootToCompare, rootName) + } + } + + is JsonArray -> { + val pathToArrayToCompare = parentNamePath.split(NAME_SEPARATOR) + var arrayToCompare: JsonArray? = null + + var parentElement: JsonElement = rootToCompare + pathToArrayToCompare.forEach { + if (it == "") return@forEach + + parentElement = parentElement[it] + } + + if (parentElement is JsonArray) { + arrayToCompare = parentElement as JsonArray + } + + Assert.assertNotNull("can't find array $elementName/$parentNamePath in $rootName", arrayToCompare) + arrayToCompare ?: return + + element.forEachIndexed { index, arrayElement -> + val validIndex = index <= arrayToCompare.toList().lastIndex + + assertThat("$rootName/${pathToArrayToCompare.last()} doesn't contain $arrayElement at $index index", validIndex) + + compare("", arrayElement, "$elementName/${pathToArrayToCompare.last()}", arrayToCompare[index]!!, "$rootName/${pathToArrayToCompare.last()}") + } + } + + is JsonPrimitive -> { + val names = parentNamePath.split(NAME_SEPARATOR) + var parentElement: JsonElement = rootToCompare + + if (names == listOf("")) { + assertThat("$elementName/:$element doesn't match $rootName/:$parentElement", (parentElement), `is`(element)) + } else { + names.forEachIndexed { index, name -> + if (parentElement is JsonObject) { + assertThat("$elementName/$parentNamePath not found in $rootName", (parentElement as JsonObject).contains(name)) + } + + parentElement = parentElement[name] + + if (index == names.lastIndex) { + assertThat("$elementName/$parentNamePath:$element doesn't match $rootName/$parentNamePath:$parentElement", (parentElement as JsonPrimitive), `is`(element)) + } + } + } + } + } + } +} diff --git a/app/src/testShared/io/github/sds100/keymapper/util/FlowUtils.kt b/app/src/test/java/io/github/sds100/keymapper/util/FlowUtils.kt similarity index 100% rename from app/src/testShared/io/github/sds100/keymapper/util/FlowUtils.kt rename to app/src/test/java/io/github/sds100/keymapper/util/FlowUtils.kt diff --git a/app/src/testShared/io/github/sds100/keymapper/util/JsonTestUtils.kt b/app/src/test/java/io/github/sds100/keymapper/util/JsonTestUtils.kt similarity index 96% rename from app/src/testShared/io/github/sds100/keymapper/util/JsonTestUtils.kt rename to app/src/test/java/io/github/sds100/keymapper/util/JsonTestUtils.kt index e98e8edd15..71e2d52856 100644 --- a/app/src/testShared/io/github/sds100/keymapper/util/JsonTestUtils.kt +++ b/app/src/test/java/io/github/sds100/keymapper/util/JsonTestUtils.kt @@ -3,6 +3,10 @@ package io.github.sds100.keymapper.util import com.github.salomonbrys.kotson.contains import com.github.salomonbrys.kotson.forEach import com.github.salomonbrys.kotson.get +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.core.Is.`is` import org.junit.Assert diff --git a/app/src/testShared/io/github/sds100/keymapper/util/KeyMapUtils.kt b/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt similarity index 100% rename from app/src/testShared/io/github/sds100/keymapper/util/KeyMapUtils.kt rename to app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt From 9e04eb68380e2b2bda349df71d7430347b0da130 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 16 Sep 2024 21:01:01 +0200 Subject: [PATCH 011/118] tests: duplicate shared test utils across android and unit tests because testShared gradle config no longer works with flavors --- app/build.gradle | 5 -- .../keymapper/AppDatabaseMigrationTest.kt | 6 +- .../github/sds100/keymapper/JsonTestUtils.kt | 88 +++++++++++++++++++ .../github/sds100/keymapper/util/FlowUtils.kt | 0 .../sds100/keymapper/util/JsonTestUtils.kt | 4 + .../sds100/keymapper/util/KeyMapUtils.kt | 0 6 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 app/src/androidTest/java/io/github/sds100/keymapper/JsonTestUtils.kt rename app/src/{testShared => test/java}/io/github/sds100/keymapper/util/FlowUtils.kt (100%) rename app/src/{testShared => test/java}/io/github/sds100/keymapper/util/JsonTestUtils.kt (96%) rename app/src/{testShared => test/java}/io/github/sds100/keymapper/util/KeyMapUtils.kt (100%) diff --git a/app/build.gradle b/app/build.gradle index 734ec6a343..45bfc459c6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -98,12 +98,7 @@ android { } android.sourceSets { - test { - java.srcDirs += "$projectDir/src/testShared" - } - androidTest { - java.srcDirs += "$projectDir/src/testShared" assets.srcDirs += files("$projectDir/schemas".toString()) resources.srcDirs += ['src/test/resources'] } diff --git a/app/src/androidTest/java/io/github/sds100/keymapper/AppDatabaseMigrationTest.kt b/app/src/androidTest/java/io/github/sds100/keymapper/AppDatabaseMigrationTest.kt index a9d25db0db..83dc9f3ce9 100644 --- a/app/src/androidTest/java/io/github/sds100/keymapper/AppDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/io/github/sds100/keymapper/AppDatabaseMigrationTest.kt @@ -11,8 +11,12 @@ import androidx.test.espresso.matcher.ViewMatchers import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.github.salomonbrys.kotson.get +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.JsonParser import io.github.sds100.keymapper.data.db.AppDatabase -import io.github.sds100.keymapper.util.JsonTestUtils import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.MainScope import kotlinx.coroutines.runBlocking diff --git a/app/src/androidTest/java/io/github/sds100/keymapper/JsonTestUtils.kt b/app/src/androidTest/java/io/github/sds100/keymapper/JsonTestUtils.kt new file mode 100644 index 0000000000..d79454b9df --- /dev/null +++ b/app/src/androidTest/java/io/github/sds100/keymapper/JsonTestUtils.kt @@ -0,0 +1,88 @@ +package io.github.sds100.keymapper + +import com.github.salomonbrys.kotson.contains +import com.github.salomonbrys.kotson.forEach +import com.github.salomonbrys.kotson.get +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.core.Is.`is` +import org.junit.Assert + +/** + * Created by sds100 on 25/01/21. + */ +object JsonTestUtils { + private const val NAME_SEPARATOR = '/' + + fun compareBothWays(element: JsonElement, elementName: String, other: JsonElement, otherName: String) { + compare("", element, elementName, other, otherName) + compare("", other, elementName, element, elementName) + } + + private fun compare(parentNamePath: String = "", element: JsonElement, elementName: String, rootToCompare: JsonElement, rootName: String) { + when (element) { + is JsonObject -> { + element.forEach { name, jsonElement -> + val newPath = if (parentNamePath.isBlank()) { + name + } else { + "$parentNamePath$NAME_SEPARATOR$name" + } + + compare(newPath, jsonElement, elementName, rootToCompare, rootName) + } + } + + is JsonArray -> { + val pathToArrayToCompare = parentNamePath.split(NAME_SEPARATOR) + var arrayToCompare: JsonArray? = null + + var parentElement: JsonElement = rootToCompare + pathToArrayToCompare.forEach { + if (it == "") return@forEach + + parentElement = parentElement[it] + } + + if (parentElement is JsonArray) { + arrayToCompare = parentElement as JsonArray + } + + Assert.assertNotNull("can't find array $elementName/$parentNamePath in $rootName", arrayToCompare) + arrayToCompare ?: return + + element.forEachIndexed { index, arrayElement -> + val validIndex = index <= arrayToCompare.toList().lastIndex + + assertThat("$rootName/${pathToArrayToCompare.last()} doesn't contain $arrayElement at $index index", validIndex) + + compare("", arrayElement, "$elementName/${pathToArrayToCompare.last()}", arrayToCompare[index]!!, "$rootName/${pathToArrayToCompare.last()}") + } + } + + is JsonPrimitive -> { + val names = parentNamePath.split(NAME_SEPARATOR) + var parentElement: JsonElement = rootToCompare + + if (names == listOf("")) { + assertThat("$elementName/:$element doesn't match $rootName/:$parentElement", (parentElement), `is`(element)) + } else { + names.forEachIndexed { index, name -> + if (parentElement is JsonObject) { + assertThat("$elementName/$parentNamePath not found in $rootName", (parentElement as JsonObject).contains(name)) + } + + parentElement = parentElement[name] + + if (index == names.lastIndex) { + assertThat("$elementName/$parentNamePath:$element doesn't match $rootName/$parentNamePath:$parentElement", (parentElement as JsonPrimitive), `is`(element)) + } + } + } + } + } + } +} diff --git a/app/src/testShared/io/github/sds100/keymapper/util/FlowUtils.kt b/app/src/test/java/io/github/sds100/keymapper/util/FlowUtils.kt similarity index 100% rename from app/src/testShared/io/github/sds100/keymapper/util/FlowUtils.kt rename to app/src/test/java/io/github/sds100/keymapper/util/FlowUtils.kt diff --git a/app/src/testShared/io/github/sds100/keymapper/util/JsonTestUtils.kt b/app/src/test/java/io/github/sds100/keymapper/util/JsonTestUtils.kt similarity index 96% rename from app/src/testShared/io/github/sds100/keymapper/util/JsonTestUtils.kt rename to app/src/test/java/io/github/sds100/keymapper/util/JsonTestUtils.kt index e98e8edd15..71e2d52856 100644 --- a/app/src/testShared/io/github/sds100/keymapper/util/JsonTestUtils.kt +++ b/app/src/test/java/io/github/sds100/keymapper/util/JsonTestUtils.kt @@ -3,6 +3,10 @@ package io.github.sds100.keymapper.util import com.github.salomonbrys.kotson.contains import com.github.salomonbrys.kotson.forEach import com.github.salomonbrys.kotson.get +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.core.Is.`is` import org.junit.Assert diff --git a/app/src/testShared/io/github/sds100/keymapper/util/KeyMapUtils.kt b/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt similarity index 100% rename from app/src/testShared/io/github/sds100/keymapper/util/KeyMapUtils.kt rename to app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt From 731a329e8d2cc2d469c0b362633a6b5bc53395f5 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 16 Sep 2024 21:26:03 +0200 Subject: [PATCH 012/118] #1297: send events to accessibility service asynchronously --- .../accessibility/AccessibilityServiceAdapter.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceAdapter.kt index f97fb10254..2b39e3cade 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceAdapter.kt @@ -77,6 +77,16 @@ class AccessibilityServiceAdapter( }.launchIn(coroutineScope) } + /** + * Send an event to the accessibility service asynchronously. This method + * will return immediately and you won't be notified of whether it is sent. + */ + fun sendAsync(event: ServiceEvent) { + coroutineScope.launch { + eventsToService.emit(event) + } + } + override suspend fun send(event: ServiceEvent): Result<*> { state.value = getState() @@ -171,8 +181,8 @@ class AccessibilityServiceAdapter( settingsIntent.addFlags( Intent.FLAG_ACTIVITY_NEW_TASK - or Intent.FLAG_ACTIVITY_CLEAR_TASK - or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS, + or Intent.FLAG_ACTIVITY_CLEAR_TASK + or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS, ) ctx.startActivity(settingsIntent) From 3c39f576e334a296035f0fbf1b4384223f9aadb8 Mon Sep 17 00:00:00 2001 From: sds100 Date: Mon, 16 Sep 2024 21:37:14 +0200 Subject: [PATCH 013/118] #1297: make accessibility service controller an abstract class --- .../assistant/AssistantTriggerFragment.kt | 41 ---------------- .../AccessibilityServiceController.kt | 48 +++++++++++++++++++ ... => BaseAccessibilityServiceController.kt} | 17 ++++--- 3 files changed, 58 insertions(+), 48 deletions(-) delete mode 100644 app/src/free/java/io/github/sds100/keymapper/mappings/assistant/AssistantTriggerFragment.kt create mode 100644 app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt rename app/src/main/java/io/github/sds100/keymapper/system/accessibility/{AccessibilityServiceController.kt => BaseAccessibilityServiceController.kt} (97%) diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/assistant/AssistantTriggerFragment.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/assistant/AssistantTriggerFragment.kt deleted file mode 100644 index 9dd2e1c91d..0000000000 --- a/app/src/free/java/io/github/sds100/keymapper/mappings/assistant/AssistantTriggerFragment.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.github.sds100.keymapper.mappings.assistant - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.Fragment -import io.github.sds100.keymapper.databinding.FragmentComposeViewBinding - -class AssistantTriggerFragment : Fragment() { - private var _binding: FragmentComposeViewBinding? = null - - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentComposeViewBinding.inflate(inflater, container, false) - val view = binding.root - binding.composeView.apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - MaterialTheme { - Text("This feature is only available in the Play store version of the app because it is behind a paywall.") - } - } - } - - return view - } - - override fun onDestroyView() { - _binding = null - super.onDestroyView() - } -} \ No newline at end of file diff --git a/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt b/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt new file mode 100644 index 0000000000..4690a1e34e --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt @@ -0,0 +1,48 @@ +package io.github.sds100.keymapper.system.accessibility + +import io.github.sds100.keymapper.actions.PerformActionsUseCase +import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase +import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.mappings.PauseMappingsUseCase +import io.github.sds100.keymapper.mappings.fingerprintmaps.DetectFingerprintMapsUseCase +import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase +import io.github.sds100.keymapper.reroutekeyevents.RerouteKeyEventsUseCase +import io.github.sds100.keymapper.system.devices.DevicesAdapter +import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter +import io.github.sds100.keymapper.system.root.SuAdapter +import io.github.sds100.keymapper.util.ServiceEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +class AccessibilityServiceController( + coroutineScope: CoroutineScope, + accessibilityService: IAccessibilityService, + inputEvents: SharedFlow, + outputEvents: MutableSharedFlow, + detectConstraintsUseCase: DetectConstraintsUseCase, + performActionsUseCase: PerformActionsUseCase, + detectKeyMapsUseCase: DetectKeyMapsUseCase, + detectFingerprintMapsUseCase: DetectFingerprintMapsUseCase, + rerouteKeyEventsUseCase: RerouteKeyEventsUseCase, + pauseMappingsUseCase: PauseMappingsUseCase, + devicesAdapter: DevicesAdapter, + suAdapter: SuAdapter, + inputMethodAdapter: InputMethodAdapter, + settingsRepository: PreferenceRepository, +) : BaseAccessibilityServiceController( + coroutineScope, + accessibilityService, + inputEvents, + outputEvents, + detectConstraintsUseCase, + performActionsUseCase, + detectKeyMapsUseCase, + detectFingerprintMapsUseCase, + rerouteKeyEventsUseCase, + pauseMappingsUseCase, + devicesAdapter, + suAdapter, + inputMethodAdapter, + settingsRepository +) \ No newline at end of file diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt similarity index 97% rename from app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt rename to app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt index 9b3775514b..1e635420a3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt @@ -54,7 +54,7 @@ import timber.log.Timber /** * Created by sds100 on 17/04/2021. */ -class AccessibilityServiceController( +abstract class BaseAccessibilityServiceController( private val coroutineScope: CoroutineScope, private val accessibilityService: IAccessibilityService, private val inputEvents: SharedFlow, @@ -92,7 +92,7 @@ class AccessibilityServiceController( detectConstraintsUseCase, ) - private val keymapDetectionDelegate = KeyMapController( + private val keyMapController = KeyMapController( coroutineScope, detectKeyMapsUseCase, performActionsUseCase, @@ -123,7 +123,7 @@ class AccessibilityServiceController( if (!isPaused.value) { withContext(Dispatchers.Main.immediate) { - keymapDetectionDelegate.onKeyEvent( + keyMapController.onKeyEvent( keyCode, action, metaState = 0, @@ -196,7 +196,7 @@ class AccessibilityServiceController( } pauseMappingsUseCase.isPaused.distinctUntilChanged().onEach { - keymapDetectionDelegate.reset() + keyMapController.reset() fingerprintMapController.reset() triggerKeyMapFromOtherAppsController.reset() }.launchIn(coroutineScope) @@ -324,7 +324,7 @@ class AccessibilityServiceController( try { var consume: Boolean - consume = keymapDetectionDelegate.onKeyEvent( + consume = keyMapController.onKeyEvent( keyCode, action, metaState, @@ -426,7 +426,7 @@ class AccessibilityServiceController( triggerKeyMapFromOtherAppsController.onDetected(uid) } - private fun onEventFromUi(event: ServiceEvent) { + open fun onEventFromUi(event: ServiceEvent) { Timber.d("Service received event from UI: $event") when (event) { is ServiceEvent.StartRecordingTrigger -> @@ -449,7 +449,10 @@ class AccessibilityServiceController( is ServiceEvent.TestAction -> performActionsUseCase.perform(event.action) - is ServiceEvent.Ping -> coroutineScope.launch { outputEvents.emit(ServiceEvent.Pong(event.key)) } + is ServiceEvent.Ping -> coroutineScope.launch { + outputEvents.emit(ServiceEvent.Pong(event.key)) + } + is ServiceEvent.HideKeyboard -> accessibilityService.hideKeyboard() is ServiceEvent.ShowKeyboard -> accessibilityService.showKeyboard() is ServiceEvent.ChangeIme -> accessibilityService.switchIme(event.imeId) From e6f3bbc4eae1133b08d4785500fd9cc9670dc02c Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 25 Sep 2024 17:02:52 +0200 Subject: [PATCH 014/118] #1304 docs: add vietnamese translations badge to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 92079b4128..e03504b293 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Key Mapper is a free and open source Android app that can remap your buttons and ![pl translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Polish&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27pl%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) ![ru translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Russian&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27ru%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) ![sk translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Slovak&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27sk%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) +![vi translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Vietnamese&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27vi%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) ![zh-CN translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Chinese%20(Simplified)&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27zh-CN%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) ## Star History From 9c3546060930cb5f2d27110fa3201ca0af5040f7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 25 Sep 2024 17:08:06 +0200 Subject: [PATCH 015/118] chore: use github actions checkout@v4 --- .github/workflows/build-mkdocs.yml | 2 +- .github/workflows/production.yml | 2 +- .github/workflows/pull-request.yml | 4 ++-- .github/workflows/testing.yml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-mkdocs.yml b/.github/workflows/build-mkdocs.yml index 752f6a711a..d97c50f25c 100644 --- a/.github/workflows/build-mkdocs.yml +++ b/.github/workflows/build-mkdocs.yml @@ -14,7 +14,7 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: 3.x diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 23d9bc1dbf..5b20b9423b 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: actions/cache@v3 with: diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 2356ae0a8a..c5c1c00520 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: actions/cache@v3 with: @@ -38,7 +38,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: actions/cache@v3 with: diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index dc882a2172..778c991250 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: actions/cache@v3 with: @@ -44,7 +44,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - uses: actions/cache@v3 with: From 6886e22535ee1432e2dc6844d7756f2bdd32ce19 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 25 Sep 2024 17:10:48 +0200 Subject: [PATCH 016/118] chore: use github actions upload-artifact@v4 --- .github/workflows/testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 778c991250..8696db012f 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -97,7 +97,7 @@ jobs: run: echo "APK_NAME=$(basename app/build/outputs/apk/ci/*.apk .apk)" >> $GITHUB_ENV - name: Upload APK - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v4 with: name: ${{ env.APK_NAME }} path: app/build/outputs/apk/ci/${{ env.APK_NAME }}.apk From 6d548853078d8c9269d169d8eb8c57a3c6072cd8 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 25 Sep 2024 15:11:22 +0000 Subject: [PATCH 017/118] New Crowdin translations by GitHub Action --- app/src/main/res/values-uk/strings.xml | 66 ++ app/src/main/res/values-vi/strings.xml | 1055 +++++++++++++++++ .../android/uk_UA/full_description.txt | 38 + .../android/uk_UA/short_description.txt | 1 + fastlane/metadata/android/uk_UA/title.txt | 1 + .../android/vi_VN/full_description.txt | 38 + .../android/vi_VN/short_description.txt | 1 + fastlane/metadata/android/vi_VN/title.txt | 1 + 8 files changed, 1201 insertions(+) create mode 100644 app/src/main/res/values-uk/strings.xml create mode 100644 app/src/main/res/values-vi/strings.xml create mode 100644 fastlane/metadata/android/uk_UA/full_description.txt create mode 100644 fastlane/metadata/android/uk_UA/short_description.txt create mode 100644 fastlane/metadata/android/uk_UA/title.txt create mode 100644 fastlane/metadata/android/vi_VN/full_description.txt create mode 100644 fastlane/metadata/android/vi_VN/short_description.txt create mode 100644 fastlane/metadata/android/vi_VN/title.txt diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml new file mode 100644 index 0000000000..8b2b1737b0 --- /dev/null +++ b/app/src/main/res/values-uk/strings.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000000..094f2853d8 --- /dev/null +++ b/app/src/main/res/values-vi/strings.xml @@ -0,0 +1,1055 @@ + + + Giải phóng chìa khóa của bạn! + Không có hành động nào được chọn + Key Mapper yêu cầu sử dụng dịch vụ trợ năng để dịch vụ này có thể phát hiện và thay đổi thao tác nhấn nút của bạn khi bạn ở ngoài ứng dụng. Bản đồ chính của bạn sẽ chỉ hoạt động khi bạn đã bật dịch vụ trợ năng. Nó cũng phải được bật để tạo trình kích hoạt. + %d Đã chọn + Cho phép + Mở + ¯\\_(ツ)_/¯\n\nKhông có gì ở đây! + Ghi thao tác kích hoạt! + Thêm hành động! + ¯\\_(ツ)_/¯\n\n Tạo một hành động! + ¯\\_(ツ)_/¯\n\nBạn chưa chọn bất kỳ hành động nào cho phím tắt này! + Bạn chưa tạo bất kỳ bản đồ quan trọng nào! + Mọi thứ đều có vẻ tốt! + Ứng dụng sẽ hoạt động nhưng có thể cần khắc phục một số lỗi tùy thuộc vào việc bạn đang làm. + Thiết bị của bạn không hỗ trợ một số hành động. + Hành động không được hỗ trợ + Yêu cầu root + Nhấn… + Không có hành động nào + Không có hành động kích hoạt + Mã khóa không xác định: %s + Tên thiết bị không xác định + Bật + Tắt + Theo dõi hệ thống + Thiết bị này + Bất kỳ thiết bị nào + Không biết tên của thiết bị này + Mặc định + Bật dịch vụ trợ năng + Khởi động lại dịch vụ trợ năng + Chia sẻ + Dừng lặp lại khi… + Trình kích hoạt được phát hiện + Phím được nhấn lại + Đã đạt đến giới hạn + Vuốt lại + Trình kích hoạt được phát hiện + Phím được nhấn lại + Hiển thị ứng dụng ẩn + Sửa đổi + Chuyển đổi + QUAN TRỌNG!!! Các tọa độ này chỉ chính xác khi màn hình của bạn cùng hướng với ảnh chụp màn hình! Hành động này sẽ hủy mọi thao tác chạm hoặc cử chỉ bạn đang thực hiện trên màn hình.\n\nNếu bạn cần trợ giúp tìm tọa độ của một điểm trên màn hình, hãy chụp ảnh màn hình rồi nhấn vào ảnh chụp màn hình nơi bạn muốn nhấn vào hành động này. + Lưu ý: Khi sử dụng \"pinch in\" X và Y là tọa độ END, khi sử dụng \"pinch out\" X và Y là tọa độ START. + Không biết tên máy! + Nhấn vào hành động để khắc phục! + Nhấn vào các hạn chế để khắc phục! + Thực hiện hành động + Giữ cho đến khi kích hoạt… + Không có thiết bị + ¯\\_(ツ)_/¯\n\nKhông có tính năng bổ sung! + Tạo sơ đồ phím mới + Đã hoàn tất việc định cấu hình hành động sự kiện quan trọng + Chọn tọa độ xong + Nhật ký hành động + Gửi tới + Có gì mới + Bấm vào phím trên thiết bị mà bạn muốn nhập. \n\nQUAN Trọng! Việc nhập phím này dưới dạng hành động sẽ chỉ hoạt động nếu bạn đang sử dụng bàn phím tương thích với Key Mapper. + QUAN TRỌNG! Việc nhập mã khóa này dưới dạng hành động sẽ chỉ có tác dụng nếu bạn đang sử dụng bàn phím tương thích với Key Mapper. + Tệp âm thanh sẽ được sao chép vào thư mục dữ liệu riêng tư của Key Mapper, điều đó có nghĩa là các thao tác của bạn vẫn hoạt động ngay cả khi tệp bị di chuyển hoặc xóa. Nó cũng sẽ được sao lưu cùng với bản đồ chính của bạn trong thư mục zip. Bạn có thể xóa các tập tin âm thanh đã lưu trong cài đặt. + Nhập một số văn bản bạn muốn chèn vào khi thực hiện hành động này. + Nhập số điện thoại. + Nhập URL mà bạn muốn mở. http://, https:// hoặc www. không bắt buộc. + Không thể tìm thấy bất kỳ thiết bị được ghép nối nào. Bluetooth đã được bật chưa? + Tùy chọn \"Cho phép các ứng dụng khác kích hoạt sơ đồ bàn phím này\" sẽ được bật cho sơ đồ bàn phím mà bạn chọn nếu chưa bật. Nếu sau này bạn tắt tùy chọn này thì mọi phím tắt hoặc Ý định kích hoạt sơ đồ phím này sẽ không hoạt động. + Đã bật + Tàn tật + Khôi phục + Sau khi đã bật quản trị viên thiết bị, bạn phải TẮT KÍCH HOẠT nó nếu muốn gỡ cài đặt Key Mapper. + Thêm một hạn chế! + Đợi %sms + Bắt đầu hoạt động: %s + Bắt đầu dịch vụ: %s + Gửi phát sóng: %s + Sơ đồ chính UUID + Sử dụng shell (chỉ ROOT) + Key Mapper yêu cầu quyền sửa đổi chế độ Không làm phiền nếu bạn muốn các nút hoạt động như mong đợi ở chế độ Không làm phiền! + Trình kích hoạt này sẽ không hoạt động như mong đợi ở chế độ Không làm phiền! + Tùy chọn kích hoạt khi màn hình tắt cần có quyền root để hoạt động! + Tùy chọn kích hoạt khi màn hình tắt sẽ không hoạt động! + Trình kích hoạt này sẽ không hoạt động khi đang đổ chuông hoặc đang gọi điện thoại! + Android không cho phép các dịch vụ trợ năng phát hiện các lần nhấn nút âm lượng khi điện thoại của bạn đang đổ chuông hoặc đang trong cuộc gọi điện thoại, nhưng nó cho phép các dịch vụ phương thức nhập liệu phát hiện chúng. Do đó, bạn phải sử dụng một trong các bàn phím Key Mapper nếu muốn trình kích hoạt này hoạt động. + Quá nhiều ngón tay để thực hiện cử chỉ do giới hạn của Android. + Thời lượng cử chỉ quá cao do giới hạn của Android. + Hành động của bạn sẽ ngừng hoạt động ngẫu nhiên! + Hành động của bạn đã bị tạm dừng! + Bỏ tạm dừng + Dịch vụ trợ năng cần được bật để hành động của bạn hoạt động! + Điện thoại của bạn đã tắt Key Mapper khi nó ở chế độ nền hoặc bị hỏng! + Dịch vụ trợ năng đã được kích hoạt! Hành động của bạn sẽ hoạt động. + Ghi nhật ký bổ sung được bật! Hãy tắt tính năng này nếu bạn không cố gắng khắc phục sự cố. + Tắt + Về + + Mở %s + Nhấn phím \'%s\' + Nhập \'%s\' + Nhập %s%s + Nhập %s qua shell + Nhập %s%s từ %s + Mở %s + Nhấn vào tọa độ %d, %d + Nhấn vào tọa độ %d, %d (%s) + Vuốt bằng %d ngón tay từ tọa độ %d/%d đến %d/%d trong %dms + Vuốt bằng %d ngón tay từ tọa độ %d/%d đến %d/%d trong %dms (%s) + %s bằng %d ngón tay(s) trên tọa độ %d/%d với khoảng cách chụm là %dpx tính bằng %dms + %s với %d ngón tay trên tọa độ %d/%d đến với khoảng cách chụm là %dpx %dms (%s) + Gọi %s + Phát âm thanh: %s + + + Tùy chọn + Hành động + Trigger + Hạn chế + Vuốt lên + Vuốt xuống + Vuốt sang trái + Vuốt sang phải + Kiểu nhấp chuột + Tiện ích bổ sung + + + Bắt đầu X + Bắt đầu Y + Kết thúc X + Kết thúc Y + Khoảng cách chụm (px) + Kiểu kẹp + Chụm vào + Chụm ra + Mã khóa + Từ thiết bị + Tên phím tắt + Mô tả tọa độ (tùy chọn) + Văn bản cần nhập + Url để mở + Số điện thoại để gọi + Hoạt động + Thể loại + Dữ liệu + Bưu kiện + Class + Tên + Giá trị (%s) + Mô tả cho Key Mapper (bắt buộc) + Flags + Mô tả tập tin âm thanh + SSID mạng Wi-Fi + + + Đồng thời + Theo thứ tự + + HOẶC + Bấm nhanh + Nhấn giữ + Nhấn đúp + Đúng + Sai + Hoạt động + Dịch vụ + Máy thu phát sóng + + + Kích hoạt và hành động + Những hạn chế và hơn thế nữa + Tùy chọn + Hạn chế + Hành động + Trigger + Dấu vân tay + + @string/tab_keyevents + @string/tab_fingerprint + + + + + + Đã chọn %s + Sao lưu thành công! + Sao lưu thất bại! + Khôi phục thành công! + Khôi phục thất bại! + Tự động sao lưu thành công! + Tự động sao lưu thất bại! + Đã chụp ảnh màn hình + IO exception ¯\\_(ツ)_/¯ + Độ phân giải ảnh chụp màn hình không khớp với độ phân giải của thiết bị này! + Đã sao chép sơ đồ khóa UUID vào bảng nhớ tạm + Bạn đã kích hoạt hành động + Nhật ký được sao chép + Tính năng root hiện đã được kích hoạt + Chọn tập tin âm thanh không thành công! Kiểm tra nhật ký. + Bạn chưa lưu tập tin âm thanh nào! + Key Mapper đã sử dụng Shizuku để tự cấp quyền WRITE_SECURE_SETTINGS + Key Mapper đã sử dụng Root để tự cấp quyền WRITE_SECURE_SETTINGS + + + Sequence trigger timeout (ms) + Độ trễ nhấn giữ (ms) + Thời gian chờ nhấn đúp (ms) + Trì hoãn cho đến khi lặp lại (ms) + Giới hạn lặp lại + Lặp lại mỗi… (ms) + Thời gian rung (ms) + Đã bao nhiêu lần + Mỗi lần lặp lại bao nhiêu lần + + Thời gian giữ phím (ms) + Thời lượng vuốt (ms) + Đếm ngón tay + Tọa độ để thiết lập với ảnh chụp màn hình + Bắt đầu + Kết thúc + Thời lượng chụm (ms) + Đếm ngón tay + + + %s ở phía trước + %s không ở phía trước + %s đang phát phương tiện + %s không phát phương tiện + %s đã được kết nối + %s bị ngắt kết nối + Màn hình bật + Màn hình tắt + đèn pin %s đang tắt + Đèn pin %s đang bật + + Hoặc + Ứng dụng + Bluetooth + Màn hình + Định hướng + Ứng dụng ở phía trước + Ứng dụng không ở nền trước + Thiết bị Bluetooth đã được kết nối + Thiết bị Bluetooth bị ngắt kết nối + Màn hình bật + Màn hình tắt + Màn hình xoay (0°) + Màn hình xoay (90°) + Màn hình xoay (180°) + Màn hình xoay (270°) + Màn hình xoay (bất kỳ) + Phong cảnh (bất kỳ) + Ứng dụng phát âm thanh + Ứng dụng không phát âm thanh + Âm thanh đang phát + Không có âm thanh nào đang phát + Đèn pin đang bật + Đèn pin đã tắt + WiFi đang bật + Wi-Fi đã tắt + Đã kết nối với mạng WiFi + Đã ngắt kết nối khỏi mạng WiFi + Bạn sẽ phải nhập SSID theo cách thủ công vì các ứng dụng không được phép truy vấn danh sách các mạng WiFi đã biết trên Android 10 trở lên. Để trống nếu có bất kỳ mạng WiFi nào phù hợp. + Bất kì + Đã kết nối với WiFi %s + Đã ngắt kết nối với WiFi %s + Đã kết nối với bất kỳ WiFi nào + Đã ngắt kết nối và không có WiFi + Phương thức nhập được chọn + %s được chọn + Phương thức nhập không được chọn + %s không được chọn + Thiết bị đã bị khóa + Thiết bị đã được mở khóa + Trong cuộc gọi điện thoại + Không có trong cuộc gọi điện thoại + Điện thoại đổ chuông + Sạc + Xả + Màn hình xoay (0°) + Màn hình xoay (90°) + Màn hình xoay (180°) + Màn hình xoay (270°) + + + Bấm nhanh + Nhấn giữ + Nhấn đúp + + + + + Chọn hành động + + + Tắt tính năng diệt ứng dụng + Hãy làm theo hướng dẫn tại Dontkillmyapp.com để chỉ cho bạn cách tắt tất cả các \"tính năng\" tiêu diệt ứng dụng trên điện thoại của bạn. \n\nSau khi đọc hướng dẫn, bạn sẽ cần chuyển sang trang trình bày tiếp theo và khởi động lại dịch vụ trợ năng. + Mở hướng dẫn + Khởi động lại dịch vụ trợ năng + Dịch vụ trợ năng phải được khởi động lại. Tắt nó đi và bật lại. + Khởi động lại + Báo cáo lỗi + Chọn vị trí để lưu báo cáo lỗi bằng cách nhấn vào \"tạo báo cáo\". Trang trình bày tiếp theo sẽ cho biết cách bạn có thể gửi nó cho nhà phát triển. + Tạo báo cáo + Chia sẻ báo cáo + Có 2 cách để chia sẻ báo cáo lỗi cho nhà phát triển. Tham gia máy chủ Discord hoặc tạo sự cố GitHub. Đảm bảo rằng bạn đính kèm báo cáo lỗi vào tin nhắn của mình! + Discord + GitHub + + + Cài đặt + Xong + Chọn tất cả + Về + Tìm kiếm + Hướng dẫn bắt đầu nhanh + Help + Cho phép + Vô hiệu hóa + Vô hiệu hóa tất cả + Kích hoạt tất cả + Báo cáo lỗi + Hiển thị hộp chọn phương thức nhập + Cơ sở dữ liệu hạt giống + Lưu + Nhân bản + Lên + Khôi phục + Sao lưu mọi thứ + Sao lưu tất cả + Nhấn để tạm dừng + Nhấn để tiếp tục + Khôi phục + Lưu + Chuyển đổi tin nhắn ngắn + Sao chép + Xóa + + + Thêm hành động + Ghi kích hoạt + Xong + Lưu + Sửa chữa + %d… + Thêm hạm chế + Chọn mã khóa + Đúng! + Chọn hành động + Thêm bổ sung + Tạo lối tắt trình khởi chạy + Tạo lối tắt thủ công + Hướng dẫn ý định + Help + Chọn ảnh chụp màn hình (tùy chọn) + Chọn hoạt động + Set flags + Sao chép + Không giới hạn + Chọn tập tin âm thanh + Chỉnh sửa hành động + Thay thế hành động + + + Bạn có chắc không? + Tạo tiêu đề lối tắt + Cần có quyền root! + Hơn + Chọn luồng + Chọn đèn nháy + Không thể tìm thấy trang cài đặt trợ năng + Không thể ghi kích hoạt? + Hủy thay đổi của bạn + Bạn có chắc chắn muốn hủy các thay đổi của mình không? + Nếu bạn biết điện thoại của mình chưa được root hoặc bạn không biết root là gì thì bạn không thể sử dụng các tính năng chỉ hoạt động trên các thiết bị đã root. Khi bạn nhấn \'OK\', bạn sẽ được đưa đến cài đặt. Trong cài đặt, cuộn xuống cuối và nhấn \'Key Mapper có quyền root\' để bạn có thể sử dụng các tính năng/hành động gốc. + Đang tải xuống… + Nhấn lâu chỉ có tác dụng với các nút âm lượng và điều hướng vật lý. Nếu bạn bật tính năng này cho các phím khác, các phím đó sẽ không hoạt động khi chúng không được nhấn lâu. + Cấp quyền WRITE_SECURE_SETTINGS + Cần có PC/Mac để cấp quyền này. Đọc hướng dẫn trực tuyến. + Thiết bị của bạn dường như không có trang cài đặt dịch vụ trợ năng. Nhấn vào \"hướng dẫn\" để đọc hướng dẫn trực tuyến giải thích cách khắc phục vấn đề này. + "Nhiều phím không thể được nhấn đúp cùng một lúc." + Các phím cần được liệt kê từ trên xuống dưới theo thứ tự chúng sẽ được giữ. + Trình kích hoạt \"trình tự\" có thời gian chờ không giống như trình kích hoạt song song. Điều này có nghĩa là sau khi nhấn phím đầu tiên, bạn sẽ có một khoảng thời gian nhất định để nhập các phím còn lại vào bộ kích hoạt. Tất cả các phím mà bạn đã thêm vào trình kích hoạt sẽ không thực hiện hành động thông thường cho đến khi hết thời gian chờ. Bạn có thể thay đổi thời gian chờ này trong tab \"Tùy chọn\". + Android không cho phép ứng dụng nhận danh sách các thiết bị Bluetooth được kết nối (không ghép nối). Ứng dụng chỉ có thể phát hiện khi chúng được kết nối và ngắt kết nối. Vì vậy, nếu thiết bị Bluetooth của bạn đã được kết nối với thiết bị của bạn khi dịch vụ trợ năng khởi động, bạn sẽ phải kết nối lại thiết bị đó để ứng dụng biết thiết bị đó đã được kết nối. + Thay đổi vị trí sao lưu hoặc tắt tự động sao lưu? + Các ràng buộc bật/tắt màn hình sẽ chỉ hoạt động nếu bạn đã bật tùy chọn sơ đồ phím \"phát hiện trình kích hoạt khi màn hình tắt\". Tùy chọn này sẽ chỉ hiển thị đối với một số phím (ví dụ: nút âm lượng) và nếu bạn đã root. Xem danh sách các phím được hỗ trợ trên trang Trợ giúp. + Nếu bạn đã chọn bất kỳ khóa màn hình nào khác, chẳng hạn như mã PIN hoặc Mẫu thì bạn không phải lo lắng. Nhưng nếu bạn có khóa màn hình Mật khẩu, bạn sẽ *KHÔNG* có thể mở khóa điện thoại của mình nếu bạn sử dụng Phương thức nhập cơ bản của Key Mapper vì nó không có GUI. Bạn có thể cấp quyền cho Key Mapper WRITE_SECURE_SETTINGS để nó có thể hiển thị thông báo chuyển sang và từ bàn phím. Có hướng dẫn về cách thực hiện việc này nếu bạn nhấn vào dấu chấm hỏi ở cuối màn hình. + Chọn phương thức nhập cho các hành động yêu cầu. Bạn có thể thay đổi điều này sau bằng cách nhấn vào \"Chọn bàn phím để thực hiện thao tác\" ở menu dưới cùng của màn hình chính. + Bạn cần chọn bố cục bàn phím \"Caps Lock to camera\" cho bàn phím của mình nếu không phím Caps Lock vẫn sẽ khóa mũ. Bạn có thể tìm thấy cài đặt này trong cài đặt thiết bị của mình -> Ngôn ngữ và Phương thức nhập -> Bàn phím vật lý -> Nhấn vào bàn phím của bạn -> Thiết lập bố cục bàn phím. Thao tác này sẽ ánh xạ lại phím Caps Lock thành KEYCODE_CAMERA để Key Mapper có thể ánh xạ lại phím đó đúng cách.\n\nSau khi thực hiện xong việc này, bạn phải xóa phím kích hoạt Caps Lock và ghi lại phím Caps Lock. Nó sẽ hiện \"Camera\" thay vì \"Caps Lock\" nếu bạn thực hiện đúng các bước. + Khởi động lại thiết bị của bạn nếu nút \"Kích hoạt ghi\" đang đếm ngược và các nút bạn đang nhấn không hiển thị. Nếu các nút của bạn vẫn không hiển thị sau khi khởi động lại thì Key Mapper không hỗ trợ các nút của bạn. Không có cách khắc phục cho việc này. + Không có thiết bị bên ngoài nào được kết nối. + Cài đặt bàn phím GUI Key Mapper + Điều này rất được khuyến khích! Đây là bàn phím thích hợp mà bạn có thể sử dụng với Key Mapper. Công cụ tích hợp sẵn trong Key Mapper (Phương thức nhập liệu cơ bản) không có bàn phím trên màn hình. Chọn nơi bạn muốn cài đặt nó. + Cài đặt Bàn phím Leanback của Key Mapper + Điều này rất được khuyến khích! Đây là bàn phím thích hợp cho Android TV mà bạn có thể sử dụng với Key Mapper. Công cụ tích hợp sẵn trong Key Mapper (Phương thức nhập liệu cơ bản) không có bàn phím trên màn hình. Chọn nơi bạn muốn cài đặt nó. + Cài đặt bàn phím GUI Key Mapper + Chọn nơi bạn muốn tải xuống từ đó. + Cài đặt Bàn phím Leanback của Key Mapper + Chọn nơi bạn muốn tải xuống từ đó. + Hành động này cần thiết lập thêm + Có 3 cách để bạn có thể thiết lập thiết bị của mình sử dụng tác vụ này. Dưới đây là những ưu điểm và nhược điểm của mỗi. \n\n1. Tải xuống Shizuku (được khuyến nghị). Bạn không cần phải sử dụng bàn phím ảo khác với bàn phím bạn đang sử dụng nhưng sẽ cần một phút thiết lập mỗi khi bạn khởi động lại thiết bị của mình. \n\n2. Tải xuống Bàn phím GUI Key Mapper. Đây là bàn phím ảo mà bạn có thể sử dụng với Key Mapper nhưng bạn sẽ không thể sử dụng bàn phím mà bạn hiện đang sử dụng, chẳng hạn như Gboard. \n\n3. Không làm gì cả và sử dụng bàn phím Key Mapper tích hợp sẵn. Điều này không được khuyến khích vì bạn sẽ không bàn phím ảo khi sử dụng Key Mapper! Không có lợi thế. + Hành động này cần thiết lập thêm + Có 3 cách để bạn có thể thiết lập thiết bị của mình sử dụng tác vụ này. Dưới đây là những ưu điểm và nhược điểm của mỗi. \n\n1. Tải xuống Shizuku (được khuyến nghị). Bạn không cần phải sử dụng bàn phím ảo khác với bàn phím bạn đang sử dụng nhưng sẽ cần một phút thiết lập mỗi khi bạn khởi động lại thiết bị của mình. \n\n2. Tải xuống Bàn phím Leanback Key Mapper. Đây là bàn phím ảo được tối ưu hóa cho Android TV mà bạn có thể sử dụng với Key Mapper nhưng bạn sẽ không thể sử dụng bàn phím mà bạn hiện đang sử dụng, chẳng hạn như Gboard. \n\n3. Không làm gì cả và sử dụng bàn phím Key Mapper tích hợp sẵn. Điều này không được khuyến khích vì bạn sẽ không bàn phím ảo khi sử dụng Key Mapper! Không có lợi thế. + Vô hiệu hóa tối ưu hóa pin + Bạn PHẢI đọc tất cả này nếu không bạn sẽ thất vọng trong tương lai!\n\nNhấn vào \"sửa một phần\" có thể ngăn Android dừng ứng dụng khi ứng dụng đang chạy trong nền.\n\nĐiều này KHÔNG ĐỦ. Giao diện OEM của bạn như MIUI hoặc Samsung Experience có thể có các tính năng tiêu diệt ứng dụng khác, vì vậy bạn PHẢI tắt chúng cho Key Mapper bằng cách làm theo hướng dẫn trực tuyến tại Dontkillmyapp.com. + Gửi phản hồi + Vui lòng đọc hướng dẫn về cách báo cáo sự cố trên trang web. + Bật dịch vụ trợ năng để bạn có thể ghi lại trình kích hoạt. + Khởi động lại dịch vụ trợ năng bằng cách tắtbật để bạn có thể ghi lại trình kích hoạt. + Bật dịch vụ trợ năng để bạn có thể kiểm tra hành động. + Khởi động lại dịch vụ trợ năng bằng cách tắtbật để bạn có thể kiểm tra hành động. + Khởi động lại dịch vụ trợ năng bằng cách tắtbật. + Việc sử dụng trình kích hoạt này có thể gây ra màn hình đen khi bạn mở khóa thiết bị sau khi sử dụng cài đặt ghim màn hình trong cài đặt của thiết bị. Điều này có thể được khắc phục bằng cách khởi động lại. Điều này không xảy ra trên tất cả các thiết bị vì vậy hãy cẩn thận và tắt cài đặt nếu xảy ra! + Đặt lại bản đồ cử chỉ vân tay + Bạn có chắc chắn muốn đặt lại bản đồ cử chỉ vân tay của mình không? + Key Mapper đã bị hỏng + Rất có thể điện thoại của bạn đã tắt Key Mapper khi nó đang cố chạy ở chế độ nền. Đây không là lỗi của nhà phát triển và họ không thể làm gì để khắc phục nên vui lòng đừng để lại đánh giá xấu 😃. + + \n\nTrước đây, bạn đã làm theo hướng dẫn trên Dontkillmyapp.com để ngăn điện thoại của mình tắt Key Mapper chưa? + Đúng + Không + Không tạo được báo cáo lỗi + Yêu cầu sự cho phép của Shizuku + Vì bạn đang sử dụng Shizuku nên bạn nên cấp quyền này vì một số tính năng trong Key Mapper có thể được thực hiện mà bạn không cần phải định cấu hình bất cứ điều gì (Ví dụ: nhập mã khóa mà không cần sử dụng bàn phím Key Mapper). + Sửa lỗi + Cần có sự cho phép + Key Mapper cần có quyền \"thiết bị lân cận\" để có thể lấy danh sách các thiết bị Bluetooth được ghép nối. + Bạn chưa cài đặt ứng dụng tệp nào cho phép bạn tạo tệp cho Key Mapper. Vui lòng cài đặt trình quản lý tập tin. + Bạn chưa cài đặt ứng dụng tệp nào cho phép bạn chọn tệp cho Key Mapper. Vui lòng cài đặt trình quản lý tập tin. + Dịch vụ trợ năng phải được kích hoạt + @string/accessibility_service_explanation + Cấp quyền truy cập Không làm phiền + Bạn sẽ được đưa đến trang cài đặt của thiết bị để quản lý những ứng dụng nào có thể sửa đổi trạng thái Không làm phiền. Tính năng này không xuất hiện trên một số thiết bị vì vậy hãy nhấn vào không hiển thị lại nếu bạn không thấy Key Mapper trong danh sách. + Đúng + Xác nhận + Xong + Chọn tham gia + Hướng dẫn + Hướng dẫn + Hướng dẫn + Kích hoạt tính năng root + Khoản trợ cấp + Tham gia + Thay đổi + Sửa một phần + Được rồi + Bật + Khởi động lại + Không bao giờ hiển thị lại + Mở hướng dẫn trực tuyến + Tắt + Tránh xa + Không + Hủy bỏ + Không hiển thị lại + Hướng dẫn trực tuyến + Cài đặt + Tài liệu + Nhật ký thay đổi + Báo cáo lỗi + Khởi động lại + Đi đến hướng dẫn + Shizuku + Bàn phím GUI của Key Mapper + Bàn phím Leanback của Key Mapper + Không làm gì cả + Sửa chữa + Hủy bỏ + Sửa chữa + + + Bộ chọn bàn phím + Tạm dừng/Tiếp tục ánh xạ + Cảnh báo bàn phím bị ẩn + Chuyển đổi bàn phím Key Mapper + Tính năng mới + Nhấn để thay đổi bàn phím của bạn. + Hộp chọn bàn phím + Đang tải xuống… + Nhấn để mở Key Mapper. + Tạm dừng + Đã tạm dừng + Nhấn để mở Key Mapper. + Bản tóm tắt + Miễn nhiệm + Khởi động lại + Dịch vụ trợ năng bị vô hiệu hóa + Nhấn để bắt đầu dịch vụ trợ năng. + Dịch vụ trợ năng cần khởi động lại! + Dịch vụ trợ năng đã bị hỏng! Điện thoại của bạn có thể đang tích cực tắt nó! Nhấn để khởi động lại dịch vụ trợ năng. + + Dừng dịch vụ + Bàn phím bị ẩn! + Nhấn \'hiển thị bàn phím\' để bắt đầu hiển thị lại bàn phím. + Chuyển đổi bàn phím Key Mapper + Nhấn \'chuyển đổi\' để chuyển sang và từ bàn phím Key Mapper. + Chuyển đổi + Ánh xạ lại cử chỉ vân tay với Key Mapper! + Thiết bị của bạn hỗ trợ ánh xạ lại các thao tác vuốt trên cảm biến vân tay. Nhấn để bắt đầu ánh xạ lại! + Bạn cần thiết lập lại một số cài đặt! + Có vẻ như bạn đang sử dụng cài đặt này để tự động thay đổi bàn phím hoặc hiển thị bộ chọn phương thức nhập khi thiết bị Bluetooth kết nối và ngắt kết nối. Key Mapper hiện cho phép bạn sử dụng bất kỳ thiết bị đầu vào nào chứ không chỉ các thiết bị Bluetooth. Không có cách nào để di chuyển cài đặt cũ theo cách chức năng mới sẽ hoạt động, do đó bạn sẽ phải chọn lại thiết bị trong cài đặt Key Mapper. Nhấn để mở Key Mapper. + + + Độ trễ nhấn giữ mặc định (ms) + Cần nhấn một nút trong bao lâu để được phát hiện là nhấn lâu. Mặc định là 500ms. Có thể được ghi đè trong các tùy chọn của bản đồ chính. + Thời lượng nhấn đúp mặc định (ms) + Một nút phải được nhấn đúp nhanh đến mức nào để được phát hiện là nhấn đúp. Mặc định là 300ms. Có thể được ghi đè trong các tùy chọn của bản đồ chính. + Rung trong bao lâu nếu chế độ rung được bật cho sơ đồ phím. Mặc định là 200ms. Có thể được ghi đè trong các tùy chọn của bản đồ chính. + Thời lượng rung mặc định (ms) + Cần giữ nút kích hoạt trong bao lâu để hành động bắt đầu lặp lại. Mặc định là 400ms. Có thể được ghi đè trong các tùy chọn của bản đồ chính. + Độ trễ mặc định cho đến khi lặp lại (ms) + Độ trễ giữa mỗi lần một hành động được lặp lại. Mặc định là 50ms. Có thể được ghi đè trong các tùy chọn của bản đồ chính. + Độ trễ mặc định giữa các lần lặp lại (ms) + Thời gian cho phép để hoàn thành một trình kích hoạt trình tự. Mặc định là 1000ms. Có thể được ghi đè trong các tùy chọn của bản đồ chính. + Thời gian chờ kích hoạt trình tự mặc định (ms) + Khôi phục + Buộc tất cả các bản đồ chính rung. + Buộc rung + Thông báo bộ chọn bàn phím + Hiển thị thông báo liên tục để cho phép bạn chọn bàn phím. + Tạm dừng/tiếp tục thông báo hành động + Hiển thị thông báo liên tục bắt đầu/tạm dừng hành động của bạn. + Tự động sao lưu cài đặt tới một vị trí được chỉ định + Không có địa điểm nào được chọn. + Chọn thiết bị + Tự động hiển thị hộp chọn bàn phím + Khi thiết bị bạn đã chọn kết nối hoặc ngắt kết nối, bộ chọn bàn phím sẽ tự động hiển thị. Chọn các thiết bị dưới đây. + Tự động thay đổi bàn phím ảo khi một thiết bị (ví dụ: bàn phím) kết nối/ngắt kết nối + Bàn phím Key Mapper được sử dụng lần cuối sẽ được chọn tự động khi thiết bị được chọn được kết nối. Bàn phím thông thường của bạn sẽ được tự động chọn khi thiết bị ngắt kết nối. + Tự động thay đổi bàn phím ảo khi bạn bắt đầu nhập văn bản + Bàn phím không phải Key Mapper được sử dụng lần cuối sẽ tự động được chọn khi bạn cố mở bàn phím. Bàn phím Key Mapper của bạn sẽ được chọn tự động sau khi bạn ngừng sử dụng bàn phím. + Hiển thị thông báo trên màn hình khi tự động thay đổi bàn phím + Key Mapper có quyền root + Bật tính năng này nếu bạn muốn sử dụng các tính năng/hành động chỉ hoạt động trên các thiết bị đã root. Key Mapper phải có quyền root từ ứng dụng quản lý quyền truy cập root của bạn (ví dụ: Magisk, SuperSU) để các tính năng này hoạt động. Chỉ bật tính năng này nếu bạn biết thiết bị của mình đã được root và bạn đã cấp quyền root cho Key Mapper. + Chủ đề tối + Cài đặt thông báo + Chuyển đổi giữa bàn phím Key Mapper và bàn phím mặc định của bạn khi bạn chạm vào thông báo. + Chuyển đổi thông báo bàn phím Key Mapper + Tự động thay đổi bàn phím khi chuyển đổi sơ đồ phím + Tự động chọn bàn phím Key Mapper khi bạn tiếp tục lại sơ đồ chính và chọn bàn phím mặc định khi tạm dừng chúng. + Ẩn cảnh báo màn hình chính + Ẩn cảnh báo ở đầu màn hình chính. + Hiển thị 5 ký tự đầu tiên của id thiết bị cho trình kích hoạt cụ thể của thiết bị + Điều này rất hữu ích để phân biệt giữa các thiết bị có cùng tên. + Sửa bàn phím được đặt thành tiếng Anh Mỹ + Điều này khắc phục những bàn phím không có bố cục bàn phím chính xác khi bật dịch vụ trợ năng. Nhấn để đọc thêm và định cấu hình. + Sửa bàn phím được đặt thành tiếng Anh Mỹ + Có một lỗi trong Android 11 là việc bật dịch vụ trợ năng khiến Android nghĩ rằng tất cả các thiết bị bên ngoài đều là cùng một thiết bị ảo bên trong. Vì nó không thể xác định chính xác các thiết bị này nên nó không biết nên sử dụng bố cục bàn phím nào với chúng nên nó mặc định là tiếng Anh Mỹ ngay cả khi đó là bàn phím tiếng Đức chẳng hạn. Bạn có thể sử dụng Key Mapper để khắc phục sự cố này bằng cách thực hiện theo các bước bên dưới. + 4. Chọn thiết bị + 1. Cài đặt Bàn phím GUI Key Mapper (tùy chọn) + 1. Cài đặt Bàn phím Leanback của Key Mapper (tùy chọn) + 2. Kích hoạt Bàn phím GUI Key Mapper hoặc Phương thức nhập cơ bản của Key Mapper + 2. Kích hoạt Bàn phím Leanback của Key Mapper hoặc Phương thức nhập cơ bản của Key Mapper + 3. Sử dụng bàn phím bạn vừa kích hoạt + (Được khuyến nghị) Đọc hướng dẫn sử dụng cho cài đặt này. + Cho phép ghi nhật ký bổ sung + Xem và chia sẻ nhật ký + Báo cáo vấn đề + Xóa tập tin âm thanh + Xóa các tệp âm thanh có thể được sử dụng cho tác vụ Âm thanh. + Cấp quyền + Đã cấp quyền + 1. Shizuku chưa được cài đặt! Nhấn để tải xuống ứng dụng Shizuku. + 1. Shizuku đã được cài đặt. + 2. Shizuku chưa bắt đầu! Nhấn để mở ứng dụng Shizuku rồi đọc hướng dẫn giải thích cách khởi động ứng dụng. + 2. Shizuku bắt đầu. + 3. Key Mapper không được phép sử dụng Shizuku. Nhấn để cấp quyền này. + 3. Key Mapper sẽ tự động sử dụng Shizuku. Nhấn để đọc tính năng Key Mapper nào sử dụng Shizuku. + Tùy chọn ánh xạ mặc định + Thay đổi các tùy chọn mặc định cho ánh xạ của bạn. + + + Tự động hiển thị hộp chọn bàn phím + Nhấn để xem cài đặt cho phép bạn tự động hiển thị bộ chọn bàn phím. + Thông báo + Mặc định + Cài đặt root + Các tùy chọn này sẽ chỉ hoạt động trên các thiết bị root! Nếu bạn không biết root là gì hoặc thiết bị của bạn đã được root hay chưa, vui lòng đừng để lại đánh giá kém nếu chúng không hoạt động. :) + Yêu cầu quyền WRITE_SECURE_SETTINGS + Các tùy chọn này chỉ được bật nếu Key Mapper có quyền WRITE_SECURE_SETTINGS. Nhấp vào nút bên dưới để tìm hiểu cách cấp quyền. + Hỗ trợ Shizuku + Shizuku là ứng dụng cho phép Key Mapper thực hiện những việc mà chỉ ứng dụng hệ thống mới làm được. Bạn không cần sử dụng bàn phím Key Mapper chẳng hạn. Nhấn để tìm hiểu cách thiết lập tính năng này. + Hãy làm theo các bước sau để thiết lập Shizuku. + Tự động thay đổi bàn phím + Đây là những cài đặt thực sự hữu ích và bạn nên kiểm tra chúng! + Ghi nhật ký + Điều này có thể làm tăng độ trễ cho bản đồ chính của bạn, vì vậy chỉ bật tính năng này nếu bạn đang cố gắng gỡ lỗi ứng dụng hoặc được nhà phát triển yêu cầu. + + + Nhật ký thay đổi + Giấy phép + Chính sách bảo mật + Tín dụng + Mã nguồn + Hồ sơ GitHub của nhà phát triển + Đánh giá và bình luận + Chủ đề XDA + Phiên bản + Dịch + Máy chủ Discord + Kênh YouTube (Hướng dẫn) + + + Hiển thị hộp thoại âm lượng + Nhấn giữ + Rung + Hiển thị thông báo trên màn hình + Rung khi nhấn phím lần đầu và rung lại khi nhấn lâu. + Phát hiện kích hoạt khi màn hình tắt + Lặp lại + %dx + sau %dms + mỗi %dms + cho đến khi vuốt lại + cho đến khi nhấn lại + cho đến khi được thả ra + Lặp lại + Lặp lại cho đến khi được thả ra + Lặp lại cho đến khi nhấn lại + Giữ và giữ + Giữ cho đến khi nhấn lại + Không ánh xạ lại + Giữ cho đến khi vuốt lại + Cho phép các ứng dụng khác kích hoạt bản đồ phím này + + + Dịch vụ trợ năng đã được bật :) + Tất cả đều tốt. Key Mapper hiện có thể phát hiện các lần nhấn nút của bạn. + Khởi động lại dịch vụ trợ năng + Các dịch vụ trợ năng đã được bật nhưng điện thoại của bạn đã tắt hoặc bị hỏng. Khởi động lại nó. + Khởi động lại + Kích hoạt dịch vụ trợ năng + @string/accessibility_service_explanation + Dịch vụ trợ năng bị vô hiệu hóa + Bạn chỉ có thể ghi lại trình kích hoạt nếu dịch vụ trợ năng được bật. + Lưu ý từ nhà phát triển + Không có gì đảm bảo rằng mọi hành động sẽ hoạt động trên thiết bị của bạn và mọi nút đều có thể được phát hiện. Điều này là do Android có nhiều phiên bản khác nhau và các OEM có thể vô tình hoặc cố ý phá vỡ các tính năng. Nếu có điều gì đó không ổn, vui lòng thông báo cho nhà phát triển và không đánh giá ứng dụng kém vì sự cố thường nằm ngoài tầm kiểm soát của nhà phát triển. =) + Key Mapper có thể ngừng hoạt động ngẫu nhiên! + PHÊ BÌNH!!! Nhấn \"tắt\" để hy vọng ngăn Android dừng ứng dụng khi ứng dụng này ở chế độ nền. Giao diện OEM của bạn như MIUI hoặc Samsung Experience có thể có các tính năng \"tiết kiệm pin\" khác, vì vậy bạn PHẢI tắt chúng cho Key Mapper bằng cách làm theo hướng dẫn tại Dontkillmyapp.com + Tối ưu hóa pin Android gốc đã tắt. Điều này không đủ tốt cho hầu hết các thiết bị, vì vậy hãy truy cập Dontkillmyapp.com để xem hướng dẫn cách tắt nhiều tính năng diệt ứng dụng hơn nữa trên thiết bị của bạn. + Tắt + Truy cập Dontkillmyapp.com + Ánh xạ lại các nút âm lượng? + Bạn có thể ánh xạ lại các nút âm lượng + Key Mapper cần có quyền truy cập Không làm phiền nếu bạn muốn các hành động thay đổi âm lượng và ánh xạ lại các nút âm lượng hoạt động. + Tất cả đều tốt! :) + Đóng góp + "Ứng dụng này là nguồn mở! Bạn có thể bắt đầu đóng góp bằng cách truy cập kho lưu trữ sds100/KeyMapper trên GitHub và bằng cách tham gia máy chủ Discord. Ngay cả khi bạn không thể viết mã, bạn vẫn có thể đóng góp bằng cách giúp đỡ người khác và thử nghiệm các tính năng mới nhất." + Nhấn vào dấu 3 chấm để thay đổi hành vi lặp lại và hơn thế nữa. Bạn có thể kiểm tra một hành động và sửa lỗi hành động bằng cách nhấn vào hành động đó. + Ánh xạ lại cử chỉ đọc dấu vân tay + Bạn có thể ánh xạ lại cử chỉ đọc dấu vân tay! :) + Bạn không thể ánh xạ lại cử chỉ đọc dấu vân tay! + Bạn cần bật dịch vụ trợ năng để Key Mapper có thể kiểm tra xem thiết bị của bạn có thể phát hiện cử chỉ vân tay hay không. + Thiết bị của bạn có thể phát hiện cử chỉ vân tay! Có một tab ở đầu màn hình chính để ánh xạ lại cử chỉ vân tay. + Thiết bị của bạn không cho phép ứng dụng của bên thứ 3 phát hiện cử chỉ vân tay! Nhà phát triển không thể làm gì về điều này. Một số thiết bị có cài đặt vuốt xuống trên đầu đọc dấu vân tay để mở ngăn thông báo và không cho phép ứng dụng bên thứ 3 phát hiện cử chỉ vân tay. + Cho phép + Bạn cần thiết lập lại một số cài đặt + Có vẻ như bạn đã sử dụng cài đặt để tự động thay đổi bàn phím hoặc hiển thị trình chọn phương thức nhập khi thiết bị Bluetooth kết nối và ngắt kết nối. Key Mapper hiện cho phép bạn sử dụng bất kỳ thiết bị nhập nào chứ không chỉ thiết bị Bluetooth. Không có cách nào để di chuyển cài đặt cũ theo cách mà chức năng mới sẽ hoạt động nên bạn sẽ phải chọn lại thiết bị. + Cấp phép cho Shizuku + Có vẻ như bạn đã cài đặt Shizuku. Bạn nên cấp quyền cho Key Mapper sử dụng Shizuku để Key Mapper có thể làm nhiều việc hơn mà không cần người dùng nhập liệu. Ví dụ như nhấn nút nhập liệu mà không cần bạn phải sử dụng \'bàn phím Key Mapper\'. Nhấn \'thêm thông tin\' để đọc tất cả các lợi ích. Nhấn \'cấp\' để cấp quyền. + Shizuku đã được phép! + Bạn đã cấp quyền thành công cho Key Mapper Shizuku. + Thông tin thêm + Khoản trợ cấp + Shizuku chưa bắt đầu + Shizuku phải được khởi động trước khi bạn cấp quyền cho Key Mapper sử dụng. Nhấn vào \'Khởi chạy Shizuku\' để mở ứng dụng Shizuku để bạn có thể khởi động ứng dụng. + Cài đặt Shizuku + Cấp quyền thông báo + Một số tính năng của Key Mapper yêu cầu quyền đăng thông báo, ví dụ: có thông báo tạm dừng/tiếp tục các bản đồ chính của bạn. Nhấn \'cấp\' để cấp quyền. + Thông báo có thể được hiển thị! + Bạn đã cấp thành công quyền Key Mapper để hiển thị thông báo. + Khoản trợ cấp + + + Khả năng tiếp cận + Báo thức + DTMF + Âm nhạc + Thông báo + Nhẫn + Hệ thống + Cuộc gọi thoại + Nhẫn + Rung + Im lặng + Đằng trước + Back + Báo động + Sự ưu tiên + Không có gì + + + Tạm dừng ánh xạ + Tiếp tục ánh xạ + Dịch vụ bị vô hiệu hóa + Dịch vụ trợ năng Key Mapper bị vô hiệu hóa + Dịch vụ bàn phím Key Mapper bị tắt + Chuyển đổi bàn phím Key Mapper + + + Ctrl + Ctrl left + Ctrl right + Alt + Alt trái + Alt phải + Shift + Shift trái + Shift phải + Meta + Meta trái + Meta phải + Sym + Func + Khóa mũ + Khóa số + Khóa cuộn + + + Bạn phải gõ một phím! + Bạn phải có ít nhất một trình kích hoạt + Bạn phải chọn một hành động! + Phím tắt phải có tiêu đề! + Bạn phải sử dụng một trong các bàn phím Key Mapper để tác vụ này hoạt động! + Không thể tìm thấy lối tắt. Ứng dụng đã được cài đặt hay kích hoạt chưa? + Ứng dụng có tên gói %s chưa được cài đặt! + Ứng dụng chưa được cài đặt! + Ứng dụng đã bị vô hiệu hóa! + Ứng dụng %s đã bị tắt! + Bạn cần cấp quyền cho Key Mapper để sửa đổi cài đặt hệ thống. + Điều này đòi hỏi sự cho phép root! + Hành động này cần có sự cho phép của máy ảnh! + Yêu cầu Android %s hoặc mới hơn + Yêu cầu Android %s trở lên + Thiết bị của bạn không có camera. + Thiết bị của bạn không hỗ trợ NFC. + Thiết bị của bạn không có đầu đọc dấu vân tay. + Thiết bị của bạn không hỗ trợ WiFi. + Thiết bị của bạn không hỗ trợ Bluetooth. + Thiết bị của bạn không hỗ trợ thực thi chính sách thiết bị. + Thiết bị của bạn không có đèn flash của máy ảnh. + Thiết bị của bạn không có bất kỳ tính năng điện thoại nào. + Không tìm thấy cờ \"%s\"! + Không thể tìm thấy trang cài đặt bàn phím! + Key Mapper cần phải là quản trị viên thiết bị! + Đang ở chế độ Không làm phiền! + Key Mapper không có quyền sử dụng phím tắt đó + Ứng dụng cần có quyền thay đổi trạng thái Không làm phiền! + Hành động này cần có quyền đọc trạng thái điện thoại! + Không thể tìm thấy trang cấp phép WRITE_SETTINGS! + Lỗi mở lối tắt ứng dụng này + Không có ứng dụng nào được cài đặt có thể gửi email! + Đã cấp quyền thay đổi chế độ Không làm phiền! + Không thể tìm thấy cài đặt quyền truy cập Không làm phiền! + Key Mapper cần có quyền WRITE_SECURE_SETTINGS. + Không thể tìm thấy ứng dụng nào để mở URL đó + Không thực hiện được lệnh \"getevent\". Bạn có chắc là bạn đã root chưa? + Không có ứng dụng nào có thể bắt đầu cuộc gọi điện thoại này + Máy ảnh đang được sử dụng! + Máy ảnh đã bị ngắt kết nối! + Máy ảnh bị vô hiệu hóa! + Lỗi máy ảnh! + Số lượng camera tối đa được sử dụng! + Không thể truy cập vào máy ảnh! + Không có đèn flash phía trước + Không có đèn flash phía sau + Dịch vụ trợ năng phải được kích hoạt để ứng dụng này hoạt động! + Dịch vụ trợ năng cần được kích hoạt! + Dịch vụ trợ năng đã được kích hoạt! + Dịch vụ trợ năng cần được kích hoạt! + Dịch vụ trợ năng cần được khởi động lại! + Trình khởi chạy của bạn không hỗ trợ phím tắt. + Một số tính năng cần có quyền WRITE_SECURE_SETTINGS. + Đã cấp quyền WRITE_SECURE_SETTINGS. + Phương thức nhập đã chọn của bạn cần được bật để “Sự kiện chính”, “Khóa”, “Văn bản” và một số hành động khác hoạt động. + Cần phải bật bàn phím Key Mapper! + Bàn phím Key Mapper đã được bật! + Bàn phím Key Mapper phải được bật và chọn để một số hành động của bạn hoạt động! + Không thể tìm thấy phương thức nhập %s + Không thể hiển thị bộ chọn phương thức nhập! + Không tìm thấy nút trợ năng! + Không thực hiện được hành động chung %s! + Bản đồ chính của bạn sẽ không hoạt động! Một số điều cần sửa chữa! + Bạn không thể di chuyển đến cuối văn bản trong trường này! + Không tìm thấy cài đặt tối ưu hóa pin! Nếu nó tồn tại, hãy mở nó bằng tay. + Không tìm thấy phần bổ sung (%s)! + Bạn không thể có những ràng buộc trùng lặp! + Cử chỉ này đã có hạn chế này! + Không thể trống rỗng! + Điều này không được hỗ trợ. :( + Không tìm thấy thiết bị! + Không thể chọn tập tin + Tệp JSON trống! + Quyền truy cập tập tin bị từ chối! %S + Lỗi IO không xác định! + Đã hủy! + Không thể sao lưu vào tập tin. Nó có bị xóa không? + Số không hợp lệ! + Ít nhất phải là %s! + Tối đa phải là %s! + Tối ưu hóa pin được bật! Hãy tắt tính năng này đi vì điều này có thể khiến Key Mapper ngừng hoạt động một cách ngẫu nhiên. + Điều này đòi hỏi sự cho phép root! + Không thể tìm thấy cài đặt truy cập thông báo! + Quyền truy cập thông báo bị từ chối! + Không hợp lệ! + Bị từ chối quyền bắt đầu cuộc gọi điện thoại! + Bạn sẽ cần cập nhật Key Mapper lên phiên bản mới nhất để sử dụng bản sao lưu này. + Tệp JSON bị hỏng! + Không có trợ lý giọng nói được cài đặt! + Không đủ quyền + Bạn chỉ cài đặt bàn phím Key Mapper! + Không có ứng dụng chơi phương tiện truyền thông! + Không tìm thấy tập tin nguồn! %S + Không tìm thấy tệp mục tiêu! %S + Không nhập được cử chỉ! + Không thể sửa đổi cài đặt hệ thống %s! + Bạn cần kích hoạt %s! + Không thể thay đổi ime! + Thiết bị của bạn không có ứng dụng camera! + Thiết bị của bạn không có trợ lý! + Thiết bị của bạn không có ứng dụng cài đặt! + Không ứng dụng nào có thể mở url này! + Không tạo được tập tin! + Không phải là một thư mục! %S + Không phải là một tập tin! %S + Không tìm thấy thư mục! %S + Không thể tìm thấy tập tin âm thanh! + Quyền lưu trữ bị từ chối! + Nguồn và đích không thể giống nhau! + Không còn khoảng trống ở mục tiêu! %S + Sự cho phép của Shizuku bị từ chối! + Shizuku chưa bắt đầu! + Tập tin này không có tên! + Bạn phải cấp quyền Key Mapper để xem các thiết bị Bluetooth đã ghép nối của mình. + Bị từ chối quyền đọc vị trí ! + Bị từ chối quyền trả lời và kết thúc cuộc gọi điện thoại! + Đã từ chối quyền xem các thiết bị Bluetooth được ghép nối! + Đã từ chối quyền hiển thị thông báo! + Phải từ 2 trở lên! + Phải bằng %d hoặc ít hơn! + Phải lớn hơn 0! + Phải lớn hơn 0! + Phải lớn hơn 0! + Phải lớn hơn 0! + Phải bằng %d hoặc ít hơn! + + + Chuyển đổi Wi-Fi + Bật Wi-Fi + Tắt Wi-Fi + Chuyển đổi Bluetooth + Bật Bluetooth + Tắt Bluetooth + Tăng âm lượng + Giảm âm lượng + Tắt âm lượng + Chuyển đổi tắt tiếng + Bật âm lượng + Hiển thị hộp thoại âm lượng + Tăng luồng + Tăng luồng %s + Giảm luồng + Giảm luồng %s + Chuyển qua các chế độ chuông (Rung, Rung, Im lặng) + Chuyển qua các chế độ chuông (Ring, Vibrate) + Thay đổi chế độ chuông + Thay đổi sang chế độ %s + Chuyển đổi chế độ Không làm phiền + Chỉ chuyển đổi chế độ DND %s + Bật chế độ Không làm phiền + Chỉ bật chế độ DND %s + Tắt chế độ Không làm phiền + Bật tự động xoay + Vô hiệu hóa tự động xoay + Chuyển đổi tự động xoay + Chế độ chân dung + Chế độ phong cảnh + Chuyển hướng + Xoay vòng qua các vòng quay + Xoay vòng qua %s vòng quay + Chuyển đổi dữ liệu di động + Kích hoạt dữ liệu di động + Tắt dữ liệu di động + Chuyển đổi độ sáng tự động + Tắt độ sáng tự động + Bật độ sáng tự động + Tăng độ sáng màn hình + Giảm độ sáng màn hình + Mở rộng ngăn thông báo + Chuyển đổi ngăn thông báo + Mở rộng cài đặt nhanh + Chuyển đổi ngăn cài đặt nhanh + Thu gọn thanh trạng thái + Tạm dừng phát lại phương tiện + Tạm dừng phát lại phương tiện cho một ứng dụng + Tạm dừng phương tiện trong %s + Tiếp tục phát lại phương tiện + Tiếp tục phát lại phương tiện cho một ứng dụng + Tiếp tục phương tiện cho %s + Phát/Tạm dừng phát lại phương tiện + Phát/Tạm dừng phát lại phương tiện cho một ứng dụng + Phát/Tạm dừng phương tiện trong %s + Bài hát tiếp theo + Bản nhạc tiếp theo cho một ứng dụng + Bài hát tiếp theo cho %s + Bản nhạc trước + Bản nhạc trước của một ứng dụng + Bài hát trước đó của %s + Chuyển tiếp nhanh + Chuyển tiếp nhanh cho một ứng dụng + Chuyển tiếp nhanh trong %s + Không phải tất cả các ứng dụng đa phương tiện đều hỗ trợ chuyển tiếp nhanh. Ví dụ: Google Play Âm nhạc. + Tua lại + Tua lại cho một ứng dụng + Tua lại trong %s + Không phải tất cả các ứng dụng media đều hỗ trợ tua lại. Ví dụ: Google Play Âm nhạc. + Quay lại + Go home + Mở gần đây + Mở trình đơn + Chuyển đổi màn hình chia nhỏ + Chuyển đến ứng dụng cuối cùng. (Nhấn đúp vào phần gần đây) + Bật tắt đèn pin + Bật đèn pin + Tắt đèn pin + Chuyển đổi đèn pin %s + Bật đèn pin %s + Tắt đèn pin %s + Bật NFC + Tắt NFC + Chuyển đổi NFC + Ảnh chụp màn hình + Khởi chạy trợ lý giọng nói + Khởi chạy trợ lý thiết bị + Mở máy ảnh + Khóa thiết bị + Thiết bị khóa an toàn + Bạn sẽ chỉ có thể đăng nhập lại bằng mã PIN của mình. Máy quét dấu vân tay và mở khóa bằng khuôn mặt sẽ bị tắt. Đây là cách đáng tin cậy duy nhất mà tôi tìm thấy để khóa các thiết bị chưa root trước Android Pie 9.0. + Thiết bị ngủ/thức + Bạn phải bật tùy chọn phát hiện trigger khi màn hình tắt! + Không làm gì cả + Di chuyển con trỏ đến cuối + Hành động này có thể không hoạt động như dự định trong một số ứng dụng. + Chuyển đổi bàn phím + Hành động này sẽ chỉ hoạt động nếu bạn đã nhấn vào trường nhập liệu nơi bàn phím được cho là sẽ hiển thị. + Hiển thị bàn phím + Ẩn bàn phím + Hiển thị hộp chọn bàn phím + Chuyển đổi bàn phím + Chuyển sang %s + Cắt + Sao chép + Dán + Chọn từ tại con trỏ + Mở cài đặt + Hiển thị menu nguồn + Chuyển đổi chế độ trên máy bay + Bật chế độ trên máy bay + Tắt chế độ Máy bay + Khởi chạy ứng dụng + Khởi chạy phím tắt ứng dụng + Nhập mã khóa + Sự kiện phím đầu vào + Nhấn vào màn hình + Vuốt màn hình + Chụm màn hình + Nhập văn bản + Mở URL + Gửi ý định + Bắt đầu cuộc gọi điện thoại + Trả lời cuộc gọi điện thoại + Kết thúc cuộc gọi điện thoại + Phát âm thanh + Loại bỏ thông báo gần đây nhất + Loại bỏ tất cả thông báo + + + Điều hướng + Âm lượng + Phương tiện truyền thông + Bàn phím + Ứng dụng + Đầu vào + Máy ảnh & Âm thanh + Kết nối + Nội dung + Giao diện + Điện thoại + Trưng bày + Thông báo + + + Boolean + Mảng Boolean + Số nguyên + Mảng số nguyên + Chuỗi + Mảng chuỗi + Dài + Mảng dài + Byte + Mảng byte + Double + Mảng đôi + Char + Mảng char + Nổi + Mảng nổi + Ngắn + Mảng ngắn + Chỉ có thể là \"đúng\" hoặc \"sai\" + Danh sách \"đúng\" và \"sai\" được phân tách bằng dấu phẩy. Ví dụ: đúng, sai, đúng + Một số nguyên hợp lệ trong ngôn ngữ lập trình Java. + Danh sách các số nguyên hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: 100.399 + Một danh sách được phân tách bằng dấu phẩy. Ví dụ: loại 1, loại 2 + Bất kỳ văn bản nào. + Danh sách các chuỗi được phân tách bằng dấu phẩy. Ví dụ: chuỗi1, chuỗi2 + Một Long hợp lệ trong ngôn ngữ lập trình Java. + Danh sách các Độ dài hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: 102302234234234,399083423234429 + Một Byte hợp lệ trong ngôn ngữ lập trình Java. + Danh sách các Byte hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: 123,3 + Một Double hợp lệ trong ngôn ngữ lập trình Java. + Danh sách các Nhân đôi hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: 1.0,3.234 + Một Char hợp lệ trong ngôn ngữ lập trình Java. Ví dụ: \'a\' hoặc \'b\' + Danh sách các ký tự hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: a,b,c + Float hợp lệ trong ngôn ngữ lập trình Java. Ví dụ: 3,145 + Danh sách các Float hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: 1241.123 + Một đoạn ngắn hợp lệ bằng ngôn ngữ lập trình Java. Ví dụ: 2342 + Danh sách các video ngắn hợp lệ được phân tách bằng dấu phẩy bằng ngôn ngữ lập trình Java. Ví dụ: 3242,12354 + Các cờ cho một Ý định được lưu dưới dạng cờ bit. Những cờ này thay đổi cách xử lý Ý định. Nếu mục này trống đối với Ý định hoạt động thì Trình ánh xạ khóa sẽ sử dụng FLAG_ACTIVITY_NEW_TASK theo mặc định. Để biết thêm thông tin, hãy nhấn vào \'tài liệu\' để xem tài liệu dành cho nhà phát triển Android. + + + Hướng dẫn bắt đầu nhanh + Hãy xem Hướng dẫn bắt đầu nhanh nếu bạn gặp khó khăn. + + + GitHub + Website + Bản dịch + Phiên bản %s + Tỷ lệ + Nhật ký thay đổi + Discord + Những thứ nhàm chán + Giấy phép + Giấy phép nguồn mở cho ứng dụng này. + Chính sách bảo mật + Chúng tôi không thu thập bất kỳ thông tin cá nhân nào nhưng đây là chính sách bảo mật nói lên điều này. + Đội ngũ của chúng tôi + Nhà phát triển + Người điều hành/hỗ trợ cộng đồng + Người điều hành/hỗ trợ cộng đồng + Phiên dịch viên (tiếng Ba Lan) + Người phiên dịch (tiếng Séc) + Phiên dịch viên (tiếng Tây Ban Nha) + + diff --git a/fastlane/metadata/android/uk_UA/full_description.txt b/fastlane/metadata/android/uk_UA/full_description.txt new file mode 100644 index 0000000000..515ccecd74 --- /dev/null +++ b/fastlane/metadata/android/uk_UA/full_description.txt @@ -0,0 +1,38 @@ +What can be remapped? + + * Fingerprint gestures on supported devices. + * Volume buttons. + * Navigation buttons. + * Bluetooth/wired keyboards. + * Buttons on other connected devices should also work. + +ONLY HARDWARE buttons can be remapped. +There is NO GUARANTEE any of these buttons will work and this app is NOT designed to control games. Your device's OEM/vendor can prevent them from being remapped. + +You can combine multiple keys from a specific device or any device to form a "trigger". Each trigger can have multiple actions. The keys can be set to be pressed at the same time or one after another in a sequence. Keys can be remapped when they are short pressed, long pressed or double pressed. A keymap can have a set of "constraints" so it only has an effect in certain situations. + +What can’t be remapped? + * Power button + * Bixby button + * Mouse buttons + * Dpad, thumb sticks or triggers on game controllers + +Your key maps don't work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. + +What can I remap my keys to do? +Some actions will only work on rooted devices and specific Android versions. + +There are too many features to list here so check out the full list here: https://docs.keymapper.club/user-guide/actions + +Permissions +You don't have to grant all the permissions for the app to work. The app will tell you if a permission needs to be granted for a feature to work. + + * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block keyevents. + * Device Admin: To turn the screen off when using the action to turn off the screen. + * Modify System Settings: To change the brightness and rotation settings. + * Camera: To control the flashlight. + + On some devices, enabling the accessibility service will disable "enhanced data encryption". + +Discord: www.keymapper.club +Website: docs.keymapper.club \ No newline at end of file diff --git a/fastlane/metadata/android/uk_UA/short_description.txt b/fastlane/metadata/android/uk_UA/short_description.txt new file mode 100644 index 0000000000..4de8bd060b --- /dev/null +++ b/fastlane/metadata/android/uk_UA/short_description.txt @@ -0,0 +1 @@ +Unleash your keys! Open source! \ No newline at end of file diff --git a/fastlane/metadata/android/uk_UA/title.txt b/fastlane/metadata/android/uk_UA/title.txt new file mode 100644 index 0000000000..19f819ebd7 --- /dev/null +++ b/fastlane/metadata/android/uk_UA/title.txt @@ -0,0 +1 @@ +Key Mapper \ No newline at end of file diff --git a/fastlane/metadata/android/vi_VN/full_description.txt b/fastlane/metadata/android/vi_VN/full_description.txt new file mode 100644 index 0000000000..459f5b0cb3 --- /dev/null +++ b/fastlane/metadata/android/vi_VN/full_description.txt @@ -0,0 +1,38 @@ +Những gì có thể được ánh xạ lại? + + * Cử chỉ vân tay trên các thiết bị được hỗ trợ. + * Nút âm lượng. + * Các nút điều hướng. + * Bàn phím Bluetooth/có dây. + * Các nút trên các thiết bị được kết nối khác cũng sẽ hoạt động. + +CHỈ có thể ánh xạ lại các nút PHẦN CỨNG. +KHÔNG CÓ ĐẢM BẢO bất kỳ nút nào trong số này sẽ hoạt động và ứng dụng này KHÔNG được thiết kế để điều khiển trò chơi. OEM/nhà cung cấp thiết bị của bạn có thể ngăn không cho chúng được ánh xạ lại. + +Bạn có thể kết hợp nhiều phím từ một thiết bị cụ thể hoặc bất kỳ thiết bị nào để tạo thành một "trình kích hoạt". Mỗi trigger có thể có nhiều hành động. Các phím có thể được cài đặt để nhấn cùng lúc hoặc lần lượt theo trình tự. Các phím có thể được ánh xạ lại khi chúng được nhấn nhanh, nhấn lâu hoặc nhấn đúp. Sơ đồ bàn phím có thể có một tập hợp các "ràng buộc" nên nó chỉ có tác dụng trong một số trường hợp nhất định. + +Những gì không thể được ánh xạ lại? + * Nút nguồn + * Nút Bixby + * Nút chuột + * Dpad, gậy ngón tay cái hoặc trình kích hoạt trên bộ điều khiển trò chơi + +Bản đồ chính của bạn không hoạt động nếu màn hình TẮT. Đây là một hạn chế trong Android. Dev không thể làm gì được. + +Tôi có thể sắp xếp lại chìa khóa của mình để làm gì? +Một số hành động sẽ chỉ hoạt động trên các thiết bị đã root và các phiên bản Android cụ thể. + +Có quá nhiều tính năng để liệt kê ở đây, vì vậy hãy xem danh sách đầy đủ tại đây: https://docs.keymapper.club/user-guide/actions + +Quyền +Bạn không cần phải cấp tất cả các quyền để ứng dụng hoạt động. Ứng dụng sẽ cho bạn biết liệu có cần cấp quyền để một tính năng hoạt động hay không. + + * Dịch vụ trợ năng: Yêu cầu cơ bản để ánh xạ lại hoạt động. Nó cần thiết để ứng dụng có thể nghe và chặn các sự kiện quan trọng. + * Quản trị thiết bị: Để tắt màn hình khi sử dụng thao tác tắt màn hình. + * Sửa đổi cài đặt hệ thống: Để thay đổi cài đặt độ sáng và xoay. + * Camera: Để điều khiển đèn pin. + + Trên một số thiết bị, việc bật dịch vụ trợ năng sẽ vô hiệu hóa "mã hóa dữ liệu nâng cao". + +Discord: www.keymapper.club +Website: docs.keymapper.club \ No newline at end of file diff --git a/fastlane/metadata/android/vi_VN/short_description.txt b/fastlane/metadata/android/vi_VN/short_description.txt new file mode 100644 index 0000000000..725018974c --- /dev/null +++ b/fastlane/metadata/android/vi_VN/short_description.txt @@ -0,0 +1 @@ +Giải phóng chìa khóa của bạn! Nguồn mở! \ No newline at end of file diff --git a/fastlane/metadata/android/vi_VN/title.txt b/fastlane/metadata/android/vi_VN/title.txt new file mode 100644 index 0000000000..19f819ebd7 --- /dev/null +++ b/fastlane/metadata/android/vi_VN/title.txt @@ -0,0 +1 @@ +Key Mapper \ No newline at end of file From 5be22b90c20093af1c12b601de7b8cbe8351cd17 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 26 Sep 2024 21:27:12 +0200 Subject: [PATCH 018/118] #1297 change trigger screen placeholder text to mention advanced triggers --- app/src/main/res/layout/fragment_trigger.xml | 8 ++++---- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/res/layout/fragment_trigger.xml b/app/src/main/res/layout/fragment_trigger.xml index bcb30aa085..49e80c3161 100644 --- a/app/src/main/res/layout/fragment_trigger.xml +++ b/app/src/main/res/layout/fragment_trigger.xml @@ -67,10 +67,10 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" - android:layout_marginStart="16dp" - android:layout_marginTop="16dp" - android:layout_marginEnd="16dp" - android:layout_marginBottom="16dp" + android:layout_marginStart="32dp" + android:layout_marginTop="32dp" + android:layout_marginEnd="32dp" + android:layout_marginBottom="32dp" android:text="@string/triggers_recyclerview_placeholder" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa2cf48f14..782a8a7719 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,7 +10,7 @@ Open ¯\\_(ツ)_/¯ ¯\\_(ツ)_/¯\n\nNothing here! - Record a trigger! + The first step is to add some buttons that will trigger the key map.\n\nFirst tap ‘Record trigger’ and then press the buttons that you want to remap. They will appear here.\n\nAlternatively, you can trigger a key map using an ‘advanced trigger’. Add an action! ¯\\_(ツ)_/¯\n\nCreate a key map! ¯\\_(ツ)_/¯\n\nYou haven\'t chosen any actions for this shortcut! From 3c5b7e180a0da4ca38b9ab4e3c42538bb3ea3048 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 26 Sep 2024 21:33:25 +0200 Subject: [PATCH 019/118] #1297 only show the trigger mode buttons if there are keys recorded --- .../keymaps/ConfigKeyMapTriggerViewModel.kt | 13 +++++++++++++ app/src/main/res/layout/fragment_trigger.xml | 2 ++ 2 files changed, 15 insertions(+) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt index 194adcbde1..3712417133 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt @@ -136,6 +136,19 @@ class ConfigKeyMapTriggerViewModel( } }.flowOn(Dispatchers.Default).stateIn(coroutineScope, SharingStarted.Eagerly, false) + /** + * After adding the feature for advanced triggers, only show the buttons + * for the trigger mode if there are keys recorded. + */ + val triggerModeRadioButtonsVisible: StateFlow = config.mapping + .map { state -> + when (state) { + is State.Data -> state.data.trigger.keys.isNotEmpty() + State.Loading -> false + } + } + .stateIn(coroutineScope, SharingStarted.Eagerly, false) + val doublePressButtonVisible: StateFlow = config.mapping.map { state -> when (state) { is State.Data -> state.data.trigger.keys.size == 1 diff --git a/app/src/main/res/layout/fragment_trigger.xml b/app/src/main/res/layout/fragment_trigger.xml index 49e80c3161..abe01a77e9 100644 --- a/app/src/main/res/layout/fragment_trigger.xml +++ b/app/src/main/res/layout/fragment_trigger.xml @@ -120,6 +120,7 @@ android:enabled="@{viewModel.triggerModeButtonsEnabled}" android:paddingTop="8dp" android:text="@string/press_dot_dot_dot" + android:visibility="@{viewModel.triggerModeRadioButtonsVisible ? View.VISIBLE : View.GONE}" android:textAppearance="@style/TextAppearance.Material3.LabelMedium" app:layout_constraintBottom_toTopOf="@+id/radioGroupTriggerMode" app:layout_constraintStart_toStartOf="parent" /> @@ -134,6 +135,7 @@ android:checkedButton="@{viewModel.checkedTriggerModeRadioButton}" android:gravity="bottom" android:orientation="horizontal" + android:visibility="@{viewModel.triggerModeRadioButtonsVisible ? View.VISIBLE : View.GONE}" app:layout_constraintBottom_toTopOf="@+id/buttonRecordKeys" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"> From 7cdca4646944dbc820e228ad0bcc13f8ec10521f Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 26 Sep 2024 22:00:43 +0200 Subject: [PATCH 020/118] #1297 add theme for jetpack compose --- .../github/sds100/keymapper/ComposeColors.kt | 61 ++++++++++++ .../github/sds100/keymapper/ComposeTheme.kt | 97 +++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 app/src/main/java/io/github/sds100/keymapper/ComposeColors.kt create mode 100755 app/src/main/java/io/github/sds100/keymapper/ComposeTheme.kt diff --git a/app/src/main/java/io/github/sds100/keymapper/ComposeColors.kt b/app/src/main/java/io/github/sds100/keymapper/ComposeColors.kt new file mode 100644 index 0000000000..65002c01a4 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/ComposeColors.kt @@ -0,0 +1,61 @@ +package io.github.sds100.keymapper + +import androidx.compose.ui.graphics.Color + +object ComposeColors { + val primaryLight = Color(0xFF175DB2) + val onPrimaryLight = Color(0xFFFFFFFF) + val primaryContainerLight = Color(0xFFD6E3FF) + val onPrimaryContainerLight = Color(0xFF001B3F) + val secondaryLight = Color(0xFF005EB5) + val onSecondaryLight = Color(0xFFFFFFFF) + val secondaryContainerLight = Color(0xFF001B3D) + val onSecondaryContainerLight = Color(0xFFD4E3FF) + val tertiaryLight = Color(0xFF0061A3) + val onTertiaryLight = Color(0xFFFFFFFF) + val tertiaryContainerLight = Color(0xFFCFE4FF) + val onTertiaryContainerLight = Color(0xFF001D36) + val errorLight = Color(0xFFBA1B1B) + val onErrorLight = Color(0xFFFFFFFF) + val errorContainerLight = Color(0xFFFFDAD4) + val onErrorContainerLight = Color(0xFF410001) + val backgroundLight = Color(0xFFFDFBFF) + val onBackgroundLight = Color(0xFF1A1B1F) + val surfaceLight = Color(0xFFFDFBFF) + val onSurfaceLight = Color(0xFF1A1B1F) + val surfaceVariantLight = Color(0xFFE0E2EC) + val onSurfaceVariantLight = Color(0xFF44474F) + val outlineLight = Color(0xFF74777F) + val outlineVariantLight = Color(0xFFBFC8CA) + val inverseSurfaceLight = Color(0xFF2F3034) + val inverseOnSurfaceLight = Color(0xFFF2F0F4) + val inversePrimaryLight = Color(0xFFA8C7FF) + + val primaryDark = Color(0xFFA8C7FF) + val onPrimaryDark = Color(0xFF002F66) + val primaryContainerDark = Color(0xFF004590) + val onPrimaryContainerDark = Color(0xFFD6E3FF) + val secondaryDark = Color(0xFFB1CBD0) + val onSecondaryDark = Color(0xFF003063) + val secondaryContainerDark = Color(0xFF00468A) + val onSecondaryContainerDark = Color(0xFFD4E3FF) + val tertiaryDark = Color(0xFF9BCAFF) + val onTertiaryDark = Color(0xFF003259) + val tertiaryContainerDark = Color(0xFF00497E) + val onTertiaryContainerDark = Color(0xFFCFE4FF) + val errorDark = Color(0xFFFFB4A9) + val onErrorDark = Color(0xFF930006) + val errorContainerDark = Color(0xFF93000A) + val onErrorContainerDark = Color(0xFFFFDAD4) + val backgroundDark = Color(0xFF1A1B1F) + val onBackgroundDark = Color(0xFFE3E2E6) + val surfaceDark = Color(0xFF1A1B1F) + val onSurfaceDark = Color(0xFFE3E2E6) + val surfaceVariantDark = Color(0xFF44474F) + val onSurfaceVariantDark = Color(0xFFC4C6CF) + val outlineDark = Color(0xFF8D9099) + val outlineVariantDark = Color(0xFF3F484A) + val inverseSurfaceDark = Color(0xFFE3E2E6) + val inverseOnSurfaceDark = Color(0xFF1A1B1F) + +} \ No newline at end of file diff --git a/app/src/main/java/io/github/sds100/keymapper/ComposeTheme.kt b/app/src/main/java/io/github/sds100/keymapper/ComposeTheme.kt new file mode 100755 index 0000000000..d2a2c0c545 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/ComposeTheme.kt @@ -0,0 +1,97 @@ +package io.github.sds100.keymapper + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +object ComposeTheme { + val lightScheme = lightColorScheme( + primary = ComposeColors.primaryLight, + onPrimary = ComposeColors.onPrimaryLight, + primaryContainer = ComposeColors.primaryContainerLight, + onPrimaryContainer = ComposeColors.onPrimaryContainerLight, + secondary = ComposeColors.secondaryLight, + onSecondary = ComposeColors.onSecondaryLight, + secondaryContainer = ComposeColors.secondaryContainerLight, + onSecondaryContainer = ComposeColors.onSecondaryContainerLight, + tertiary = ComposeColors.tertiaryLight, + onTertiary = ComposeColors.onTertiaryLight, + tertiaryContainer = ComposeColors.tertiaryContainerLight, + onTertiaryContainer = ComposeColors.onTertiaryContainerLight, + error = ComposeColors.errorLight, + onError = ComposeColors.onErrorLight, + errorContainer = ComposeColors.errorContainerLight, + onErrorContainer = ComposeColors.onErrorContainerLight, + background = ComposeColors.backgroundLight, + onBackground = ComposeColors.onBackgroundLight, + surface = ComposeColors.surfaceLight, + onSurface = ComposeColors.onSurfaceLight, + surfaceVariant = ComposeColors.surfaceVariantLight, + onSurfaceVariant = ComposeColors.onSurfaceVariantLight, + outline = ComposeColors.outlineLight, + outlineVariant = ComposeColors.outlineVariantLight, + inverseSurface = ComposeColors.inverseSurfaceLight, + inverseOnSurface = ComposeColors.inverseOnSurfaceLight, + inversePrimary = ComposeColors.inversePrimaryLight, + ) + + val darkScheme = + darkColorScheme( + primary = ComposeColors.primaryDark, + onPrimary = ComposeColors.onPrimaryDark, + primaryContainer = ComposeColors.primaryContainerDark, + onPrimaryContainer = ComposeColors.onPrimaryContainerDark, + secondary = ComposeColors.secondaryDark, + onSecondary = ComposeColors.onSecondaryDark, + secondaryContainer = ComposeColors.secondaryContainerDark, + onSecondaryContainer = ComposeColors.onSecondaryContainerDark, + tertiary = ComposeColors.tertiaryDark, + onTertiary = ComposeColors.onTertiaryDark, + tertiaryContainer = ComposeColors.tertiaryContainerDark, + onTertiaryContainer = ComposeColors.onTertiaryContainerDark, + error = ComposeColors.errorDark, + onError = ComposeColors.onErrorDark, + errorContainer = ComposeColors.errorContainerDark, + onErrorContainer = ComposeColors.onErrorContainerDark, + background = ComposeColors.backgroundDark, + onBackground = ComposeColors.onBackgroundDark, + surface = ComposeColors.surfaceDark, + onSurface = ComposeColors.onSurfaceDark, + surfaceVariant = ComposeColors.surfaceVariantDark, + onSurfaceVariant = ComposeColors.onSurfaceVariantDark, + outline = ComposeColors.outlineDark, + outlineVariant = ComposeColors.outlineVariantDark, + inverseSurface = ComposeColors.inverseSurfaceDark, + inverseOnSurface = ComposeColors.inverseOnSurfaceDark, + ) +} + +@Composable +fun KeyMapperTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = if (darkTheme) ComposeTheme.darkScheme else ComposeTheme.lightScheme + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.surfaceContainer.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography(), + content = content, + ) +} From 286b0bf9f6cfdee16dbd93ca94ee07a0dadc33b7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 26 Sep 2024 22:13:24 +0200 Subject: [PATCH 021/118] #1297 add empty Jetpack Compose record trigger button --- app/build.gradle | 10 +++-- .../keymaps/RecordTriggerButtonRow.kt | 41 +++++++++++++++++++ .../keymaps/trigger/TriggerFragment.kt | 19 +++++++-- .../fragment_trigger.xml | 10 ++--- .../layout-w600dp-land/fragment_trigger.xml | 18 ++++---- .../layout-w900dp-h600dp/fragment_trigger.xml | 16 ++++---- app/src/main/res/layout/fragment_trigger.xml | 12 +++--- 7 files changed, 87 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt diff --git a/app/build.gradle b/app/build.gradle index ec3fc44076..85c508ca83 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -160,8 +160,8 @@ dependencies { implementation "dev.rikka.shizuku:api:$shizuku_version" implementation "dev.rikka.shizuku:provider:$shizuku_version" implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3' - proImplementation "com.android.billingclient:billing:7.0.0" - proImplementation "com.android.billingclient:billing-ktx:7.0.0" + proImplementation "com.android.billingclient:billing:7.1.0" + proImplementation "com.android.billingclient:billing-ktx:7.1.0" // splitties implementation "com.louiscad.splitties:splitties-bitflags:$splitties_version" @@ -197,8 +197,10 @@ dependencies { kapt "androidx.room:room-compiler:$room_version" // Compose - implementation 'androidx.compose.ui:ui-android:1.6.8' - implementation 'androidx.compose.material3:material3-android:1.2.1' + implementation 'androidx.compose.ui:ui-android:1.7.2' + implementation 'androidx.compose.material3:material3-android:1.3.0' + implementation 'androidx.compose.ui:ui-tooling-preview-android:1.7.2' + debugImplementation 'androidx.compose.ui:ui-tooling:1.7.2' // debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.6' diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt new file mode 100644 index 0000000000..cac68a2164 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt @@ -0,0 +1,41 @@ +package io.github.sds100.keymapper.mappings.keymaps + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.github.sds100.keymapper.KeyMapperTheme +import io.github.sds100.keymapper.R + +/** + * This row of buttons is shown at the bottom of the TriggerFragment. + */ +@Composable +fun RecordTriggerButtonRow(modifier: Modifier = Modifier) { + Row(modifier) { + RecordTriggerButton() + } +} + +@Composable +private fun RecordTriggerButton() { + FilledTonalButton( + onClick = {}, + colors = ButtonDefaults.filledTonalButtonColors() + .copy(containerColor = MaterialTheme.colorScheme.primary) + ) { + Text(stringResource(R.string.button_record_trigger)) + } +} + +@androidx.compose.ui.tooling.preview.Preview +@Composable +private fun Preview() { + KeyMapperTheme { + RecordTriggerButtonRow() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt index 0b1fd3b40e..f863d6e235 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt @@ -3,6 +3,9 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.lifecycle.Lifecycle import androidx.navigation.navGraphViewModels import androidx.recyclerview.widget.ItemTouchHelper @@ -10,17 +13,18 @@ import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.EpoxyRecyclerView import com.airbnb.epoxy.EpoxyTouchHelper import com.google.android.material.card.MaterialCardView +import io.github.sds100.keymapper.KeyMapperTheme import io.github.sds100.keymapper.R import io.github.sds100.keymapper.TriggerKeyBindingModel_ import io.github.sds100.keymapper.databinding.FragmentTriggerBinding import io.github.sds100.keymapper.fixError import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapTriggerViewModel import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapViewModel +import io.github.sds100.keymapper.mappings.keymaps.RecordTriggerButtonRow import io.github.sds100.keymapper.triggerKey import io.github.sds100.keymapper.util.FragmentInfo import io.github.sds100.keymapper.util.Inject import io.github.sds100.keymapper.util.State -import io.github.sds100.keymapper.util.color import io.github.sds100.keymapper.util.launchRepeatOnLifecycle import io.github.sds100.keymapper.util.ui.RecyclerViewFragment import kotlinx.coroutines.flow.Flow @@ -53,13 +57,22 @@ class TriggerFragment : RecyclerViewFragment @@ -166,21 +166,19 @@ - diff --git a/app/src/main/res/layout-w600dp-land/fragment_trigger.xml b/app/src/main/res/layout-w600dp-land/fragment_trigger.xml index 0d69de3ffc..3955cbf244 100644 --- a/app/src/main/res/layout-w600dp-land/fragment_trigger.xml +++ b/app/src/main/res/layout-w600dp-land/fragment_trigger.xml @@ -83,11 +83,11 @@ android:layout_height="wrap_content" android:layout_marginStart="@dimen/cardview_padding_left" android:layout_marginEnd="@dimen/cardview_padding_right" - android:onCheckedChanged="@{(radioGroup, checkedId) -> viewModel.onClickTypeRadioButtonCheckedChange(checkedId)}" + android:checkedButton="@{viewModel.checkedClickTypeRadioButton}" android:gravity="bottom" + android:onCheckedChanged="@{(radioGroup, checkedId) -> viewModel.onClickTypeRadioButtonCheckedChange(checkedId)}" android:orientation="horizontal" android:visibility="@{viewModel.clickTypeRadioButtonsVisible ? View.VISIBLE : View.GONE}" - android:checkedButton="@{viewModel.checkedClickTypeRadioButton}" app:layout_constraintBottom_toTopOf="@+id/textViewRadioGroupHeader" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="@+id/recyclerViewError"> @@ -137,7 +137,7 @@ android:checkedButton="@{viewModel.checkedTriggerModeRadioButton}" android:gravity="bottom" android:orientation="horizontal" - app:layout_constraintBottom_toTopOf="@+id/buttonRecordKeys" + app:layout_constraintBottom_toTopOf="@+id/composeViewRecordTriggerButtons" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="@id/recyclerViewError"> @@ -145,18 +145,18 @@ android:id="@+id/radioButtonParallel" android:layout_width="match_parent" android:layout_height="wrap_content" - android:onCheckedChanged="@{(view, isChecked) -> viewModel.onParallelRadioButtonCheckedChange(isChecked)}" android:layout_weight="0.5" android:enabled="@{viewModel.triggerModeButtonsEnabled}" + android:onCheckedChanged="@{(view, isChecked) -> viewModel.onParallelRadioButtonCheckedChange(isChecked)}" android:text="@string/radio_button_parallel" /> - diff --git a/app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml b/app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml index 8531fb1849..9877ca3d04 100644 --- a/app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml +++ b/app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml @@ -84,7 +84,7 @@ android:onCheckedChanged="@{(radioGroup, checkedId) -> viewModel.onClickTypeRadioButtonCheckedChange(checkedId)}" android:orientation="horizontal" android:visibility="@{viewModel.clickTypeRadioButtonsVisible ? View.VISIBLE : View.GONE}" - app:layout_constraintBottom_toTopOf="@id/buttonRecordKeys" + app:layout_constraintBottom_toTopOf="@id/composeViewRecordTriggerButtons" app:layout_constraintEnd_toStartOf="@id/radioGroupTriggerMode" app:layout_constraintStart_toStartOf="parent"> @@ -131,7 +131,7 @@ android:checkedButton="@{viewModel.checkedTriggerModeRadioButton}" android:gravity="bottom" android:orientation="horizontal" - app:layout_constraintBottom_toTopOf="@+id/buttonRecordKeys" + app:layout_constraintBottom_toTopOf="@+id/composeViewRecordTriggerButtons" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@+id/radioGroupClickType"> @@ -139,18 +139,18 @@ android:id="@+id/radioButtonParallel" android:layout_width="match_parent" android:layout_height="wrap_content" - android:onCheckedChanged="@{(view, isChecked) -> viewModel.onParallelRadioButtonCheckedChange(isChecked)}" android:layout_weight="0.5" android:enabled="@{viewModel.triggerModeButtonsEnabled}" + android:onCheckedChanged="@{(view, isChecked) -> viewModel.onParallelRadioButtonCheckedChange(isChecked)}" android:text="@string/radio_button_parallel" /> @@ -163,21 +163,19 @@ android:visibility="gone" /> - diff --git a/app/src/main/res/layout/fragment_trigger.xml b/app/src/main/res/layout/fragment_trigger.xml index abe01a77e9..65d5b2269d 100644 --- a/app/src/main/res/layout/fragment_trigger.xml +++ b/app/src/main/res/layout/fragment_trigger.xml @@ -120,8 +120,8 @@ android:enabled="@{viewModel.triggerModeButtonsEnabled}" android:paddingTop="8dp" android:text="@string/press_dot_dot_dot" - android:visibility="@{viewModel.triggerModeRadioButtonsVisible ? View.VISIBLE : View.GONE}" android:textAppearance="@style/TextAppearance.Material3.LabelMedium" + android:visibility="@{viewModel.triggerModeRadioButtonsVisible ? View.VISIBLE : View.GONE}" app:layout_constraintBottom_toTopOf="@+id/radioGroupTriggerMode" app:layout_constraintStart_toStartOf="parent" /> @@ -136,7 +136,7 @@ android:gravity="bottom" android:orientation="horizontal" android:visibility="@{viewModel.triggerModeRadioButtonsVisible ? View.VISIBLE : View.GONE}" - app:layout_constraintBottom_toTopOf="@+id/buttonRecordKeys" + app:layout_constraintBottom_toTopOf="@+id/composeViewRecordTriggerButtons" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"> @@ -168,21 +168,19 @@ - From 8f57a609f39aa75367d0aa22c0b443924cc68594 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 26 Sep 2024 22:17:12 +0200 Subject: [PATCH 022/118] #1297 use dynamic Material You colors for compose theme --- .../java/io/github/sds100/keymapper/ComposeTheme.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/ComposeTheme.kt b/app/src/main/java/io/github/sds100/keymapper/ComposeTheme.kt index d2a2c0c545..5ec3ce969a 100755 --- a/app/src/main/java/io/github/sds100/keymapper/ComposeTheme.kt +++ b/app/src/main/java/io/github/sds100/keymapper/ComposeTheme.kt @@ -1,14 +1,18 @@ package io.github.sds100.keymapper import android.app.Activity +import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat @@ -79,7 +83,14 @@ fun KeyMapperTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, ) { - val colorScheme = if (darkTheme) ComposeTheme.darkScheme else ComposeTheme.lightScheme + val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val colorScheme = when { + dynamicColor && darkTheme -> dynamicDarkColorScheme(LocalContext.current) + dynamicColor && !darkTheme -> dynamicLightColorScheme(LocalContext.current) + darkTheme -> ComposeTheme.darkScheme + else -> ComposeTheme.lightScheme + } + val view = LocalView.current if (!view.isInEditMode) { SideEffect { From 00bf278f9bbf0a875dce5284ec6c4145d4e953c2 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 26 Sep 2024 22:21:54 +0200 Subject: [PATCH 023/118] fix (ConfigMappingFragment): Don't show the swipe animations for reaching the end of the pager if there is only one page. --- .../github/sds100/keymapper/mappings/ConfigMappingFragment.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/ConfigMappingFragment.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/ConfigMappingFragment.kt index 45c4e660f5..bc909bba47 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/ConfigMappingFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/ConfigMappingFragment.kt @@ -78,6 +78,10 @@ abstract class ConfigMappingFragment : Fragment() { fragmentInfoList.map { it.first.toLong() to it.second.instantiate }, ) + // Don't show the swipe animations for reaching the end of the pager + // if there is only one page. + binding.viewPager.isUserInputEnabled = fragmentInfoList.size > 1 + TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, position -> val tabTitleRes = fragmentInfoList[position].second.header From 1433ee588cf388862bef63ada175cc93350beb19 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 28 Sep 2024 23:34:39 +0200 Subject: [PATCH 024/118] #1297 complete new record trigger button --- .../keymapper/{ => compose}/ComposeColors.kt | 9 +- .../keymapper/compose/ComposeCustomColors.kt | 28 ++++ .../keymapper/{ => compose}/ComposeTheme.kt | 23 +++- .../keymaps/ConfigKeyMapTriggerViewModel.kt | 21 ++- .../keymaps/RecordTriggerButtonRow.kt | 122 ++++++++++++++++-- .../keymaps/trigger/TriggerFragment.kt | 4 +- app/src/main/res/values/strings.xml | 2 + 7 files changed, 173 insertions(+), 36 deletions(-) rename app/src/main/java/io/github/sds100/keymapper/{ => compose}/ComposeColors.kt (92%) create mode 100644 app/src/main/java/io/github/sds100/keymapper/compose/ComposeCustomColors.kt rename app/src/main/java/io/github/sds100/keymapper/{ => compose}/ComposeTheme.kt (88%) diff --git a/app/src/main/java/io/github/sds100/keymapper/ComposeColors.kt b/app/src/main/java/io/github/sds100/keymapper/compose/ComposeColors.kt similarity index 92% rename from app/src/main/java/io/github/sds100/keymapper/ComposeColors.kt rename to app/src/main/java/io/github/sds100/keymapper/compose/ComposeColors.kt index 65002c01a4..d4b067d4b5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/ComposeColors.kt +++ b/app/src/main/java/io/github/sds100/keymapper/compose/ComposeColors.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper +package io.github.sds100.keymapper.compose import androidx.compose.ui.graphics.Color @@ -30,6 +30,8 @@ object ComposeColors { val inverseSurfaceLight = Color(0xFF2F3034) val inverseOnSurfaceLight = Color(0xFFF2F0F4) val inversePrimaryLight = Color(0xFFA8C7FF) + val redLight = Color(0xffd32f2f) + val onRedLight = Color(0xFFFFFFFF) val primaryDark = Color(0xFFA8C7FF) val onPrimaryDark = Color(0xFF002F66) @@ -57,5 +59,6 @@ object ComposeColors { val outlineVariantDark = Color(0xFF3F484A) val inverseSurfaceDark = Color(0xFFE3E2E6) val inverseOnSurfaceDark = Color(0xFF1A1B1F) - -} \ No newline at end of file + val redDark = Color(0xffff7961) + val onRedDark = Color(0xFFFFFFFF) +} diff --git a/app/src/main/java/io/github/sds100/keymapper/compose/ComposeCustomColors.kt b/app/src/main/java/io/github/sds100/keymapper/compose/ComposeCustomColors.kt new file mode 100644 index 0000000000..55545a9a3d --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/compose/ComposeCustomColors.kt @@ -0,0 +1,28 @@ +package io.github.sds100.keymapper.compose + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +/** + * Stores the custom colors in a palette that changes + * depending on the light/dark theme. A CompositionLocalProvider + * is used in the KeyMapperTheme to provide the correct palette in a similar + * way to how MaterialTheme.current works. + */ +@Immutable +data class ComposeCustomColors( + val red: Color = Color.Unspecified, + val onRed: Color = Color.Unspecified, +) { + companion object { + val LightPalette = ComposeCustomColors( + red = ComposeColors.redLight, + onRed = ComposeColors.onRedLight, + ) + + val DarkPalette = ComposeCustomColors( + red = ComposeColors.redDark, + onRed = ComposeColors.onRedDark, + ) + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/ComposeTheme.kt b/app/src/main/java/io/github/sds100/keymapper/compose/ComposeTheme.kt similarity index 88% rename from app/src/main/java/io/github/sds100/keymapper/ComposeTheme.kt rename to app/src/main/java/io/github/sds100/keymapper/compose/ComposeTheme.kt index 5ec3ce969a..380dabf5ad 100755 --- a/app/src/main/java/io/github/sds100/keymapper/ComposeTheme.kt +++ b/app/src/main/java/io/github/sds100/keymapper/compose/ComposeTheme.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper +package io.github.sds100.keymapper.compose import android.app.Activity import android.os.Build @@ -10,7 +10,9 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView @@ -78,6 +80,8 @@ object ComposeTheme { ) } +val LocalCustomColorsPalette = staticCompositionLocalOf { ComposeCustomColors() } + @Composable fun KeyMapperTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -100,9 +104,16 @@ fun KeyMapperTheme( } } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography(), - content = content, - ) + val customColorsPalette = + if (darkTheme) ComposeCustomColors.DarkPalette else ComposeCustomColors.LightPalette + + CompositionLocalProvider( + LocalCustomColorsPalette provides customColorsPalette, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = Typography(), + content = content, + ) + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt index 3712417133..ff661846ad 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.mappings.keymaps import android.os.Build import android.view.KeyEvent +import androidx.compose.runtime.getValue import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTrigger @@ -82,16 +83,11 @@ class ConfigKeyMapTriggerViewModel( */ val openEditOptions = _openEditOptions.asSharedFlow() - val recordTriggerButtonText: StateFlow = recordTrigger.state.map { recordTriggerState -> - when (recordTriggerState) { - is RecordTriggerState.CountingDown -> getString( - R.string.button_recording_trigger_countdown, - recordTriggerState.timeLeft, - ) - - RecordTriggerState.Stopped -> getString(R.string.button_record_trigger) - } - }.flowOn(Dispatchers.Default).stateIn(coroutineScope, SharingStarted.Lazily, "") + val recordTriggerState: StateFlow = recordTrigger.state.stateIn( + coroutineScope, + SharingStarted.Lazily, + RecordTriggerState.Stopped, + ) val triggerModeButtonsEnabled: StateFlow = config.mapping.map { state -> when (state) { @@ -137,8 +133,9 @@ class ConfigKeyMapTriggerViewModel( }.flowOn(Dispatchers.Default).stateIn(coroutineScope, SharingStarted.Eagerly, false) /** - * After adding the feature for advanced triggers, only show the buttons - * for the trigger mode if there are keys recorded. + * Only show the buttons for the trigger mode if keys have been added. The buttons + * shouldn't be shown when no trigger is selected because they aren't relevant + * for advanced triggers. */ val triggerModeRadioButtonsVisible: StateFlow = config.mapping .map { state -> diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt index cac68a2164..355b1f4cc5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt @@ -1,41 +1,137 @@ package io.github.sds100.keymapper.mappings.keymaps import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import io.github.sds100.keymapper.KeyMapperTheme +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme +import io.github.sds100.keymapper.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerState /** * This row of buttons is shown at the bottom of the TriggerFragment. */ @Composable -fun RecordTriggerButtonRow(modifier: Modifier = Modifier) { +fun RecordTriggerButtonRow( + modifier: Modifier = Modifier, + viewModel: ConfigKeyMapTriggerViewModel, +) { + val recordTriggerState by viewModel.recordTriggerState.collectAsState() + + RecordTriggerButtonRow( + modifier = modifier, + onRecordTriggerClick = viewModel::onRecordTriggerButtonClick, + recordTriggerState = recordTriggerState, + onAdvancedTriggersClick = { + }, + ) +} + +/** + * This row of buttons is shown at the bottom of the TriggerFragment. + */ +@Composable +private fun RecordTriggerButtonRow( + modifier: Modifier = Modifier, + onRecordTriggerClick: () -> Unit = {}, + recordTriggerState: RecordTriggerState, + onAdvancedTriggersClick: () -> Unit = {}, +) { Row(modifier) { - RecordTriggerButton() + RecordTriggerButton( + modifier = Modifier.weight(1f), + recordTriggerState, + onClick = onRecordTriggerClick, + ) + + Spacer(modifier = Modifier.width(8.dp)) + + AdvancedTriggersButton( + modifier = Modifier.weight(1f), + isEnabled = recordTriggerState is RecordTriggerState.Stopped, + onClick = onAdvancedTriggersClick, + ) } } @Composable -private fun RecordTriggerButton() { +private fun RecordTriggerButton( + modifier: Modifier, + state: RecordTriggerState, + onClick: () -> Unit, +) { + val colors = ButtonDefaults.filledTonalButtonColors().copy( + containerColor = LocalCustomColorsPalette.current.red, + contentColor = LocalCustomColorsPalette.current.onRed, + ) + + val text: String = when (state) { + is RecordTriggerState.CountingDown -> + stringResource(R.string.button_recording_trigger_countdown, state.timeLeft) + + RecordTriggerState.Stopped -> + stringResource(R.string.button_record_trigger) + } + FilledTonalButton( - onClick = {}, - colors = ButtonDefaults.filledTonalButtonColors() - .copy(containerColor = MaterialTheme.colorScheme.primary) + modifier = modifier, + onClick = onClick, + colors = colors, + ) { + Text(text) + } +} + +@Composable +private fun AdvancedTriggersButton( + modifier: Modifier, + isEnabled: Boolean, + onClick: () -> Unit, +) { + OutlinedButton( + modifier = modifier, + enabled = isEnabled, + onClick = onClick, ) { - Text(stringResource(R.string.button_record_trigger)) + Text(stringResource(R.string.button_advanced_triggers)) } } -@androidx.compose.ui.tooling.preview.Preview +@Preview(widthDp = 400) @Composable -private fun Preview() { +private fun PreviewCountingDown() { KeyMapperTheme { - RecordTriggerButtonRow() + Surface { + RecordTriggerButtonRow( + modifier = Modifier.fillMaxWidth(), + recordTriggerState = RecordTriggerState.CountingDown(3), + ) + } } -} \ No newline at end of file +} + +@Preview(widthDp = 400) +@Composable +private fun PreviewStopped() { + KeyMapperTheme { + Surface { + RecordTriggerButtonRow( + modifier = Modifier.fillMaxWidth(), + recordTriggerState = RecordTriggerState.Stopped, + ) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt index f863d6e235..3600cf1534 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt @@ -13,9 +13,9 @@ import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.EpoxyRecyclerView import com.airbnb.epoxy.EpoxyTouchHelper import com.google.android.material.card.MaterialCardView -import io.github.sds100.keymapper.KeyMapperTheme import io.github.sds100.keymapper.R import io.github.sds100.keymapper.TriggerKeyBindingModel_ +import io.github.sds100.keymapper.compose.KeyMapperTheme import io.github.sds100.keymapper.databinding.FragmentTriggerBinding import io.github.sds100.keymapper.fixError import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapTriggerViewModel @@ -64,7 +64,7 @@ class TriggerFragment : RecyclerViewFragment Add action Record trigger + Advanced triggers + NEW! Done Save Fix From 8df65ced4dc2b4ed870264b5394ceaba6b1ef7f7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 10:13:49 +0200 Subject: [PATCH 025/118] #1297 create empty bottom sheet for choosing advanced triggers --- .../keymaps/RecordTriggerButtonRow.kt | 19 ++++++++ .../trigger/AdvancedTriggersBottomSheet.kt | 45 +++++++++++++++++++ app/src/main/res/values/strings.xml | 1 + 3 files changed, 65 insertions(+) create mode 100644 app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt index 355b1f4cc5..a1fc44d535 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt @@ -5,13 +5,18 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -19,23 +24,37 @@ import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.R import io.github.sds100.keymapper.compose.KeyMapperTheme import io.github.sds100.keymapper.compose.LocalCustomColorsPalette +import io.github.sds100.keymapper.mappings.keymaps.trigger.AdvancedTriggersBottomSheet import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerState /** * This row of buttons is shown at the bottom of the TriggerFragment. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun RecordTriggerButtonRow( modifier: Modifier = Modifier, viewModel: ConfigKeyMapTriggerViewModel, ) { val recordTriggerState by viewModel.recordTriggerState.collectAsState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showBottomSheet: Boolean by rememberSaveable { mutableStateOf(false) } + + if (showBottomSheet) { + AdvancedTriggersBottomSheet( + onDismissRequest = { + showBottomSheet = false + }, + sheetState = sheetState, + ) + } RecordTriggerButtonRow( modifier = modifier, onRecordTriggerClick = viewModel::onRecordTriggerButtonClick, recordTriggerState = recordTriggerState, onAdvancedTriggersClick = { + showBottomSheet = true }, ) } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt new file mode 100644 index 0000000000..91dbc41c14 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt @@ -0,0 +1,45 @@ +package io.github.sds100.keymapper.mappings.keymaps.trigger + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.github.sds100.keymapper.R +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdvancedTriggersBottomSheet( + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, + sheetState: SheetState, +) { + val scope = rememberCoroutineScope() + + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismissRequest, + sheetState = sheetState, + ) { + Text("Test") + IconButton(onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.button_dismiss_advanced_triggers_sheet_content_description), + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 851280890e..20b83ce663 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -450,6 +450,7 @@ Record trigger Advanced triggers NEW! + Dismiss choosing advanced triggers. Done Save Fix From 6519be5a31d8bdd70e8fab280ec13326c290754b Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 10:17:13 +0200 Subject: [PATCH 026/118] #1297 create stubbed advanced triggers bottom sheet for free build --- .../mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename app/src/{main => free}/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt (97%) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt similarity index 97% rename from app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt rename to app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt index 91dbc41c14..80a213c4e2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt @@ -29,7 +29,7 @@ fun AdvancedTriggersBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, ) { - Text("Test") + Text("I am free build.") IconButton(onClick = { scope.launch { sheetState.hide() From b16c234680bed0d3cab4ac88075a7fc8a1259dab Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 10:39:41 +0200 Subject: [PATCH 027/118] #1297 add NEW! badge to advanced triggers button --- .../keymaps/RecordTriggerButtonRow.kt | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt index a1fc44d535..473793d6d5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt @@ -1,12 +1,17 @@ package io.github.sds100.keymapper.mappings.keymaps +import androidx.compose.foundation.layout.Box 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.material3.Badge import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -17,6 +22,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -71,7 +77,9 @@ private fun RecordTriggerButtonRow( ) { Row(modifier) { RecordTriggerButton( - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .align(Alignment.Bottom), recordTriggerState, onClick = onRecordTriggerClick, ) @@ -120,12 +128,30 @@ private fun AdvancedTriggersButton( isEnabled: Boolean, onClick: () -> Unit, ) { - OutlinedButton( - modifier = modifier, - enabled = isEnabled, - onClick = onClick, - ) { - Text(stringResource(R.string.button_advanced_triggers)) + Box(modifier = modifier) { + OutlinedButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp), + enabled = isEnabled, + onClick = onClick, + ) { + Text(stringResource(R.string.button_advanced_triggers)) + } + + Badge( + modifier = Modifier + .align(Alignment.TopEnd) + .height(36.dp), + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Text( + modifier = Modifier.padding(horizontal = 8.dp), + text = stringResource(R.string.button_advanced_triggers_badge), + style = MaterialTheme.typography.labelLarge, + ) + } } } From 7f326577a6a11e0b779b5749943aafb950510eb8 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 10:42:24 +0200 Subject: [PATCH 028/118] #1297 add system bars padding to advanced triggers bottomsheet --- .../sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt index 473793d6d5..c181a35057 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt @@ -6,6 +6,7 @@ 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.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.material3.Badge import androidx.compose.material3.ButtonDefaults @@ -48,6 +49,7 @@ fun RecordTriggerButtonRow( if (showBottomSheet) { AdvancedTriggersBottomSheet( + modifier = Modifier.systemBarsPadding(), onDismissRequest = { showBottomSheet = false }, From bb01372bbec96e93f6f234b95cd60666606ec53d Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 12:21:47 +0200 Subject: [PATCH 029/118] #1297 complete assistant trigger product card --- app/build.gradle | 85 ++++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 85c508ca83..768ddcb447 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,6 @@ -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlin-kapt' +apply plugin: "com.android.application" +apply plugin: "kotlin-android" +apply plugin: "kotlin-kapt" apply plugin: "androidx.navigation.safeargs.kotlin" apply plugin: "kotlinx-serialization" apply plugin: "org.jetbrains.kotlin.plugin.parcelize" @@ -8,9 +8,9 @@ apply plugin: "org.jlleitschuh.gradle.ktlint" android { - namespace 'io.github.sds100.keymapper' + namespace "io.github.sds100.keymapper" compileSdk 34 - buildToolsVersion = '34.0.0' + buildToolsVersion = "34.0.0" def versionProperties = new Properties() file("version.properties").withInputStream { versionProperties.load(it) } @@ -46,7 +46,7 @@ android { release { minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" signingConfig signingConfigs.release } @@ -61,13 +61,13 @@ android { removeUnusedResources true obfuscate false optimizeCode true - proguardFiles 'proguard-rules.pro' + proguardFiles "proguard-rules.pro" } /* - This is required because the splitties library doesn't have a ci build type. + This is required because the splitties library doesn"t have a ci build type. */ - matchingFallbacks = ['debug'] + matchingFallbacks = ["debug"] applicationIdSuffix ".ci" versionNameSuffix "-ci." + versionProperties.getProperty("VERSION_NUM") @@ -115,7 +115,7 @@ android { android.sourceSets { androidTest { assets.srcDirs += files("$projectDir/schemas".toString()) - resources.srcDirs += ['src/test/resources'] + resources.srcDirs += ["src/test/resources"] } } @@ -126,18 +126,18 @@ android.applicationVariants.all { variant -> } dependencies { - implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation fileTree(include: ["*.jar"], dir: "libs") - compileOnly project(':systemstubs') + compileOnly project(":systemstubs") def room_version = "2.6.1" def coroutinesVersion = "1.5.0" - def nav_version = '2.7.7' + def nav_version = "2.7.7" def work_version = "2.9.1" def epoxy_version = "4.6.2" def splitties_version = "3.0.0" def multidex_version = "2.0.1" - def shizuku_version = '13.1.5' + def shizuku_version = "13.1.5" // kotlin stuff implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" @@ -145,21 +145,21 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0" // random stuff - implementation 'com.google.android.material:material:1.12.0' - implementation 'com.github.salomonbrys.kotson:kotson:2.5.0' + implementation "com.google.android.material:material:1.12.0" + implementation "com.github.salomonbrys.kotson:kotson:2.5.0" implementation "com.airbnb.android:epoxy:$epoxy_version" - implementation 'com.github.AppIntro:AppIntro:6.1.0' + implementation "com.github.AppIntro:AppIntro:6.1.0" implementation "com.airbnb.android:epoxy-databinding:$epoxy_version" kapt "com.airbnb.android:epoxy-processor:$epoxy_version" - implementation 'com.jakewharton.timber:timber:4.7.1' - implementation 'uk.co.samuelwall:material-tap-target-prompt:3.1.0' - implementation 'net.lingala.zip4j:zip4j:2.8.0' + implementation "com.jakewharton.timber:timber:4.7.1" + implementation "uk.co.samuelwall:material-tap-target-prompt:3.1.0" + implementation "net.lingala.zip4j:zip4j:2.8.0" implementation "com.anggrayudi:storage:0.8.1" - implementation 'com.github.MFlisar:DragSelectRecyclerView:0.3' - implementation 'com.google.android.flexbox:flexbox:3.0.0' + implementation "com.github.MFlisar:DragSelectRecyclerView:0.3" + implementation "com.google.android.flexbox:flexbox:3.0.0" implementation "dev.rikka.shizuku:api:$shizuku_version" implementation "dev.rikka.shizuku:provider:$shizuku_version" - implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3' + implementation "org.lsposed.hiddenapibypass:hiddenapibypass:4.3" proImplementation "com.android.billingclient:billing:7.1.0" proImplementation "com.android.billingclient:billing-ktx:7.1.0" @@ -172,23 +172,23 @@ dependencies { implementation "com.louiscad.splitties:splitties-mainthread:$splitties_version" // androidx - implementation 'androidx.legacy:legacy-support-core-ui:1.0.0' - implementation 'androidx.core:core-ktx:1.13.1' - - implementation 'androidx.activity:activity-ktx:1.9.1' - implementation 'androidx.fragment:fragment-ktx:1.8.2' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4' - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.4' + implementation "androidx.legacy:legacy-support-core-ui:1.0.0" + implementation "androidx.core:core-ktx:1.13.1" + + implementation "androidx.activity:activity-ktx:1.9.1" + implementation "androidx.fragment:fragment-ktx:1.8.2" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.8.4" implementation "androidx.room:room-ktx:$room_version" implementation "androidx.work:work-runtime-ktx:$work_version" implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version" implementation "androidx.multidex:multidex:$multidex_version" - implementation 'androidx.appcompat:appcompat:1.7.0' - implementation 'androidx.recyclerview:recyclerview:1.3.2' - implementation 'androidx.preference:preference-ktx:1.2.1' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation "androidx.appcompat:appcompat:1.7.0" + implementation "androidx.recyclerview:recyclerview:1.3.2" + implementation "androidx.preference:preference-ktx:1.2.1" + implementation "androidx.constraintlayout:constraintlayout:2.1.4" implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" implementation "androidx.room:room-runtime:$room_version" implementation "androidx.viewpager2:viewpager2:1.1.0" @@ -197,12 +197,13 @@ dependencies { kapt "androidx.room:room-compiler:$room_version" // Compose - implementation 'androidx.compose.ui:ui-android:1.7.2' - implementation 'androidx.compose.material3:material3-android:1.3.0' - implementation 'androidx.compose.ui:ui-tooling-preview-android:1.7.2' - debugImplementation 'androidx.compose.ui:ui-tooling:1.7.2' + implementation "androidx.compose.ui:ui-android:1.7.2" + implementation "androidx.compose.material3:material3-android:1.3.0" + implementation "androidx.compose.ui:ui-tooling-preview-android:1.7.2" + implementation "androidx.compose.material:material-icons-extended-android:1.7.2" + debugImplementation "androidx.compose.ui:ui-tooling:1.7.2" -// debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.6' +// debugImplementation "com.squareup.leakcanary:leakcanary-android:2.6" def junitVersion = "4.13.2" def androidXTestExtKotlinRunnerVersion = "1.2.1" @@ -220,7 +221,7 @@ dependencies { testImplementation "pl.pragmatists:JUnitParams:1.1.1" testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0" testImplementation "org.mockito:mockito-core:5.1.1" - testImplementation 'org.mockito:mockito-inline:5.0.0' + testImplementation "org.mockito:mockito-inline:5.0.0" androidTestImplementation "androidx.test.ext:junit:$androidXTestExtKotlinRunnerVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" @@ -228,7 +229,7 @@ dependencies { androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion" androidTestImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.navigation:navigation-testing:$nav_version" - androidTestImplementation 'android.arch.persistence.room:testing:1.1.1' + androidTestImplementation "android.arch.persistence.room:testing:1.1.1" androidTestImplementation "org.mockito:mockito-android:4.6.1" debugImplementation "androidx.fragment:fragment-testing:1.8.2" implementation "androidx.test:core:$androidXTestCoreVersion" From 5f2c05188e8c34752422e922d4ef4ebf5149f3c6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 14:03:12 +0200 Subject: [PATCH 030/118] #1222 #1307 fix: execute the correct app shortcut action if multiple are used from the same app Fix was to use FLAG_UPDATE_CURRENT in the pending intent. --- .../keymapper/system/apps/AndroidAppShortcutAdapter.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/apps/AndroidAppShortcutAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/apps/AndroidAppShortcutAdapter.kt index 486dbf0a45..db86d7bb52 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/apps/AndroidAppShortcutAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/apps/AndroidAppShortcutAdapter.kt @@ -110,8 +110,12 @@ class AndroidAppShortcutAdapter(context: Context) : AppShortcutAdapter { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) try { - val pendingIntent = - PendingIntent.getActivity(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) + // See issue #1222 and #1307. Must have FLAG_UPDATE_CURRENT so that + // the intent data is updated. If you don't do this and have two app shortcut actions + // from the same app then the data isn't updated and both actions will send + // the pending intent for the shortcut that was triggered first. + val flags = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + val pendingIntent = PendingIntent.getActivity(ctx, 0, intent, flags) pendingIntent.send() return Success(Unit) From b7d6a9c2f3e68a535da42717e5d128f42aa10893 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 14:03:21 +0200 Subject: [PATCH 031/118] chore: set version to 2.6.3 --- app/version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/version.properties b/app/version.properties index 2d5e7f719b..7860daf1ec 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ -VERSION_NAME=2.6.2 -VERSION_CODE=65 +VERSION_NAME=2.6.3 +VERSION_CODE=66 VERSION_NUM=0 \ No newline at end of file From 6baf2247cbd4d4bfb5d72e0ed4d6f874a4655c94 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 14:21:51 +0200 Subject: [PATCH 032/118] delete AutoSwitchImeControllerTest --- .../keymapper/AutoSwitchImeControllerTest.kt | 224 ------------------ 1 file changed, 224 deletions(-) delete mode 100644 app/src/test/java/io/github/sds100/keymapper/AutoSwitchImeControllerTest.kt diff --git a/app/src/test/java/io/github/sds100/keymapper/AutoSwitchImeControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/AutoSwitchImeControllerTest.kt deleted file mode 100644 index c906eb2d3a..0000000000 --- a/app/src/test/java/io/github/sds100/keymapper/AutoSwitchImeControllerTest.kt +++ /dev/null @@ -1,224 +0,0 @@ -package io.github.sds100.keymapper - -import io.github.sds100.keymapper.data.Keys -import io.github.sds100.keymapper.data.repositories.FakePreferenceRepository -import io.github.sds100.keymapper.mappings.PauseMappingsUseCase -import io.github.sds100.keymapper.system.devices.FakeDevicesAdapter -import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import io.github.sds100.keymapper.system.inputmethod.AutoSwitchImeController -import io.github.sds100.keymapper.system.inputmethod.ImeInfo -import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter -import io.github.sds100.keymapper.system.popup.PopupMessageAdapter -import io.github.sds100.keymapper.util.ServiceEvent -import io.github.sds100.keymapper.util.Success -import io.github.sds100.keymapper.util.ui.ResourceProvider -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -/** - * Created by sds100 on 25/04/2021. - */ - -@ExperimentalCoroutinesApi -@RunWith(MockitoJUnitRunner::class) -class AutoSwitchImeControllerTest { - - companion object { - private const val KEY_MAPPER_IME_ID = "key_mapper_keyboard_id" - private const val NORMAL_IME_ID = "proper_keyboard_id" - - private val FAKE_KEYBOARD = InputDeviceInfo( - descriptor = "fake_keyboard_descriptor", - name = "fake keyboard", - id = 1, - isExternal = true, - isGameController = false, - ) - - private val FAKE_CONTROLLER = InputDeviceInfo( - descriptor = "fake_controller_descriptor", - name = "fake controller", - id = 2, - isExternal = true, - isGameController = true, - ) - - private val KEY_MAPPER_IME = ImeInfo( - id = KEY_MAPPER_IME_ID, - packageName = Constants.PACKAGE_NAME, - label = "label", - isEnabled = true, - isChosen = false, - ) - - private val NORMAL_IME = ImeInfo( - id = NORMAL_IME_ID, - packageName = "other.example.app", - label = "normal keyboard", - isEnabled = true, - isChosen = true, - ) - } - - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) - - private lateinit var controller: AutoSwitchImeController - private lateinit var fakePreferenceRepository: FakePreferenceRepository - private lateinit var mockInputMethodAdapter: InputMethodAdapter - private lateinit var mockPauseMappingsUseCase: PauseMappingsUseCase - private lateinit var fakeDevicesAdapter: FakeDevicesAdapter - private lateinit var mockPopupMessageAdapter: PopupMessageAdapter - private lateinit var mockResourceProvider: ResourceProvider - - @Before - fun init() { - fakePreferenceRepository = FakePreferenceRepository() - - mockInputMethodAdapter = mock { - on { getInfoByPackageName(Constants.PACKAGE_NAME) }.then { - Success(KEY_MAPPER_IME) - } - - on { inputMethodHistory }.then { - MutableStateFlow( - listOf(NORMAL_IME), - ) - } - - onBlocking { chooseImeWithoutUserInput(KEY_MAPPER_IME_ID) }.then { - Success( - KEY_MAPPER_IME, - ) - } - onBlocking { chooseImeWithoutUserInput(NORMAL_IME_ID) }.then { - Success( - NORMAL_IME, - ) - } - } - - fakeDevicesAdapter = FakeDevicesAdapter() - - mockPopupMessageAdapter = mock() - - mockPauseMappingsUseCase = mock { - on { isPaused }.then { flow { } } - } - - mockResourceProvider = mock() - - controller = AutoSwitchImeController( - coroutineScope, - fakePreferenceRepository, - mockInputMethodAdapter, - mockPauseMappingsUseCase, - fakeDevicesAdapter, - mockPopupMessageAdapter, - mockResourceProvider, - accessibilityServiceAdapter = mock { - on { eventReceiver }.then { MutableSharedFlow() } - }, - ) - } - - @Test - fun `choose single device, when device connected, show ime picker`() = - coroutineScope.runBlockingTest { - // GIVEN - val chosenDevices = setOf(FAKE_KEYBOARD.descriptor) - - fakePreferenceRepository.set(Keys.showImePickerOnDeviceConnect, true) - fakePreferenceRepository.set(Keys.devicesThatShowImePicker, chosenDevices) - - // WHEN - fakeDevicesAdapter.onInputDeviceConnect.emit(FAKE_KEYBOARD) - - // THEN - verify(mockInputMethodAdapter, times(1)).showImePicker(fromForeground = false) - } - - @Test - fun `choose single device, when device disconnected, show ime picker`() = - coroutineScope.runBlockingTest { - // GIVEN - val chosenDevices = setOf(FAKE_KEYBOARD.descriptor) - - fakePreferenceRepository.set(Keys.showImePickerOnDeviceConnect, true) - fakePreferenceRepository.set(Keys.devicesThatShowImePicker, chosenDevices) - - // WHEN - fakeDevicesAdapter.onInputDeviceDisconnect.emit(FAKE_KEYBOARD) - - // THEN - verify(mockInputMethodAdapter, times(1)).showImePicker(fromForeground = false) - } - - @Test - fun `choose single device, on device disconnect, choose normal keyboard`() = - coroutineScope.runBlockingTest { - // GIVEN - val chosenDevices = setOf(FAKE_KEYBOARD.descriptor) - fakePreferenceRepository.set(Keys.devicesThatChangeIme, chosenDevices) - fakePreferenceRepository.set(Keys.changeImeOnDeviceConnect, true) - fakePreferenceRepository.set(Keys.showToastWhenAutoChangingIme, true) - - whenever(mockInputMethodAdapter.chosenIme).then { MutableStateFlow(KEY_MAPPER_IME) } - - // WHEN - fakeDevicesAdapter.onInputDeviceDisconnect.emit(FAKE_KEYBOARD) - - // THEN - verify(mockInputMethodAdapter, times(1)).chooseImeWithoutUserInput( - NORMAL_IME_ID, - ) - - verify(mockResourceProvider, times(1)).getString( - R.string.toast_chose_keyboard, - NORMAL_IME.label, - ) - verify(mockPopupMessageAdapter, times(1)).showPopupMessage(anyOrNull()) - } - - @Test - fun `choose single device, when device connected, choose key mapper keyboard`() = - coroutineScope.runBlockingTest { - // GIVEN - val chosenDevices = setOf(FAKE_KEYBOARD.descriptor) - fakePreferenceRepository.set(Keys.devicesThatChangeIme, chosenDevices) - fakePreferenceRepository.set(Keys.changeImeOnDeviceConnect, true) - fakePreferenceRepository.set(Keys.showToastWhenAutoChangingIme, true) - - whenever(mockInputMethodAdapter.chosenIme).then { MutableStateFlow(NORMAL_IME) } - - // WHEN - fakeDevicesAdapter.onInputDeviceConnect.emit(FAKE_KEYBOARD) - - // THEN - verify(mockInputMethodAdapter, times(1)).chooseImeWithoutUserInput( - KEY_MAPPER_IME_ID, - ) - - verify(mockResourceProvider, times(1)).getString( - R.string.toast_chose_keyboard, - KEY_MAPPER_IME.label, - ) - verify(mockPopupMessageAdapter, times(1)).showPopupMessage(anyOrNull()) - } -} From 60baa82c2b037d81568d1b9d96a626f5691ab005 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 14:23:20 +0200 Subject: [PATCH 033/118] chore: upgrade coroutines version to 1.9.0 --- app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 45bfc459c6..bbf347a380 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -116,7 +116,7 @@ dependencies { compileOnly project(':systemstubs') def room_version = "2.6.1" - def coroutinesVersion = "1.5.0" + def coroutinesVersion = "1.9.0" def nav_version = '2.7.7' def work_version = "2.9.1" def epoxy_version = "4.6.2" From 2d29dbe543d1141fa475f9f763d189a4e0c11cae Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 14:23:43 +0200 Subject: [PATCH 034/118] use new testdispatcher in TestDispatcherProvider --- .../java/io/github/sds100/keymapper/TestDispatcherProvider.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/io/github/sds100/keymapper/TestDispatcherProvider.kt b/app/src/test/java/io/github/sds100/keymapper/TestDispatcherProvider.kt index 710e14e85b..f1384aba31 100644 --- a/app/src/test/java/io/github/sds100/keymapper/TestDispatcherProvider.kt +++ b/app/src/test/java/io/github/sds100/keymapper/TestDispatcherProvider.kt @@ -1,14 +1,14 @@ package io.github.sds100.keymapper import io.github.sds100.keymapper.util.DispatcherProvider -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestDispatcher /** * Created by sds100 on 01/05/2021. */ class TestDispatcherProvider( - private val testDispatcher: TestCoroutineDispatcher, + private val testDispatcher: TestDispatcher, ) : DispatcherProvider { override fun main() = testDispatcher override fun default() = testDispatcher From c89b8a5e406a2e8937c0a2362f768ed46bbb2cee Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 14:23:55 +0200 Subject: [PATCH 035/118] tests: fix GetActionErrorUseCaseTest --- .../actions/GetActionErrorUseCaseTest.kt | 104 +++++++++--------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/app/src/test/java/io/github/sds100/keymapper/actions/GetActionErrorUseCaseTest.kt b/app/src/test/java/io/github/sds100/keymapper/actions/GetActionErrorUseCaseTest.kt index d901f96a33..eca3be64c8 100644 --- a/app/src/test/java/io/github/sds100/keymapper/actions/GetActionErrorUseCaseTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/actions/GetActionErrorUseCaseTest.kt @@ -8,10 +8,9 @@ import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.util.Error import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.nullValue @@ -30,9 +29,8 @@ import org.mockito.kotlin.whenever @RunWith(MockitoJUnitRunner::class) class GetActionErrorUseCaseTest { - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) private lateinit var useCase: GetActionErrorUseCaseImpl @@ -61,56 +59,58 @@ class GetActionErrorUseCaseTest { * #776 */ @Test - fun `dont show Shizuku errors if a compatible ime is selected`() = coroutineScope.runBlockingTest { - // GIVEN - whenever(mockShizukuAdapter.isInstalled).then { MutableStateFlow(true) } - whenever(mockInputMethodAdapter.chosenIme).then { - MutableStateFlow( - ImeInfo( - id = "ime_id", - packageName = "io.github.sds100.keymapper.inputmethod.latin", - label = "Key Mapper GUI Keyboard", - isEnabled = true, - isChosen = true, - ), - ) + fun `don't show Shizuku errors if a compatible ime is selected`() = + testScope.runTest { + // GIVEN + whenever(mockShizukuAdapter.isInstalled).then { MutableStateFlow(true) } + whenever(mockInputMethodAdapter.chosenIme).then { + MutableStateFlow( + ImeInfo( + id = "ime_id", + packageName = "io.github.sds100.keymapper.inputmethod.latin", + label = "Key Mapper GUI Keyboard", + isEnabled = true, + isChosen = true, + ), + ) + } + + val action = ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN) + + // WHEN + val error = useCase.getError(action) + + // THEN + assertThat(error, nullValue()) } - val action = ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN) - - // WHEN - val error = useCase.getError(action) - - // THEN - assertThat(error, nullValue()) - } - /** * #776 */ @Test - fun `show Shizuku errors if a compatible ime is not selected and Shizuku is installed`() = coroutineScope.runBlockingTest { - // GIVEN - whenever(mockShizukuAdapter.isInstalled).then { MutableStateFlow(true) } - whenever(mockShizukuAdapter.isStarted).then { MutableStateFlow(false) } - - whenever(mockInputMethodAdapter.chosenIme).then { - MutableStateFlow( - ImeInfo( - id = "ime_id", - packageName = "io.gboard", - label = "Gboard", - isEnabled = true, - isChosen = true, - ), - ) + fun `show Shizuku errors if a compatible ime is not selected and Shizuku is installed`() = + testScope.runTest { + // GIVEN + whenever(mockShizukuAdapter.isInstalled).then { MutableStateFlow(true) } + whenever(mockShizukuAdapter.isStarted).then { MutableStateFlow(false) } + + whenever(mockInputMethodAdapter.chosenIme).then { + MutableStateFlow( + ImeInfo( + id = "ime_id", + packageName = "io.gboard", + label = "Gboard", + isEnabled = true, + isChosen = true, + ), + ) + } + + val action = ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN) + // WHEN + val error = useCase.getError(action) + + // THEN + assertThat(error, `is`(Error.ShizukuNotStarted)) } - - val action = ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_VOLUME_DOWN) - // WHEN - val error = useCase.getError(action) - - // THEN - assertThat(error, `is`(Error.ShizukuNotStarted)) - } } From 04bd7a83618c513c42a8c8241e0c8bd10559b0b4 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 17:40:00 +0200 Subject: [PATCH 036/118] run unit tests in github actions workflows --- .github/workflows/production.yml | 12 ++++++++++++ .github/workflows/pull-request.yml | 12 ++++++++++++ .github/workflows/testing.yml | 12 ++++++++++++ 3 files changed, 36 insertions(+) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 5b20b9423b..bb0a1c7d36 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -10,6 +10,18 @@ concurrency: cancel-in-progress: true jobs: + test: + name: Run unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - name: Unit tests + run: bash ./gradlew testDebugUnitTest + apk: name: Build and release to production runs-on: ubuntu-latest diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index c5c1c00520..959d3d6eda 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -4,6 +4,18 @@ on: pull_request: jobs: + test: + name: Run unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - name: Unit tests + run: bash ./gradlew testDebugUnitTest + style: name: Code style check runs-on: ubuntu-latest diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 8696db012f..924139d7d2 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -10,6 +10,18 @@ concurrency: cancel-in-progress: true jobs: + test: + name: Run unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Android SDK + uses: android-actions/setup-android@v2 + + - name: Unit tests + run: bash ./gradlew testDebugUnitTest + style: name: Code style check runs-on: ubuntu-latest From b2ac3ebbbe35060a180f70986e4c72bc24f3278a Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 18:46:22 +0200 Subject: [PATCH 037/118] #1308 fix tests to use new Coroutines library in 1.6.0 See migration: https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-test/MIGRATION.md#replace-runblockingtest-with-runtestunconfinedtestdispatcher --- .../notifications/NotificationController.kt | 10 +- .../sds100/keymapper/BackupManagerTest.kt | 133 ++++-------------- .../keymapper/ConfigKeyMapUseCaseTest.kt | 28 ++-- .../keymapper/KeyMapJsonMigrationTest.kt | 14 +- .../LegacyFingerprintMapMigrationTest.kt | 15 +- .../keymapper/NotificationControllerTest.kt | 36 ++--- .../actions/PerformActionsUseCaseTest.kt | 30 ++-- ...onfigKeyServiceEventActionViewModelTest.kt | 18 ++- .../FingerprintMapRepositoryTest.kt | 32 ++--- .../data/repositories/KeyMapRepositoryTest.kt | 32 ++--- .../keymapper/home/HomeMenuViewModelTest.kt | 79 ----------- .../mappings/SimpleMappingControllerTest.kt | 28 ++-- .../ConfigKeyMapTriggerViewModelTest.kt | 23 ++- .../mappings/keymaps/KeyMapControllerTest.kt | 128 ++++++++--------- ...riggerKeyMapFromOtherAppsControllerTest.kt | 60 ++++---- .../onboarding/OnboardingUseCaseTest.kt | 18 ++- .../intents/ConfigIntentViewModelTest.kt | 12 +- .../restore-keymaps-no-db-version.json | 2 + 18 files changed, 247 insertions(+), 451 deletions(-) delete mode 100644 app/src/test/java/io/github/sds100/keymapper/home/HomeMenuViewModelTest.kt diff --git a/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt b/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt index fbb8fcbf04..815b868bb8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt @@ -108,8 +108,8 @@ class NotificationController( /** * Open the app and use the String as the Intent action. */ - private val _openApp: MutableSharedFlow = MutableSharedFlow() - val openApp: SharedFlow = _openApp.asSharedFlow() + private val _openApp: MutableSharedFlow = MutableSharedFlow() + val openApp: SharedFlow = _openApp.asSharedFlow() private val _showToast = MutableSharedFlow() val showToast = _showToast.asSharedFlow() @@ -213,7 +213,7 @@ class NotificationController( ACTION_STOP_SERVICE -> controlAccessibilityService.stopService() ACTION_DISMISS_TOGGLE_MAPPINGS -> manageNotifications.dismiss(ID_TOGGLE_MAPPINGS) - ACTION_OPEN_KEY_MAPPER -> _openApp.emit(null) + ACTION_OPEN_KEY_MAPPER -> _openApp.emit("") ACTION_SHOW_IME_PICKER -> showImePicker.show(fromForeground = false) ACTION_SHOW_KEYBOARD -> hideInputMethod.show() ACTION_TOGGLE_KEYBOARD -> toggleCompatibleIme.toggle().onSuccess { @@ -224,12 +224,12 @@ class NotificationController( ACTION_FINGERPRINT_GESTURE_FEATURE -> { onboardingUseCase.approvedFingerprintFeaturePrompt = true - _openApp.emit(null) + _openApp.emit("") } ACTION_ON_SETUP_CHOSEN_DEVICES_AGAIN -> { onboardingUseCase.approvedSetupChosenDevicesAgainNotification() - _openApp.emit(null) + _openApp.emit("") } } }.flowOn(dispatchers.default()).launchIn(coroutineScope) diff --git a/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt b/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt index 3deb16da96..2874f2287d 100644 --- a/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/BackupManagerTest.kt @@ -25,12 +25,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.DelayController -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler -import kotlinx.coroutines.test.createTestCoroutineScope +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` @@ -51,7 +50,6 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import timber.log.Timber import java.io.File -import kotlin.coroutines.ContinuationInterceptor /** * Created by sds100 on 19/04/2021. @@ -65,9 +63,8 @@ class BackupManagerTest { @get:Rule var temporaryFolder = TemporaryFolder() - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) private val dispatcherProvider = TestDispatcherProvider(testDispatcher) @@ -83,7 +80,9 @@ class BackupManagerTest { private lateinit var gson: Gson @Before - fun init() { + fun setUp() { + Dispatchers.setMain(testDispatcher) + Timber.plant(TestLoggingTree()) fakePreferenceRepository = FakePreferenceRepository() @@ -105,7 +104,7 @@ class BackupManagerTest { mockUuidGenerator = mock() backupManager = BackupManagerImpl( - coroutineScope, + testScope, fileAdapter = fakeFileAdapter, keyMapRepository = mockKeyMapRepository, preferenceRepository = fakePreferenceRepository, @@ -118,14 +117,11 @@ class BackupManagerTest { parser = JsonParser() gson = Gson() - - Dispatchers.setMain(testDispatcher) } @After fun tearDown() { Dispatchers.resetMain() - testDispatcher.cleanupTestCoroutines() } /** @@ -133,7 +129,9 @@ class BackupManagerTest { */ @Test fun `Don't allow back ups from a newer version of key mapper`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { + advanceUntilIdle() + // GIVEN val dataJsonFile = "restore-app-version-too-big.zip/data.json" val zipFile = fakeFileAdapter.getPrivateFile("backup.zip") @@ -141,14 +139,11 @@ class BackupManagerTest { copyFileToPrivateFolder(dataJsonFile, destination = "backup.zip/data.json") // WHEN - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - val result = backupManager.restoreMappings(zipFile.uri) + advanceUntilIdle() // THEN assertThat(result, `is`(Error.BackupVersionTooNew)) - - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() } /** @@ -156,7 +151,7 @@ class BackupManagerTest { */ @Test fun `Allow back ups from a back up without a key mapper version in it`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN whenever(mockKeyMapRepository.keyMapList).then { MutableStateFlow(State.Data(emptyList())) @@ -172,18 +167,15 @@ class BackupManagerTest { copyFileToPrivateFolder(dataJsonFile, destination = "backup.zip/data.json") // WHEN - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() val result = backupManager.restoreMappings(zipFile.uri) // THEN assertThat(result, `is`(Success(Unit))) - - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() } @Test - fun `don't crash if back up does not contain sounds folder`() = coroutineScope.runBlockingTest { + fun `don't crash if back up does not contain sounds folder`() = runTest(testDispatcher) { // GIVEN whenever(mockKeyMapRepository.keyMapList).then { MutableStateFlow(State.Data(emptyList())) @@ -199,19 +191,15 @@ class BackupManagerTest { copyFileToPrivateFolder(dataJsonFile, destination = "backup.zip/data.json") // WHEN - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - val result = backupManager.restoreMappings(zipFile.uri) // THEN assertThat(result, `is`(Success(Unit))) - - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() } @Test fun `successfully restore zip folder with data json and sound files`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val dataJsonFile = "restore-all.zip/data.json" val soundFile = "restore-all.zip/sounds/sound.ogg" @@ -221,15 +209,11 @@ class BackupManagerTest { copyFileToPrivateFolder(soundFile, destination = "backup.zip/sounds/sound.ogg") // WHEN - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - val result = backupManager.restoreMappings(zipFile.uri) // THEN assertThat(result, `is`(Success(Unit))) - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() - verify(mockKeyMapRepository, times(1)).insert(any(), any()) verify(mockFingerprintMapRepository, times(1)).update(any(), any(), any(), any()) verify(mockSoundsManager, times(1)).restoreSound(any()) @@ -240,7 +224,7 @@ class BackupManagerTest { */ @Test fun `backup sound file even if there is not a key map with a sound action`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val backupDirUuid = "backup_uid" val soundFileName = "sound.ogg" @@ -270,19 +254,14 @@ class BackupManagerTest { } // WHEN - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - val backupZip = File(temporaryFolder.root, "backup.zip") backupZip.mkdirs() val result = backupManager.backupMappings(uri = backupZip.path) // THEN - assertThat(result, `is`(Success(backupZip.path))) - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() - // only 2 files have been backed up assertThat(backupZip.listFiles()?.size, `is`(2)) @@ -294,7 +273,7 @@ class BackupManagerTest { @Test fun `backup sound file if there is a key map with a sound action`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val backupDirUuid = "backup_uuid" val soundFileUid = "uid" @@ -328,8 +307,6 @@ class BackupManagerTest { soundFile.createFile() // WHEN - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - val backupZip = File(temporaryFolder.root, "backup.zip") backupZip.mkdirs() @@ -339,8 +316,6 @@ class BackupManagerTest { assertThat(result, `is`(Success(backupZip.path))) - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() - // only 2 files have been backed up assertThat(backupZip.listFiles()?.size, `is`(2)) @@ -352,145 +327,106 @@ class BackupManagerTest { } @Test - fun `restore legacy backup with device info, success`() = coroutineScope.runBlockingTest { + fun `restore legacy backup with device info, success`() = runTest(testDispatcher) { // GIVEN val fileName = "legacy-backup-test-data.json" // WHEN - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - val result = backupManager.restoreMappings(copyFileToPrivateFolder(fileName)) // THEN assertThat(result, `is`(Success(Unit))) - - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() - verify(mockKeyMapRepository, times(1)).insert(any(), any()) verify(mockFingerprintMapRepository, times(1)).update(any(), any(), any(), any()) } @Test fun `restore keymaps with no db version, assume version is 9 and don't show error message`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { val fileName = "restore-keymaps-no-db-version.json" - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - val result = backupManager.restoreMappings(copyFileToPrivateFolder(fileName)) assertThat(result, `is`(Success(Unit))) - - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() - verify(mockKeyMapRepository, times(1)).insert(any(), any()) } @Test fun `restore a single legacy fingerprint map, only restore a single fingerprint map and a success message`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { val fileName = "restore-legacy-single-fingerprint-map.json" - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - val result = backupManager.restoreMappings(copyFileToPrivateFolder(fileName)) assertThat(result, `is`(Success(Unit))) - - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() - verify(mockFingerprintMapRepository, times(1)).update(any()) } @Test fun `restore all legacy fingerprint maps, all fingerprint maps should be restored and a success message`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { val fileName = "restore-all-legacy-fingerprint-maps.json" - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - val result = backupManager.restoreMappings(copyFileToPrivateFolder(fileName)) assertThat(result, `is`(Success(Unit))) - - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() - verify(mockFingerprintMapRepository, times(1)).update(any(), any(), any(), any()) } @Test fun `restore many key maps and device info, all key maps and device info should be restored and a success message`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { val fileName = "restore-many-keymaps.json" - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - val result = backupManager.restoreMappings(copyFileToPrivateFolder(fileName)) assertThat(result, `is`(Success(Unit))) - - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() - verify(mockKeyMapRepository, times(1)).insert(any(), any(), any(), any()) } @Test fun `restore with key map db version greater than allowed version, send incompatible backup event`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { val fileName = "restore-keymap-db-version-too-big.json" - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() val result = backupManager.restoreMappings(copyFileToPrivateFolder(fileName)) assertThat(result, `is`(Error.BackupVersionTooNew)) - - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() - verify(mockKeyMapRepository, never()).insert(anyVararg()) } @Test fun `restore with legacy fingerprint gesture map db version greater than allowed version, send incompatible backup event`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { val fileName = "restore-legacy-fingerprint-map-version-too-big.json" - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() val result = backupManager.restoreMappings(copyFileToPrivateFolder(fileName)) assertThat(result, `is`(Error.BackupVersionTooNew)) - - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() - verify(mockFingerprintMapRepository, never()).update(anyVararg()) } @Test - fun `restore empty file, show empty json error message`() = coroutineScope.runBlockingTest { + fun `restore empty file, show empty json error message`() = runTest(testDispatcher) { val fileName = "empty.json" - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() val result = backupManager.restoreMappings(copyFileToPrivateFolder(fileName)) assertThat(result, `is`(Error.EmptyJson)) - - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() } @Test - fun `restore corrupt file, show corrupt json message`() = coroutineScope.runBlockingTest { + fun `restore corrupt file, show corrupt json message`() = runTest(testDispatcher) { val fileName = "corrupt.json" - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() val result = backupManager.restoreMappings(copyFileToPrivateFolder(fileName)) assertThat(result, IsInstanceOf(Error.CorruptJsonFile::class.java)) - - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() } @Test fun `backup all fingerprint maps, return list of fingerprint maps and app database version`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val backupDirUuid = "backup_uuid" @@ -513,15 +449,11 @@ class BackupManagerTest { backupZip.mkdirs() // WHEN - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - val result = backupManager.backupFingerprintMaps(backupZip.path) // THEN assertThat(result, `is`(Success(backupZip.path))) - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() - // only 1 file has been backed up assertThat(backupZip.listFiles()?.size, `is`(1)) @@ -543,9 +475,8 @@ class BackupManagerTest { @Test fun `backup key maps, return list of default key maps, keymap db version should be current database version`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN - val backupDirUuid = "backup_uuid" whenever(mockUuidGenerator.random()).then { @@ -560,15 +491,11 @@ class BackupManagerTest { backupZip.mkdirs() // WHEN - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - val result = backupManager.backupKeyMaps(backupZip.path, keyMapList.map { it.uid }) // THEN assertThat(result, `is`(Success(backupZip.path))) - (coroutineScope.coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() - // only 1 file has been backed up assertThat(backupZip.listFiles()?.size, `is`(1)) diff --git a/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt b/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt index 67b541875c..f412bc7d8d 100644 --- a/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt @@ -13,14 +13,11 @@ import io.github.sds100.keymapper.util.dataOrNull import io.github.sds100.keymapper.util.singleKeyTrigger import io.github.sds100.keymapper.util.triggerKey import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.`is` -import org.junit.After import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock @@ -32,9 +29,7 @@ import org.mockito.kotlin.mock @ExperimentalCoroutinesApi class ConfigKeyMapUseCaseTest { - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() private lateinit var useCase: ConfigKeyMapUseCaseImpl @@ -47,11 +42,6 @@ class ConfigKeyMapUseCaseTest { ) } - @After - fun tearDown() { - testDispatcher.cleanupTestCoroutines() - } - /** * Issue #753. If a modifier key is used as a trigger then it the * option to not override the default action must be chosen so that the modifier @@ -59,7 +49,7 @@ class ConfigKeyMapUseCaseTest { */ @Test fun `when add modifier key trigger, enable do not remap option`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { val modifierKeys = setOf( KeyEvent.KEYCODE_SHIFT_LEFT, KeyEvent.KEYCODE_SHIFT_RIGHT, @@ -93,7 +83,7 @@ class ConfigKeyMapUseCaseTest { */ @Test fun `when add non-modifier key trigger, do ont enable do not remap option`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN useCase.mapping.value = State.Data(KeyMap()) @@ -112,7 +102,7 @@ class ConfigKeyMapUseCaseTest { */ @Test fun `when add answer phone call action, then add phone ringing constraint`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN useCase.mapping.value = State.Data(KeyMap()) val action = ActionData.AnswerCall @@ -131,7 +121,7 @@ class ConfigKeyMapUseCaseTest { */ @Test fun `when add end phone call action, then add in phone call constraint`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN useCase.mapping.value = State.Data(KeyMap()) val action = ActionData.EndCall @@ -149,7 +139,7 @@ class ConfigKeyMapUseCaseTest { */ @Test fun `key map with hold down action, load key map, hold down flag shouldn't disappear`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val action = KeyMapAction( data = ActionData.TapScreen(100, 100, null), @@ -171,7 +161,7 @@ class ConfigKeyMapUseCaseTest { @Test fun `add modifier key event action, enable hold down option and disable repeat option`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { KeyEventUtils.MODIFIER_KEYCODES.forEach { keyCode -> useCase.mapping.value = State.Data(KeyMap()) diff --git a/app/src/test/java/io/github/sds100/keymapper/KeyMapJsonMigrationTest.kt b/app/src/test/java/io/github/sds100/keymapper/KeyMapJsonMigrationTest.kt index b05e653a77..3c028b19b1 100644 --- a/app/src/test/java/io/github/sds100/keymapper/KeyMapJsonMigrationTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/KeyMapJsonMigrationTest.kt @@ -13,10 +13,9 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 import io.github.sds100.keymapper.data.migration.MigrationUtils import io.github.sds100.keymapper.util.JsonTestUtils import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test @@ -42,9 +41,8 @@ class KeyMapJsonMigrationTest { @get:Rule var instantExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) private lateinit var parser: JsonParser private lateinit var gson: Gson @@ -111,7 +109,7 @@ class KeyMapJsonMigrationTest { expectedData: JsonArray, inputVersion: Int, outputVersion: Int, - ) = coroutineScope.runBlockingTest { + ) = runTest(testDispatcher) { val migrations = listOf( JsonMigration(9, 10) { json -> Migration9To10.migrateJson(json) }, JsonMigration(10, 11) { json -> Migration10To11.migrateJson(json) }, diff --git a/app/src/test/java/io/github/sds100/keymapper/LegacyFingerprintMapMigrationTest.kt b/app/src/test/java/io/github/sds100/keymapper/LegacyFingerprintMapMigrationTest.kt index 48260c24b0..8c5f6813f6 100644 --- a/app/src/test/java/io/github/sds100/keymapper/LegacyFingerprintMapMigrationTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/LegacyFingerprintMapMigrationTest.kt @@ -14,10 +14,9 @@ import io.github.sds100.keymapper.data.migration.fingerprintmaps.FingerprintMapM import io.github.sds100.keymapper.data.migration.fingerprintmaps.FingerprintMapMigration1To2 import io.github.sds100.keymapper.util.JsonTestUtils import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test @@ -45,9 +44,9 @@ class LegacyFingerprintMapMigrationTest { @get:Rule var instantExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + private lateinit var parser: JsonParser private lateinit var gson: Gson @@ -97,7 +96,7 @@ class LegacyFingerprintMapMigrationTest { expectedData: JsonArray, inputVersion: Int, outputVersion: Int, - ) = coroutineScope.runBlockingTest { + ) = runTest(testDispatcher) { val migrations = listOf( JsonMigration(0, 1) { json -> FingerprintMapMigration0To1.migrate(json) }, JsonMigration(1, 2) { json -> FingerprintMapMigration1To2.migrate(json) }, diff --git a/app/src/test/java/io/github/sds100/keymapper/NotificationControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/NotificationControllerTest.kt index ad298dcfac..1d5f11890f 100644 --- a/app/src/test/java/io/github/sds100/keymapper/NotificationControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/NotificationControllerTest.kt @@ -6,17 +6,15 @@ import io.github.sds100.keymapper.system.accessibility.ServiceState import io.github.sds100.keymapper.system.notifications.ManageNotificationsUseCase import io.github.sds100.keymapper.system.notifications.NotificationController import io.github.sds100.keymapper.system.notifications.NotificationModel -import io.github.sds100.keymapper.util.FlowUtils.toListWithTimeout import io.github.sds100.keymapper.util.ui.ResourceProvider import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.DelayController -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` import org.junit.Before @@ -27,7 +25,6 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import kotlin.coroutines.ContinuationInterceptor /** * Created by sds100 on 25/04/2021. @@ -37,9 +34,8 @@ import kotlin.coroutines.ContinuationInterceptor @RunWith(MockitoJUnitRunner::class) class NotificationControllerTest { - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) private lateinit var controller: NotificationController private lateinit var mockManageNotifications: ManageNotificationsUseCase @@ -62,7 +58,7 @@ class NotificationControllerTest { fakeOnboarding = FakeOnboardingUseCase() controller = NotificationController( - coroutineScope, + testScope, mockManageNotifications, pauseMappings = mock { on { isPaused }.then { flow {} } @@ -88,24 +84,20 @@ class NotificationControllerTest { @Test fun `click setup chosen devices notification, open app and approve`() = - coroutineScope.runBlockingTest { - // WHEN - - (coroutineContext[ContinuationInterceptor]!! as DelayController).pauseDispatcher() - launch { - onActionClick.emit(NotificationController.ACTION_ON_SETUP_CHOSEN_DEVICES_AGAIN) + runTest(testDispatcher) { + val value = async { + controller.openApp.first() } - // THEN - assertThat(controller.openApp.toListWithTimeout().size, `is`(1)) - (coroutineContext[ContinuationInterceptor]!! as DelayController).resumeDispatcher() + onActionClick.emit(NotificationController.ACTION_ON_SETUP_CHOSEN_DEVICES_AGAIN) + assertThat(value.await(), `is`("")) assertThat(fakeOnboarding.approvedSetupChosenDevicesAgainNotification, `is`(true)) } @Test fun `show setup chosen devices notification`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val title = "title" val text = "text" diff --git a/app/src/test/java/io/github/sds100/keymapper/actions/PerformActionsUseCaseTest.kt b/app/src/test/java/io/github/sds100/keymapper/actions/PerformActionsUseCaseTest.kt index 6127261d01..483d51af3c 100644 --- a/app/src/test/java/io/github/sds100/keymapper/actions/PerformActionsUseCaseTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/actions/PerformActionsUseCaseTest.kt @@ -12,10 +12,9 @@ import io.github.sds100.keymapper.util.InputEventType import io.github.sds100.keymapper.util.State import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -37,9 +36,8 @@ import org.mockito.kotlin.whenever @RunWith(MockitoJUnitRunner::class) class PerformActionsUseCaseTest { - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) private lateinit var useCase: PerformActionsUseCaseImpl private lateinit var mockKeyMapperImeMessenger: KeyMapperImeMessenger @@ -55,7 +53,7 @@ class PerformActionsUseCaseTest { mockToastAdapter = mock() useCase = PerformActionsUseCaseImpl( - coroutineScope, + testScope, accessibilityService = mockAccessibilityService, inputMethodAdapter = mock(), fileAdapter = mock(), @@ -95,7 +93,7 @@ class PerformActionsUseCaseTest { */ @Test fun `dont show accessibility service not found error for open menu action`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = ActionData.OpenMenu @@ -118,7 +116,7 @@ class PerformActionsUseCaseTest { */ @Test fun `set the device id of key event actions to a connected game controller if is a game pad key code`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val fakeGamePad = InputDeviceInfo( descriptor = "game_pad", @@ -156,7 +154,7 @@ class PerformActionsUseCaseTest { */ @Test fun `don't set the device id of key event actions to a connected game controller if there are no connected game controllers`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN fakeDevicesAdapter.connectedInputDevices.value = State.Data(emptyList()) @@ -186,7 +184,7 @@ class PerformActionsUseCaseTest { */ @Test fun `don't set the device id of key event actions to a connected game controller if the action has a custom device set`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val fakeGamePad = InputDeviceInfo( descriptor = "game_pad", @@ -236,7 +234,7 @@ class PerformActionsUseCaseTest { */ @Test fun `perform key event action with device name and multiple devices connected with same descriptor and none support the key code, ensure action is still performed`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val descriptor = "fake_device_descriptor" @@ -271,9 +269,7 @@ class PerformActionsUseCaseTest { ) // none of the devices support the key code - fakeDevicesAdapter.deviceHasKey = { id, keyCode -> - false - } + fakeDevicesAdapter.deviceHasKey = { id, keyCode -> false } // WHEN useCase.perform(action, inputEventType = InputEventType.DOWN_UP, keyMetaState = 0) @@ -293,7 +289,7 @@ class PerformActionsUseCaseTest { @Test fun `perform key event action with no device name, ensure action is still performed with correct device id`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val descriptor = "fake_device_descriptor" diff --git a/app/src/test/java/io/github/sds100/keymapper/actions/keyevents/ConfigKeyServiceEventActionViewModelTest.kt b/app/src/test/java/io/github/sds100/keymapper/actions/keyevents/ConfigKeyServiceEventActionViewModelTest.kt index b223aed38d..e28c7f0db1 100644 --- a/app/src/test/java/io/github/sds100/keymapper/actions/keyevents/ConfigKeyServiceEventActionViewModelTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/actions/keyevents/ConfigKeyServiceEventActionViewModelTest.kt @@ -7,12 +7,11 @@ import io.github.sds100.keymapper.system.devices.InputDeviceInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.createTestCoroutineScope import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` @@ -36,9 +35,9 @@ class ConfigKeyServiceEventActionViewModelTest { @get:Rule var instantExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + private lateinit var viewModel: ConfigKeyEventActionViewModel private lateinit var mockUseCase: ConfigKeyEventUseCase @@ -63,13 +62,12 @@ class ConfigKeyServiceEventActionViewModelTest { @After fun tearDown() { - testDispatcher.cleanupTestCoroutines() Dispatchers.resetMain() } @Test fun `multiple input devices with same descriptor but a different name, choose a device, ensure device with correct name is chosen`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val fakeDevice1 = InputDeviceInfo( descriptor = "bla", @@ -92,7 +90,7 @@ class ConfigKeyServiceEventActionViewModelTest { // THEN viewModel.chooseDevice(0) - coroutineScope.advanceUntilIdle() + testScope.advanceUntilIdle() assertThat(viewModel.uiState.value.chosenDeviceName, `is`(fakeDevice1.name)) diff --git a/app/src/test/java/io/github/sds100/keymapper/data/repositories/FingerprintMapRepositoryTest.kt b/app/src/test/java/io/github/sds100/keymapper/data/repositories/FingerprintMapRepositoryTest.kt index 5da322de5f..2a69ebf06e 100644 --- a/app/src/test/java/io/github/sds100/keymapper/data/repositories/FingerprintMapRepositoryTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/data/repositories/FingerprintMapRepositoryTest.kt @@ -11,10 +11,9 @@ import io.github.sds100.keymapper.util.State import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -43,9 +42,8 @@ class FingerprintMapRepositoryTest { ) } - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) private val dispatchers = TestDispatcherProvider(testDispatcher) private lateinit var repository: RoomFingerprintMapRepository @@ -65,7 +63,7 @@ class FingerprintMapRepositoryTest { repository = RoomFingerprintMapRepository( mockDao, - coroutineScope, + testScope, devicesAdapter, dispatchers = dispatchers, ) @@ -73,8 +71,8 @@ class FingerprintMapRepositoryTest { @Test fun `only swipe down fingerprint map in database, insert 3 blank fingerprint maps for the other fingerprint maps`() = - coroutineScope.runBlockingTest { - repository.fingerprintMapList.launchIn(coroutineScope) + runTest(testDispatcher) { + repository.fingerprintMapList.launchIn(testScope) fingerprintMaps.emit(listOf(FingerprintMapEntity(id = FingerprintMapEntity.ID_SWIPE_DOWN))) @@ -87,8 +85,8 @@ class FingerprintMapRepositoryTest { @Test fun `no fingerprint maps in database, insert 4 blank fingerprint maps`() = - coroutineScope.runBlockingTest { - repository.fingerprintMapList.launchIn(coroutineScope) + runTest(testDispatcher) { + repository.fingerprintMapList.launchIn(testScope) fingerprintMaps.emit(emptyList()) @@ -102,7 +100,7 @@ class FingerprintMapRepositoryTest { @Test fun `fingerprint map with key event action from device and proper device name extra, do not update action device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = ActionEntity( type = ActionEntity.Type.KEY_EVENT, @@ -126,7 +124,7 @@ class FingerprintMapRepositoryTest { @Test fun `fingerprint map with key event action from device and blank device name extra, if device for action is disconnected, do not update action device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = ActionEntity( type = ActionEntity.Type.KEY_EVENT, @@ -150,7 +148,7 @@ class FingerprintMapRepositoryTest { @Test fun `fingerprint map with key event action from device and blank device name extra, if device for action is connected, update action device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = ActionEntity( type = ActionEntity.Type.KEY_EVENT, @@ -192,7 +190,7 @@ class FingerprintMapRepositoryTest { @Test fun `fingerprint map with key event action from device and no device name extra, if device for action is connected, update action device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = ActionEntity( type = ActionEntity.Type.KEY_EVENT, @@ -234,7 +232,7 @@ class FingerprintMapRepositoryTest { @Test fun `fingerprint map with key event action from device and no device name extra, if device for action is disconnected, update action device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = ActionEntity( type = ActionEntity.Type.KEY_EVENT, diff --git a/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt b/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt index 3270b5b785..9a390733d9 100644 --- a/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt @@ -11,10 +11,9 @@ import io.github.sds100.keymapper.system.devices.InputDeviceInfo import io.github.sds100.keymapper.util.State import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -45,9 +44,8 @@ class KeyMapRepositoryTest { ) } - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) private lateinit var repository: RoomKeyMapRepository private lateinit var devicesAdapter: FakeDevicesAdapter @@ -67,7 +65,7 @@ class KeyMapRepositoryTest { repository = RoomKeyMapRepository( mockDao, devicesAdapter, - coroutineScope, + testScope, dispatchers = TestDispatcherProvider(testDispatcher), ) } @@ -77,7 +75,7 @@ class KeyMapRepositoryTest { */ @Test fun `if modifying a huge number of key maps then split job into batches`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val keyMapList = sequence { repeat(991) { @@ -112,7 +110,7 @@ class KeyMapRepositoryTest { @Test fun `key map with key event action from device and proper device name extra, do not update action device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = ActionEntity( type = ActionEntity.Type.KEY_EVENT, @@ -136,7 +134,7 @@ class KeyMapRepositoryTest { @Test fun `key map with key event action from device and blank device name extra, if device for action is disconnected, do not update action device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = ActionEntity( type = ActionEntity.Type.KEY_EVENT, @@ -160,7 +158,7 @@ class KeyMapRepositoryTest { @Test fun `key map with key event action from device and blank device name extra, if device for action is connected, update action device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = ActionEntity( type = ActionEntity.Type.KEY_EVENT, @@ -195,7 +193,7 @@ class KeyMapRepositoryTest { @Test fun `key map with key event action from device and no device name extra, if device for action is connected, update action device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = ActionEntity( type = ActionEntity.Type.KEY_EVENT, @@ -230,7 +228,7 @@ class KeyMapRepositoryTest { @Test fun `key map with key event action from device and no device name extra, if device for action is disconnected, update action device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = ActionEntity( type = ActionEntity.Type.KEY_EVENT, @@ -254,7 +252,7 @@ class KeyMapRepositoryTest { @Test fun `key map with device name for trigger key, if device for trigger key is connected, do not update trigger key device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val triggerKey = TriggerEntity.KeyEntity( keyCode = 1, @@ -277,7 +275,7 @@ class KeyMapRepositoryTest { @Test fun `key map with device name for trigger key, if device for trigger key is disconnected, do not update trigger key device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val triggerKey = TriggerEntity.KeyEntity( keyCode = 1, @@ -298,7 +296,7 @@ class KeyMapRepositoryTest { @Test fun `key map with no device name for trigger key, if device for trigger key is connected, update trigger key device name`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val triggerKey = TriggerEntity.KeyEntity( keyCode = 1, diff --git a/app/src/test/java/io/github/sds100/keymapper/home/HomeMenuViewModelTest.kt b/app/src/test/java/io/github/sds100/keymapper/home/HomeMenuViewModelTest.kt deleted file mode 100644 index 002a4674d5..0000000000 --- a/app/src/test/java/io/github/sds100/keymapper/home/HomeMenuViewModelTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -package io.github.sds100.keymapper.home - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import io.github.sds100.keymapper.R -import io.github.sds100.keymapper.util.ui.FakeResourceProvider -import io.github.sds100.keymapper.util.ui.PopupUi -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest -import kotlinx.coroutines.withTimeout -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.`is` -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.mockito.kotlin.mock - -/** - * Created by sds100 on 29/04/2022. - */ -@ExperimentalCoroutinesApi -class HomeMenuViewModelTest { - - @get:Rule - var instantExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = TestCoroutineDispatcher() - private val testCoroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) - - private lateinit var fakeResourceProvider: FakeResourceProvider - private lateinit var viewModel: HomeMenuViewModel - - @Before - fun setUp() { - fakeResourceProvider = FakeResourceProvider() - viewModel = HomeMenuViewModel( - testCoroutineScope, - alertsUseCase = mock(), - pauseMappings = mock(), - showImePicker = mock(), - fakeResourceProvider, - ) - } - - @Test - fun onCreateDocumentActivityNotFound() = runBlockingTest { - // given - fakeResourceProvider.stringResourceMap[R.string.dialog_message_no_app_found_to_create_file] = "message" - fakeResourceProvider.stringResourceMap[R.string.pos_ok] = "ok" - - // when - viewModel.onCreateBackupFileActivityNotFound() - - // then - withTimeout(1000) { - val popupEvent = viewModel.showPopup.first() - assertThat(popupEvent.ui, `is`(PopupUi.Dialog(message = "message", positiveButtonText = "ok"))) - } - } - - @Test - fun onGetContentActivityNotFound() = runBlockingTest { - // given - fakeResourceProvider.stringResourceMap[R.string.dialog_message_no_app_found_to_choose_a_file] = "message" - fakeResourceProvider.stringResourceMap[R.string.pos_ok] = "ok" - - // when - viewModel.onChooseRestoreFileActivityNotFound() - - // then - withTimeout(1000) { - val popupEvent = viewModel.showPopup.first() - assertThat(popupEvent.ui, `is`(PopupUi.Dialog(message = "message", positiveButtonText = "ok"))) - } - } -} diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/SimpleMappingControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/SimpleMappingControllerTest.kt index 4e035540c8..1ae33aaaef 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/SimpleMappingControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/SimpleMappingControllerTest.kt @@ -9,12 +9,10 @@ import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase import junitparams.JUnitParamsRunner import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest -import org.junit.After +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -43,9 +41,8 @@ class SimpleMappingControllerTest { private const val HOLD_DOWN_DURATION = 1000L } - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) private lateinit var controller: SimpleMappingController private lateinit var detectMappingUseCase: DetectMappingUseCase @@ -90,24 +87,19 @@ class SimpleMappingControllerTest { } controller = FakeSimpleMappingController( - coroutineScope, + testScope, detectMappingUseCase, performActionsUseCase, detectConstraintsUseCase, ) } - @After - fun tearDown() { - coroutineScope.cleanupTestCoroutines() - } - /** * #663 */ @Test fun `action with repeat until limit reached shouldn't stop repeating when trigger is detected again`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = FakeAction( data = ActionData.InputKeyEvent(1), @@ -131,7 +123,7 @@ class SimpleMappingControllerTest { */ @Test fun `when triggering action that repeats until limit reached, then stop repeating when the limit has been reached`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = FakeAction( data = ActionData.InputKeyEvent(keyCode = 1), @@ -153,7 +145,7 @@ class SimpleMappingControllerTest { */ @Test fun `when triggering action that repeats until pressed again with repeat limit, then stop repeating when the trigger has been pressed again`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = FakeAction( data = ActionData.InputKeyEvent(keyCode = 1), @@ -180,7 +172,7 @@ class SimpleMappingControllerTest { */ @Test fun `when triggering action that repeats until pressed again with repeat limit, then stop repeating when limit reached and trigger hasn't been pressed again`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = FakeAction( data = ActionData.InputKeyEvent(keyCode = 1), diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModelTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModelTest.kt index 6c3be50b09..608eda705d 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModelTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModelTest.kt @@ -16,13 +16,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` -import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -38,9 +36,9 @@ import org.mockito.kotlin.mock @RunWith(MockitoJUnitRunner::class) class ConfigKeyMapTriggerViewModelTest { - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + private lateinit var viewModel: ConfigKeyMapTriggerViewModel private lateinit var mockConfigKeyMapUseCase: ConfigKeyMapUseCase private lateinit var mockRecordTrigger: RecordTriggerUseCase @@ -69,7 +67,7 @@ class ConfigKeyMapTriggerViewModelTest { fakeResourceProvider = FakeResourceProvider() viewModel = ConfigKeyMapTriggerViewModel( - coroutineScope, + testScope, fakeOnboarding, mockConfigKeyMapUseCase, mockRecordTrigger, @@ -83,17 +81,12 @@ class ConfigKeyMapTriggerViewModelTest { ) } - @After - fun tearDown() { - coroutineScope.cleanupTestCoroutines() - } - /** * issue #602 */ @Test fun `when create back button trigger key then prompt the user to disable screen pinning`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN fakeResourceProvider.stringResourceMap[R.string.dialog_message_screen_pinning_warning] = "bla" diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt index 547ae25749..92c8c044f0 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt @@ -30,15 +30,13 @@ import junitparams.naming.TestCaseName import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.createTestCoroutineScope import kotlinx.coroutines.test.currentTime -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` -import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -107,9 +105,8 @@ class KeyMapControllerTest { @get:Rule var instantExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) @Before fun init() { @@ -139,7 +136,7 @@ class KeyMapControllerTest { } } - whenever(detectKeyMapsUseCase.currentTime).thenAnswer { coroutineScope.currentTime } + whenever(detectKeyMapsUseCase.currentTime).thenAnswer { testScope.currentTime } performActionsUseCase = mock { MutableStateFlow(REPEAT_DELAY).apply { @@ -160,21 +157,16 @@ class KeyMapControllerTest { } controller = KeyMapController( - coroutineScope, + testScope, detectKeyMapsUseCase, performActionsUseCase, detectConstraintsUseCase, ) } - @After - fun tearDown() { - coroutineScope.cleanupTestCoroutines() - } - @Test fun `Don't imitate button if 1 long press trigger is successful and another with a longer delay fails`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val longerTrigger = @@ -250,7 +242,7 @@ class KeyMapControllerTest { */ @Test fun `Long press trigger shouldn't be triggered if the constraints are changed by the actions`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val actionData = ActionData.Flashlight.Toggle(CameraLens.BACK) @@ -303,7 +295,7 @@ class KeyMapControllerTest { */ @Test fun `multiple key maps with the same long press trigger but different long press delays should all work`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val keyMap1 = KeyMap( trigger = KeyMapTrigger( @@ -362,7 +354,7 @@ class KeyMapControllerTest { */ @Test fun `don't consume down and up event if no valid actions to perform`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val trigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) val actionList = listOf(KeyMapAction(data = ActionData.InputKeyEvent(2))) @@ -387,7 +379,7 @@ class KeyMapControllerTest { * #689 */ @Test - fun `perform all actions once when key map is triggered`() = coroutineScope.runBlockingTest { + fun `perform all actions once when key map is triggered`() = runTest(testDispatcher) { // GIVEN val trigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) @@ -415,7 +407,7 @@ class KeyMapControllerTest { */ @Test fun `action with repeat until limit reached shouldn't stop repeating when trigger is released`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val trigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) @@ -445,7 +437,7 @@ class KeyMapControllerTest { @Test fun `key map with multiple actions and delay in between, perform all actions even when trigger is released`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val trigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) @@ -487,7 +479,7 @@ class KeyMapControllerTest { @Test fun `multiple key maps with same trigger, perform both key maps`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val trigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) @@ -525,7 +517,7 @@ class KeyMapControllerTest { */ @Test fun `when triggering action that repeats until limit reached, then stop repeating when the limit has been reached and not when the trigger is released`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = KeyMapAction( data = ActionData.InputKeyEvent(keyCode = 1), @@ -554,7 +546,7 @@ class KeyMapControllerTest { */ @Test fun `when triggering action that repeats until pressed again with repeat limit, then stop repeating when the trigger has been pressed again`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = KeyMapAction( data = ActionData.InputKeyEvent(keyCode = 1), @@ -589,7 +581,7 @@ class KeyMapControllerTest { */ @Test fun `when triggering action that repeats until pressed again with repeat limit, then stop repeating when limit reached and trigger hasn't been pressed again`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = KeyMapAction( data = ActionData.InputKeyEvent(keyCode = 1), @@ -623,7 +615,7 @@ class KeyMapControllerTest { */ @Test fun `when triggering action that repeats until released with repeat limit, then stop repeating when the trigger has been released`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = KeyMapAction( data = ActionData.InputKeyEvent(keyCode = 1), @@ -653,7 +645,7 @@ class KeyMapControllerTest { */ @Test fun `when triggering action that repeats until released with repeat limit, then stop repeating when the limit has been reached and the action is still being held down`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = KeyMapAction( data = ActionData.InputKeyEvent(keyCode = 1), @@ -682,7 +674,7 @@ class KeyMapControllerTest { * issue #653 */ @Test - fun `overlapping triggers 3`() = coroutineScope.runBlockingTest { + fun `overlapping triggers 3`() = runTest(testDispatcher) { // GIVEN val keyMaps = listOf( KeyMap( @@ -750,7 +742,7 @@ class KeyMapControllerTest { * issue #653 */ @Test - fun `overlapping triggers 2`() = coroutineScope.runBlockingTest { + fun `overlapping triggers 2`() = runTest(testDispatcher) { // GIVEN val keyMaps = listOf( KeyMap( @@ -811,7 +803,7 @@ class KeyMapControllerTest { * issue #653 */ @Test - fun `overlapping triggers 1`() = coroutineScope.runBlockingTest { + fun `overlapping triggers 1`() = runTest(testDispatcher) { // GIVEN val keyMaps = listOf( KeyMap( @@ -908,7 +900,7 @@ class KeyMapControllerTest { */ @Test fun `imitate button presses when a short press trigger with multiple keys fails`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val trigger = parallelTrigger( triggerKey(keyCode = 1), @@ -968,7 +960,7 @@ class KeyMapControllerTest { */ @Test fun `don't imitate button press when a short press trigger is triggered`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val trigger = parallelTrigger( triggerKey(keyCode = 1), @@ -998,7 +990,7 @@ class KeyMapControllerTest { */ @Test fun `don't repeat when trigger is released for an action that has these options when the trigger is held down`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val action = KeyMapAction( data = ActionData.InputKeyEvent(keyCode = 1), @@ -1019,7 +1011,7 @@ class KeyMapControllerTest { mockTriggerKeyInput(triggerKey(keyCode = 2), delay = 1) // see if the action repeats - coroutineScope.testScheduler.apply { + testScope.testScheduler.apply { advanceTimeBy(500) runCurrent() } @@ -1042,7 +1034,7 @@ class KeyMapControllerTest { */ @Test fun `don't initialise repeating if repeat when trigger is released after failed long press`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val trigger1 = parallelTrigger(triggerKey(keyCode = 1)) val action1 = KeyMapAction( @@ -1092,7 +1084,7 @@ class KeyMapControllerTest { */ @Test fun `don't initialise repeating if repeat when trigger is released after failed failed double press`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val trigger1 = parallelTrigger(triggerKey(keyCode = 1)) val action1 = KeyMapAction( @@ -1141,7 +1133,7 @@ class KeyMapControllerTest { */ @Test fun `don't initialise repeating if repeat when trigger is released after failed double press and failed long press`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val trigger1 = parallelTrigger(triggerKey(keyCode = 1)) val action1 = KeyMapAction( @@ -1202,7 +1194,7 @@ class KeyMapControllerTest { */ @Test fun `initialise repeating if repeat until pressed again on failed long press`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val trigger1 = parallelTrigger(triggerKey(keyCode = 1)) val action1 = KeyMapAction( @@ -1252,7 +1244,7 @@ class KeyMapControllerTest { */ @Test fun `initialise repeating if repeat until pressed again on failed double press`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val trigger1 = parallelTrigger(triggerKey(keyCode = 1)) val action1 = KeyMapAction( @@ -1307,7 +1299,7 @@ class KeyMapControllerTest { */ @Test fun `initialise repeating if repeat until pressed again on failed double press and failed long press`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val trigger1 = parallelTrigger(triggerKey(keyCode = 1)) val action1 = KeyMapAction( @@ -1368,7 +1360,7 @@ class KeyMapControllerTest { */ @Test fun `short press key and double press same key sequence trigger, double press key, don't perform action`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { val trigger = sequenceTrigger( triggerKey(KeyEvent.KEYCODE_A), triggerKey(KeyEvent.KEYCODE_A, clickType = ClickType.DOUBLE_PRESS), @@ -1392,7 +1384,7 @@ class KeyMapControllerTest { * issue #563 */ @Test - fun sendKeyEventActionWhenImitatingButtonPresses() = coroutineScope.runBlockingTest { + fun sendKeyEventActionWhenImitatingButtonPresses() = runTest(testDispatcher) { val trigger = singleKeyTrigger( triggerKey( keyCode = KeyEvent.KEYCODE_META_LEFT, @@ -1553,7 +1545,7 @@ class KeyMapControllerTest { @Test fun `parallel trigger with 2 keys and the 2nd key is another trigger, press 2 key trigger, only the action for 2 key trigger should be performed `() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val twoKeyTrigger = parallelTrigger( triggerKey(KeyEvent.KEYCODE_SHIFT_LEFT), @@ -1596,7 +1588,7 @@ class KeyMapControllerTest { @Test fun `trigger for a specific device and trigger for any device, input trigger from a different device, only detect trigger for any device`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val triggerKeyboard = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_A, FAKE_KEYBOARD_TRIGGER_KEY_DEVICE), @@ -1623,7 +1615,7 @@ class KeyMapControllerTest { @Test fun `trigger for a specific device, input trigger from a different device, do not detect trigger`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val triggerHeadphone = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_A, FAKE_HEADPHONE_TRIGGER_KEY_DEVICE), @@ -1642,7 +1634,7 @@ class KeyMapControllerTest { @Test fun `long press trigger and action with Hold Down until pressed again flag, input valid long press, hold down until long pressed again`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val trigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_A, clickType = ClickType.LONG_PRESS)) @@ -1684,7 +1676,7 @@ class KeyMapControllerTest { */ @Test fun `trigger with modifier key and modifier keycode action, don't include metastate from the trigger modifier key when an unmapped modifier key is pressed`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { val trigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_CTRL_LEFT)) keyMapListFlow.value = listOf( @@ -1747,7 +1739,7 @@ class KeyMapControllerTest { @Test fun `2x key sequence trigger and 3x key sequence trigger with the last 2 keys being the same, trigger 3x key trigger, ignore the first 2x key trigger`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { val firstTrigger = sequenceTrigger( triggerKey( KeyEvent.KEYCODE_VOLUME_DOWN, @@ -1784,7 +1776,7 @@ class KeyMapControllerTest { @Test fun `2x key long press parallel trigger with HOME or RECENTS keycode, trigger successfully, don't do normal action`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { /* HOME */ @@ -1835,7 +1827,7 @@ class KeyMapControllerTest { @Test fun shortPressTriggerDoublePressTrigger_holdDown_onlyDetectDoublePressTrigger() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val shortPressTrigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) val doublePressTrigger = singleKeyTrigger( @@ -1873,7 +1865,7 @@ class KeyMapControllerTest { @Test fun shortPressTriggerLongPressTrigger_holdDown_onlyDetectLongPressTrigger() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN val shortPressTrigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) val longPressTrigger = singleKeyTrigger( @@ -1909,7 +1901,7 @@ class KeyMapControllerTest { @Test @Parameters(method = "params_repeatAction") fun parallelTrigger_holdDown_repeatAction10Times(description: String, trigger: KeyMapTrigger) = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val action = KeyMapAction( data = ActionData.Volume.Up(showVolumeUi = false), @@ -1957,12 +1949,12 @@ class KeyMapControllerTest { ) @Test - @Parameters(method = "params_dualParallelTrigger_input2ndKey_do notConsumeUp") + @Parameters(method = "params_dualParallelTrigger_input2ndKey_doNotConsumeUp") fun dualParallelTrigger_input2ndKey_doNotConsumeUp( description: String, trigger: KeyMapTrigger, ) = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given keyMapListFlow.value = listOf(KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION))) @@ -2007,7 +1999,7 @@ class KeyMapControllerTest { ) @Test - fun dualShortPressParallelTrigger_validInput_consumeUp() = coroutineScope.runBlockingTest { + fun dualShortPressParallelTrigger_validInput_consumeUp() = runTest(testDispatcher) { // given val trigger = parallelTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), @@ -2046,7 +2038,7 @@ class KeyMapControllerTest { } @Test - fun dualLongPressParallelTrigger_validInput_consumeUp() = coroutineScope.runBlockingTest { + fun dualLongPressParallelTrigger_validInput_consumeUp() = runTest(testDispatcher) { // given val trigger = parallelTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.LONG_PRESS), @@ -2088,7 +2080,7 @@ class KeyMapControllerTest { @Test fun keymappedToLongPressAndDoublePress_invalidLongPress_imitateOnce() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val longPressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.LONG_PRESS), @@ -2119,7 +2111,7 @@ class KeyMapControllerTest { @Test fun keymappedToSingleShortPressAndLongPress_validShortPress_onlyPerformActiondoNotImitateKey() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val shortPressTrigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) @@ -2149,7 +2141,7 @@ class KeyMapControllerTest { @Test fun keymappedToShortPressAndDoublePress_validShortPress_onlyPerformActionDoNotImitateKey() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val shortPressTrigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) @@ -2181,7 +2173,7 @@ class KeyMapControllerTest { @Test fun singleKeyTriggerAndShortPressParallelTriggerWithSameInitialKey_validSingleKeyTriggerInput_onlyPerformActiondoNotImitateKey() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // given val singleKeyTrigger = singleKeyTrigger(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)) val parallelTrigger = parallelTrigger( @@ -2209,7 +2201,7 @@ class KeyMapControllerTest { } @Test - fun longPressSequenceTrigger_invalidLongPress_keyImitated() = coroutineScope.runBlockingTest { + fun longPressSequenceTrigger_invalidLongPress_keyImitated() = runTest(testDispatcher) { val trigger = sequenceTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.LONG_PRESS), triggerKey(KeyEvent.KEYCODE_VOLUME_UP), @@ -2233,7 +2225,7 @@ class KeyMapControllerTest { @Test @Parameters(method = "params_multipleActionsPerformed") fun validInput_multipleActionsPerformed(description: String, trigger: KeyMapTrigger) = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { val actionList = listOf(TEST_ACTION, TEST_ACTION_2) // GIVEN keyMapListFlow.value = listOf( @@ -2287,7 +2279,7 @@ class KeyMapControllerTest { @Parameters(method = "params_allTriggerKeyCombinations") @TestCaseName("{0}") fun invalidInput_downNotConsumed(description: String, keyMap: KeyMap) = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN keyMapListFlow.value = listOf(keyMap) @@ -2315,7 +2307,7 @@ class KeyMapControllerTest { @Parameters(method = "params_allTriggerKeyCombinations") @TestCaseName("{0}") fun validInput_downConsumed(description: String, keyMap: KeyMap) = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN keyMapListFlow.value = listOf(keyMap) @@ -2338,10 +2330,10 @@ class KeyMapControllerTest { } @Test - @Parameters(method = "params_allTriggerKeyCombinationsdo notConsume") + @Parameters(method = "params_allTriggerKeyCombinationsdoNotConsume") @TestCaseName("{0}") fun validInput_doNotConsumeFlag_doNotConsumeDown(description: String, keyMap: KeyMap) = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { keyMapListFlow.value = listOf(keyMap) var consumedCount = 0 @@ -3119,7 +3111,7 @@ class KeyMapControllerTest { @Parameters(method = "params_allTriggerKeyCombinations") @TestCaseName("{0}") fun validInput_actionPerformed(description: String, keyMap: KeyMap) = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN keyMapListFlow.value = listOf(keyMap) diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt index 7bee039558..429e26b30f 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt @@ -11,12 +11,10 @@ import junitparams.JUnitParamsRunner import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest -import org.junit.After +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -44,9 +42,8 @@ class TriggerKeyMapFromOtherAppsControllerTest { private const val HOLD_DOWN_DURATION = 1000L } - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) private lateinit var controller: TriggerKeyMapFromOtherAppsController private lateinit var detectKeyMapsUseCase: DetectKeyMapsUseCase @@ -96,42 +93,41 @@ class TriggerKeyMapFromOtherAppsControllerTest { } controller = TriggerKeyMapFromOtherAppsController( - coroutineScope, + testScope, detectKeyMapsUseCase, performActionsUseCase, detectConstraintsUseCase, ) } - @After - fun tearDown() { - coroutineScope.cleanupTestCoroutines() - } - /** * #707 */ @Test - fun `Key map with repeat option, don't repeat when triggered if repeat until released`() = coroutineScope.runBlockingTest { - // GIVEN - val action = - KeyMapAction( - data = ActionData.InputKeyEvent(keyCode = 1), - repeat = true, - repeatMode = RepeatMode.TRIGGER_RELEASED, + fun `Key map with repeat option, don't repeat when triggered if repeat until released`() = + runTest(testDispatcher) { + // GIVEN + val action = + KeyMapAction( + data = ActionData.InputKeyEvent(keyCode = 1), + repeat = true, + repeatMode = RepeatMode.TRIGGER_RELEASED, + ) + val keyMap = KeyMap( + actionList = listOf(action), + trigger = KeyMapTrigger(triggerFromOtherApps = true), ) - val keyMap = KeyMap(actionList = listOf(action), trigger = KeyMapTrigger(triggerFromOtherApps = true)) - keyMapListFlow.value = listOf(keyMap) + keyMapListFlow.value = listOf(keyMap) - advanceUntilIdle() + advanceUntilIdle() - // WHEN - controller.onDetected(keyMap.uid) - delay(500) - controller.reset() // stop any repeating that might be happening - advanceUntilIdle() + // WHEN + controller.onDetected(keyMap.uid) + delay(500) + controller.reset() // stop any repeating that might be happening + advanceUntilIdle() - // THEN - verify(performActionsUseCase, times(1)).perform(action.data) - } + // THEN + verify(performActionsUseCase, times(1)).perform(action.data) + } } diff --git a/app/src/test/java/io/github/sds100/keymapper/onboarding/OnboardingUseCaseTest.kt b/app/src/test/java/io/github/sds100/keymapper/onboarding/OnboardingUseCaseTest.kt index 7c1127eab1..84ad35b46e 100644 --- a/app/src/test/java/io/github/sds100/keymapper/onboarding/OnboardingUseCaseTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/onboarding/OnboardingUseCaseTest.kt @@ -6,11 +6,10 @@ import io.github.sds100.keymapper.data.repositories.FakePreferenceRepository import io.github.sds100.keymapper.util.VersionHelper import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestCoroutineExceptionHandler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.createTestCoroutineScope -import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` import org.junit.Before @@ -27,9 +26,8 @@ import org.mockito.kotlin.mock @RunWith(MockitoJUnitRunner::class) class OnboardingUseCaseTest { - private val testDispatcher = TestCoroutineDispatcher() - private val coroutineScope = - createTestCoroutineScope(TestCoroutineDispatcher() + TestCoroutineExceptionHandler() + testDispatcher) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) private lateinit var useCase: OnboardingUseCaseImpl private lateinit var fakePreferences: FakePreferenceRepository @@ -52,7 +50,7 @@ class OnboardingUseCaseTest { */ @Test fun `Only show fingerprint map feature notification for the first update only`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // show it when updating from a version that didn't support it to a version that does // GIVEN fakePreferences.set(Keys.approvedFingerprintFeaturePrompt, false) @@ -100,7 +98,7 @@ class OnboardingUseCaseTest { @Test fun `update to 2_3_0, no bluetooth devices were chosen in settings, do not show notification to choose devices again`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN fakePreferences.set( stringSetPreferencesKey("pref_bluetooth_devices_show_ime_picker"), @@ -120,7 +118,7 @@ class OnboardingUseCaseTest { @Test fun `update to 2_3_0, bluetooth devices were chosen in settings, show notification to choose devices again`() = - coroutineScope.runBlockingTest { + runTest(testDispatcher) { // GIVEN fakePreferences.set( stringSetPreferencesKey("pref_bluetooth_devices_show_ime_picker"), diff --git a/app/src/test/java/io/github/sds100/keymapper/system/intents/ConfigIntentViewModelTest.kt b/app/src/test/java/io/github/sds100/keymapper/system/intents/ConfigIntentViewModelTest.kt index e44012ab60..627ff1d7d9 100644 --- a/app/src/test/java/io/github/sds100/keymapper/system/intents/ConfigIntentViewModelTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/system/intents/ConfigIntentViewModelTest.kt @@ -3,9 +3,14 @@ package io.github.sds100.keymapper.system.intents import android.content.Intent import androidx.arch.core.executor.testing.InstantTaskExecutorRule import io.github.sds100.keymapper.util.firstBlocking +import io.github.sds100.keymapper.util.ui.FakeResourceProvider +import io.github.sds100.keymapper.util.ui.MultiChoiceItem +import io.github.sds100.keymapper.util.ui.PopupUi +import io.github.sds100.keymapper.util.ui.ShowPopupEvent +import io.github.sds100.keymapper.util.ui.onUserResponse import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.setMain import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.hasItem @@ -22,7 +27,7 @@ internal class ConfigIntentViewModelTest { @get:Rule var instantExecutorRule = InstantTaskExecutorRule() - private val testDispatcher = TestCoroutineDispatcher() + private val testDispatcher = UnconfinedTestDispatcher() private lateinit var fakeResourceProvider: FakeResourceProvider private lateinit var viewModel: ConfigIntentViewModel @@ -53,7 +58,8 @@ internal class ConfigIntentViewModelTest { viewModel.showFlagsDialog() val popupEvent: ShowPopupEvent = viewModel.showPopup.firstBlocking() val multipleChoiceDialog = popupEvent.ui as PopupUi.MultiChoice<*> - val expectedCheckedItem = MultiChoiceItem(Intent.FLAG_ACTIVITY_NEW_TASK, "FLAG_ACTIVITY_NEW_TASK", true) + val expectedCheckedItem = + MultiChoiceItem(Intent.FLAG_ACTIVITY_NEW_TASK, "FLAG_ACTIVITY_NEW_TASK", true) assertThat(multipleChoiceDialog.items, hasItem(expectedCheckedItem)) } diff --git a/app/src/test/resources/backup-manager-test/restore-keymaps-no-db-version.json b/app/src/test/resources/backup-manager-test/restore-keymaps-no-db-version.json index 3964dac5e4..b343a2bbdd 100644 --- a/app/src/test/resources/backup-manager-test/restore-keymaps-no-db-version.json +++ b/app/src/test/resources/backup-manager-test/restore-keymaps-no-db-version.json @@ -14,6 +14,7 @@ "constraintMode": 1, "flags": 0, "id": 0, + "uid": "uid1", "isEnabled": true, "trigger": { "extras": [], @@ -42,6 +43,7 @@ "constraintMode": 1, "flags": 0, "id": 0, + "uid": "uid2", "isEnabled": true, "trigger": { "extras": [], From b18a2cb1ca0a5f22a119ddeb67b09a0e55864748 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 29 Sep 2024 18:48:52 +0200 Subject: [PATCH 038/118] use Java 17 for unit tests in github actions --- .github/workflows/production.yml | 7 +++++++ .github/workflows/pull-request.yml | 7 +++++++ .github/workflows/testing.yml | 7 +++++++ 3 files changed, 21 insertions(+) diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index bb0a1c7d36..9173a48fde 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -16,6 +16,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'oracle' + java-version: 17 + cache: 'gradle' + - name: Setup Android SDK uses: android-actions/setup-android@v2 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 959d3d6eda..8c1ab5cfbd 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -10,6 +10,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'oracle' + java-version: 17 + cache: 'gradle' + - name: Setup Android SDK uses: android-actions/setup-android@v2 diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 924139d7d2..6e707b4fbf 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -16,6 +16,13 @@ jobs: steps: - uses: actions/checkout@v4 + - name: set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'oracle' + java-version: 17 + cache: 'gradle' + - name: Setup Android SDK uses: android-actions/setup-android@v2 From b7dcace54cbf88b96a9360c25a94d5b9d0f09a5b Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 1 Oct 2024 21:59:15 +0200 Subject: [PATCH 039/118] #1271 fix: ignore double press key maps overlapping short press key maps if the constraints aren't satisfied --- .../constraints/ConstraintSnapshot.kt | 33 ++-- .../mappings/SimpleMappingController.kt | 1 + .../keymaps/detection/KeyMapController.kt | 173 +++++++++--------- .../mappings/keymaps/KeyMapControllerTest.kt | 87 +++++++++ 4 files changed, 198 insertions(+), 96 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintSnapshot.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintSnapshot.kt index 2bfb975d49..b136dec7b2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintSnapshot.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/ConstraintSnapshot.kt @@ -55,18 +55,7 @@ class ConstraintSnapshotImpl( } } - override fun isSatisfied(constraintState: ConstraintState): Boolean = - when (constraintState.mode) { - ConstraintMode.AND -> { - constraintState.constraints.all { isSatisfied(it) } - } - - ConstraintMode.OR -> { - constraintState.constraints.any { isSatisfied(it) } - } - } - - private fun isSatisfied(constraint: Constraint): Boolean { + override fun isSatisfied(constraint: Constraint): Boolean { val isSatisfied = when (constraint) { is Constraint.AppInForeground -> appInForeground == constraint.packageName is Constraint.AppNotInForeground -> appInForeground != constraint.packageName @@ -98,7 +87,6 @@ class ConstraintSnapshotImpl( is Constraint.FlashlightOff -> !cameraAdapter.isFlashlightOn(constraint.lens) is Constraint.FlashlightOn -> cameraAdapter.isFlashlightOn(constraint.lens) is Constraint.WifiConnected -> { - Timber.d("Connected WiFi ssid = $connectedWifiSSID") if (constraint.ssid == null) { // connected to any network connectedWifiSSID != null @@ -139,5 +127,22 @@ class ConstraintSnapshotImpl( } interface ConstraintSnapshot { - fun isSatisfied(constraintState: ConstraintState): Boolean + fun isSatisfied(constraint: Constraint): Boolean +} + +fun ConstraintSnapshot.isSatisfied(constraintState: ConstraintState): Boolean { + // Required in case OR is used with empty list of constraints. + if (constraintState.constraints.isEmpty()) { + return true + } + + return when (constraintState.mode) { + ConstraintMode.AND -> { + constraintState.constraints.all { isSatisfied(it) } + } + + ConstraintMode.OR -> { + constraintState.constraints.any { isSatisfied(it) } + } + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/SimpleMappingController.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/SimpleMappingController.kt index 7753ee2a19..212149d0f3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/SimpleMappingController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/SimpleMappingController.kt @@ -4,6 +4,7 @@ import io.github.sds100.keymapper.actions.Action import io.github.sds100.keymapper.actions.PerformActionsUseCase import io.github.sds100.keymapper.actions.RepeatMode import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase +import io.github.sds100.keymapper.constraints.isSatisfied import io.github.sds100.keymapper.data.PreferenceDefaults import io.github.sds100.keymapper.util.InputEventType import kotlinx.coroutines.CoroutineScope diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt index 39e497cebd..48ea2cb6bf 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt @@ -9,6 +9,7 @@ import io.github.sds100.keymapper.actions.PerformActionsUseCase import io.github.sds100.keymapper.constraints.ConstraintSnapshot import io.github.sds100.keymapper.constraints.ConstraintState import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase +import io.github.sds100.keymapper.constraints.isSatisfied import io.github.sds100.keymapper.data.PreferenceDefaults import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.mappings.ClickType @@ -324,7 +325,7 @@ class KeyMapController( } } - parallelTriggers.forEach { triggerIndex -> + for (triggerIndex in parallelTriggers) { val trigger = triggers[triggerIndex] trigger.keys.forEachIndexed { keyIndex, key -> @@ -385,13 +386,13 @@ class KeyMapController( /** * All sequence events that have the long press click type. */ - private var longPressSequenceTriggerKeys: Array = arrayOf() + private var longPressSequenceTriggerKeys: Array = arrayOf() /** * All double press keys and the index of their corresponding trigger. first is the event and second is * the trigger index. */ - private var doublePressTriggerKeys: Array = arrayOf() + private var doublePressTriggerKeys: Array = arrayOf() /** * order matches with [doublePressTriggerKeys] @@ -405,7 +406,7 @@ class KeyMapController( */ private var doublePressTimeoutTimes = longArrayOf() - private var actionMap = SparseArrayCompat() + private var actionMap: SparseArrayCompat = SparseArrayCompat() private var triggers: Array = emptyArray() /** @@ -424,9 +425,9 @@ class KeyMapController( /** * The indexes of triggers that overlap after the first element with each trigger in [sequenceTriggers] */ - private var sequenceTriggersOverlappingSequenceTriggers = arrayOf() + private var sequenceTriggersOverlappingSequenceTriggers: Array = arrayOf() - private var sequenceTriggersOverlappingParallelTriggers = arrayOf() + private var sequenceTriggersOverlappingParallelTriggers: Array = arrayOf() /** * An array of the index of the last matched event in each trigger. @@ -436,7 +437,7 @@ class KeyMapController( /** * An array of the constraints for every trigger */ - private var triggerConstraints: Array = arrayOf() + private var triggerConstraints: Array = arrayOf() /** * The events to detect for each parallel trigger. @@ -447,7 +448,7 @@ class KeyMapController( * The actions to perform when each trigger is detected. The order matches with * [triggers]. */ - private var triggerActions: Array = arrayOf() + private var triggerActions: Array = arrayOf() /** * Stores whether each event in each parallel trigger need to be released after being held down. @@ -612,38 +613,59 @@ class KeyMapController( val constraintSnapshot: ConstraintSnapshot by lazy { detectConstraints.getSnapshot() } + /** + * Store which triggers are currently satisfied by the constraints. + * This is used to check later on whether to wait for a double press to complete + * before executing a short press. See issue #1271. + */ + val triggersSatisfiedByConstraints = mutableSetOf() + + for (triggerIndex in parallelTriggers.plus(sequenceTriggers)) { + val constraintState = triggerConstraints[triggerIndex] + + if (constraintSnapshot.isSatisfied(constraintState)) { + triggersSatisfiedByConstraints.add(triggerIndex) + } + } + // consume sequence trigger keys until their timeout has been reached - for (sequenceTriggerIndex in sequenceTriggers) { - val timeoutTime = sequenceTriggersTimeoutTimes[sequenceTriggerIndex] ?: -1 - val trigger = triggers[sequenceTriggerIndex] - val constraintState = triggerConstraints[sequenceTriggerIndex] + for (triggerIndex in sequenceTriggers) { + val timeoutTime = sequenceTriggersTimeoutTimes[triggerIndex] ?: -1 - if (constraintState.constraints.isNotEmpty()) { - if (!constraintSnapshot.isSatisfied(constraintState)) continue + if (!triggersSatisfiedByConstraints.contains(triggerIndex)) { + continue } if (timeoutTime != -1L && currentTime >= timeoutTime) { - lastMatchedEventIndices[sequenceTriggerIndex] = -1 - sequenceTriggersTimeoutTimes[sequenceTriggerIndex] = -1 + lastMatchedEventIndices[triggerIndex] = -1 + sequenceTriggersTimeoutTimes[triggerIndex] = -1 } else { + val triggerKeys = triggers[triggerIndex].keys + // consume the event if the trigger contains this keycode. - trigger.keys.forEachIndexed { keyIndex, key -> - if (key.keyCode == event.keyCode && trigger.keys[keyIndex].consumeKeyEvent) { + for ((keyIndex, key) in triggerKeys.withIndex()) { + if (key.keyCode == event.keyCode && triggerKeys[keyIndex].consumeKeyEvent) { consumeEvent = true } } } } - doublePressTimeoutTimes.forEachIndexed { doublePressEventIndex, timeoutTime -> + for ((doublePressEventIndex, timeoutTime) in doublePressTimeoutTimes.withIndex()) { if (currentTime >= timeoutTime) { doublePressTimeoutTimes[doublePressEventIndex] = -1 doublePressEventStates[doublePressEventIndex] = NOT_PRESSED } else { val eventLocation = doublePressTriggerKeys[doublePressEventIndex] + val triggerIndex = eventLocation.triggerIndex + + // Ignore this double press trigger if the constraint isn't satisfied. + if (!triggersSatisfiedByConstraints.contains(triggerIndex)) { + continue + } + val doublePressEvent = triggers[eventLocation.triggerIndex].keys[eventLocation.keyIndex] - val triggerIndex = eventLocation.triggerIndex triggers[triggerIndex].keys.forEachIndexed { eventIndex, event -> if (event == doublePressEvent && @@ -669,16 +691,13 @@ class KeyMapController( Otherwise the order of the key maps affects the logic. */ triggerLoop@ for (triggerIndex in parallelTriggers) { - val trigger = triggers[triggerIndex] - val lastMatchedIndex = lastMatchedEventIndices[triggerIndex] + if (!triggersSatisfiedByConstraints.contains(triggerIndex)) { + continue + } - val constraintState = triggerConstraints[triggerIndex] + val trigger = triggers[triggerIndex] - if (constraintState.constraints.isNotEmpty()) { - if (!constraintSnapshot.isSatisfied(constraintState)) { - continue - } - } + val lastMatchedIndex = lastMatchedEventIndices[triggerIndex] for (actionKey in triggerActions[triggerIndex]) { if (canActionBePerformed[actionKey] == null) { @@ -701,26 +720,22 @@ class KeyMapController( val nextIndex = lastMatchedIndex + 1 - if (trigger.matchingEventAtIndex( - event.withShortPress, - nextIndex, - ) - ) { + if (trigger.matchingEventAtIndex(event.withShortPress, nextIndex)) { lastMatchedEventIndices[triggerIndex] = nextIndex parallelTriggerEventsAwaitingRelease[triggerIndex][nextIndex] = true } - if (trigger.matchingEventAtIndex( - event.withLongPress, - nextIndex, - ) - ) { + if (trigger.matchingEventAtIndex(event.withLongPress, nextIndex)) { lastMatchedEventIndices[triggerIndex] = nextIndex parallelTriggerEventsAwaitingRelease[triggerIndex][nextIndex] = true } } triggerLoop@ for (triggerIndex in parallelTriggers) { + if (!triggersSatisfiedByConstraints.contains(triggerIndex)) { + continue + } + val trigger = triggers[triggerIndex] val lastMatchedIndex = lastMatchedEventIndices[triggerIndex] @@ -741,11 +756,7 @@ class KeyMapController( } // Perform short press action - if (trigger.matchingEventAtIndex( - event.withShortPress, - lastMatchedIndex, - ) - ) { + if (trigger.matchingEventAtIndex(event.withShortPress, lastMatchedIndex)) { if (trigger.keys[lastMatchedIndex].consumeKeyEvent) { consumeEvent = true } @@ -773,10 +784,7 @@ class KeyMapController( detectedShortPressTriggers.add(triggerIndex) val vibrateDuration = when { - trigger.vibrate -> { - vibrateDuration(trigger) - } - + trigger.vibrate -> vibrateDuration(trigger) forceVibrate.value -> defaultVibrateDuration.value else -> -1L } @@ -787,11 +795,7 @@ class KeyMapController( } // Perform long press action - if (trigger.matchingEventAtIndex( - event.withLongPress, - lastMatchedIndex, - ) - ) { + if (trigger.matchingEventAtIndex(event.withLongPress, lastMatchedIndex)) { if (trigger.keys[lastMatchedIndex].consumeKeyEvent) { consumeEvent = true } @@ -799,8 +803,7 @@ class KeyMapController( if (lastMatchedIndex == trigger.keys.lastIndex) { awaitingLongPress = true - if (trigger.longPressDoubleVibration - ) { + if (trigger.longPressDoubleVibration) { useCase.vibrate(vibrateDuration(trigger)) } @@ -836,8 +839,16 @@ class KeyMapController( } if (detectedShortPressTriggers.isNotEmpty()) { - val matchingDoublePressEvent = doublePressTriggerKeys.any { - triggers[it.triggerIndex].keys[it.keyIndex].matchesEvent(event.withDoublePress) + val matchingDoublePressEvent = doublePressTriggerKeys.any { keyLocation -> + // See issue #1271. Only consider the double press triggers that overlap + // if the constraints allow it. + + if (!triggersSatisfiedByConstraints.contains(keyLocation.triggerIndex)) { + return@any false + } + + val key = triggers[keyLocation.triggerIndex].keys[keyLocation.keyIndex] + key.matchesEvent(event.withDoublePress) } /* to prevent the actions of keys mapped to a short press and, a long press or a double press @@ -852,16 +863,17 @@ class KeyMapController( performActionsOnFailedLongPress.addAll(detectedShortPressTriggers) } - else -> detectedShortPressTriggers.forEach { triggerIndex -> + else -> { + for (triggerIndex in detectedShortPressTriggers) { + if (triggers[triggerIndex].showToast) { + showToast = true + } - if (triggers[triggerIndex].showToast) { - showToast = true + parallelTriggerActionPerformers[triggerIndex]?.onTriggered( + calledOnTriggerRelease = false, + metaState = metaStateFromKeyEvent.withFlag(metaStateFromActions), + ) } - - parallelTriggerActionPerformers[triggerIndex]?.onTriggered( - calledOnTriggerRelease = false, - metaState = metaStateFromKeyEvent.withFlag(metaStateFromActions), - ) } } } @@ -882,13 +894,14 @@ class KeyMapController( return true } - sequenceTriggers.forEach { triggerIndex -> - val trigger = triggers[triggerIndex] - val constraints = triggerConstraints[triggerIndex] + for (triggerIndex in sequenceTriggers) { + if (!triggersSatisfiedByConstraints.contains(triggerIndex)) { + continue + } - if (!constraintSnapshot.isSatisfied(constraints)) return@forEach + val trigger = triggers[triggerIndex] - trigger.keys.forEachIndexed { keyIndex, key -> + for (key in trigger.keys) { val matchingEvent = when { key.matchesEvent(event.withShortPress) -> true key.matchesEvent(event.withLongPress) -> true @@ -1015,7 +1028,7 @@ class KeyMapController( // the index of the next event to match in the trigger val nextIndex = lastMatchedEventIndex + 1 - if ((currentTime - downTime) >= longPressDelay(triggers[triggerIndex])) { + if ((currentTime - downTime) >= longPressDelay(trigger)) { successfulLongPressTrigger = true } else if (detectSequenceLongPresses && longPressSequenceTriggerKeys.any { it.matchesEvent(event.withLongPress) } @@ -1036,12 +1049,8 @@ class KeyMapController( } // if the next event matches the event just pressed - if (triggers[triggerIndex].matchingEventAtIndex( - encodedEventWithClickType, - nextIndex, - ) - ) { - if (triggers[triggerIndex].keys[nextIndex].consumeKeyEvent) { + if (trigger.matchingEventAtIndex(encodedEventWithClickType, nextIndex)) { + if (trigger.keys[nextIndex].consumeKeyEvent) { consumeEvent = true } @@ -1053,7 +1062,7 @@ class KeyMapController( */ if (nextIndex == 0) { val startTime = currentTime - val timeout = sequenceTriggerTimeout(triggers[triggerIndex]) + val timeout = sequenceTriggerTimeout(trigger) sequenceTriggersTimeoutTimes[triggerIndex] = startTime + timeout } @@ -1062,16 +1071,16 @@ class KeyMapController( If the last event in a trigger has been matched, then the action needs to be performed and the timer reset. */ - if (nextIndex == triggers[triggerIndex].keys.lastIndex) { + if (nextIndex == trigger.keys.lastIndex) { detectedSequenceTriggerIndexes.add(triggerIndex) - if (triggers[triggerIndex].showToast) { + if (trigger.showToast) { showToast = true } - triggerActions[triggerIndex].forEachIndexed { index, _ -> - if (triggers[triggerIndex].vibrate) { - vibrateDurations.add(vibrateDuration(triggers[triggerIndex])) + triggerActions[triggerIndex].forEach { _ -> + if (trigger.vibrate) { + vibrateDurations.add(vibrateDuration(trigger)) } } diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt index 92c8c044f0..0cbfa8b502 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt @@ -164,6 +164,86 @@ class KeyMapControllerTest { ) } + /** + * #1271 but with long press trigger instead of double press. + */ + @Test + fun `Trigger short press key map if constraints allow it and a long press key map to the same button is not allowed`() = + runTest(testDispatcher) { + val shortPressTrigger = singleKeyTrigger( + triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), + ) + val shortPressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOn)) + + val longPressTrigger = singleKeyTrigger( + triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.LONG_PRESS), + ) + val doublePressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOff)) + + keyMapListFlow.value = listOf( + KeyMap( + 0, + trigger = shortPressTrigger, + actionList = listOf(TEST_ACTION), + constraintState = shortPressConstraints, + ), + KeyMap( + 1, + trigger = longPressTrigger, + actionList = listOf(TEST_ACTION_2), + constraintState = doublePressConstraints, + ), + ) + + // Only the short press trigger is allowed. + mockConstraintSnapshot { constraint -> constraint == Constraint.WifiOn } + + mockTriggerKeyInput(shortPressTrigger.keys.first()) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + verify(performActionsUseCase, never()).perform(TEST_ACTION_2.data) + } + + /** + * #1271 + */ + @Test + fun `ignore double press key maps overlapping short press key maps if the constraints aren't satisfied`() = + runTest(testDispatcher) { + val shortPressTrigger = singleKeyTrigger( + triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), + ) + val shortPressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOn)) + + val doublePressTrigger = singleKeyTrigger( + triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.DOUBLE_PRESS), + ) + val doublePressConstraints = ConstraintState(constraints = setOf(Constraint.WifiOff)) + + keyMapListFlow.value = listOf( + KeyMap( + 0, + trigger = shortPressTrigger, + actionList = listOf(TEST_ACTION), + constraintState = shortPressConstraints, + ), + KeyMap( + 1, + trigger = doublePressTrigger, + actionList = listOf(TEST_ACTION_2), + constraintState = doublePressConstraints, + ), + ) + + // Only the short press trigger is allowed. + mockConstraintSnapshot { constraint -> constraint == Constraint.WifiOn } + + mockTriggerKeyInput(shortPressTrigger.keys.first()) + + verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) + verify(performActionsUseCase, never()).perform(TEST_ACTION_2.data) + } + @Test fun `Don't imitate button if 1 long press trigger is successful and another with a longer delay fails`() = runTest(testDispatcher) { @@ -3236,4 +3316,11 @@ class KeyMapControllerTest { isGameController = isGameController, ) } + + private fun mockConstraintSnapshot(isSatisfiedBlock: (constraint: Constraint) -> Boolean) { + val snapshot = object : ConstraintSnapshot { + override fun isSatisfied(constraint: Constraint): Boolean = isSatisfiedBlock(constraint) + } + whenever(detectConstraintsUseCase.getSnapshot()).thenReturn(snapshot) + } } From a9159f3cdae5c9058a82d6d421e470845c16511b Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 1 Oct 2024 22:22:06 +0200 Subject: [PATCH 040/118] docs: remove maintenance notice header --- docs/overrides/main.html | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/overrides/main.html b/docs/overrides/main.html index 659bb4595d..94d9808cc7 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -1,5 +1 @@ {% extends "base.html" %} - -{% block announce %} -MAINTENANCE NOTICE! Key Mapper is currently in maintenance mode. -{% endblock %} From be93e77d2366231e9df3ad30ea915123b3327908 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 3 Oct 2024 15:31:39 +0200 Subject: [PATCH 041/118] #1274 name KeyMapTrigger to CustomTrigger --- .../keymapper/data/entities/TriggerEntity.kt | 4 +- .../data/migration/Migration11To12.kt | 4 +- .../mappings/keymaps/ConfigKeyMapUseCase.kt | 4 +- .../mappings/keymaps/ConfigKeyMapViewModel.kt | 2 +- ...el.kt => ConfigTriggerOptionsViewModel.kt} | 4 +- ...ViewModel.kt => ConfigTriggerViewModel.kt} | 39 +++++++++---------- .../mappings/keymaps/DisplayKeyMapUseCase.kt | 14 +++---- .../keymapper/mappings/keymaps/KeyMap.kt | 10 ++--- .../mappings/keymaps/KeyMapListItemCreator.kt | 18 ++++----- .../keymaps/RecordTriggerButtonRow.kt | 2 +- .../keymaps/detection/KeyMapController.kt | 28 ++++++------- .../trigger/ConfigTriggerOptionsFragment.kt | 4 +- .../{KeyMapTrigger.kt => CustomTrigger.kt} | 14 +++---- ...pTriggerError.kt => CustomTriggerError.kt} | 2 +- .../keymaps/trigger/TriggerFragment.kt | 24 ++++++------ .../mappings/keymaps/trigger/TriggerKey.kt | 2 +- .../fragment_trigger.xml | 2 +- .../layout-w600dp-land/fragment_trigger.xml | 2 +- .../layout-w900dp-h600dp/fragment_trigger.xml | 2 +- app/src/main/res/layout/fragment_trigger.xml | 2 +- ...kt => ConfigCustomTriggerViewModelTest.kt} | 6 +-- .../mappings/keymaps/KeyMapControllerTest.kt | 14 +++---- ...riggerKeyMapFromOtherAppsControllerTest.kt | 4 +- .../sds100/keymapper/util/KeyMapUtils.kt | 8 ++-- 24 files changed, 106 insertions(+), 109 deletions(-) rename app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/{ConfigKeyMapTriggerOptionsViewModel.kt => ConfigTriggerOptionsViewModel.kt} (98%) rename app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/{ConfigKeyMapTriggerViewModel.kt => ConfigTriggerViewModel.kt} (93%) rename app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/{KeyMapTrigger.kt => CustomTrigger.kt} (94%) rename app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/{KeyMapTriggerError.kt => CustomTriggerError.kt} (85%) rename app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/{ConfigKeyMapTriggerViewModelTest.kt => ConfigCustomTriggerViewModelTest.kt} (95%) diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt index a8279aa957..650712d408 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt @@ -16,9 +16,6 @@ import java.util.UUID * Created by sds100 on 16/07/2018. */ -/** - * @property [keys] The key codes which will trigger the action - */ @Parcelize data class TriggerEntity( @SerializedName(NAME_KEYS) @@ -80,6 +77,7 @@ data class TriggerEntity( } } + // TODO move to separate file @Parcelize data class KeyEntity( @SerializedName(NAME_KEYCODE) diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration11To12.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration11To12.kt index 8f0df017fa..eca08ec637 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration11To12.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration11To12.kt @@ -98,12 +98,12 @@ object Migration11To12 { val cursor = database.query(keyMapListQuery) val keyMapIdColumnIndex = cursor.getColumnIndex("id") - val keyMapTriggerColumnIndex = cursor.getColumnIndex("trigger") + val triggerColumnIndex = cursor.getColumnIndex("trigger") val keyMapActionListColumnIndex = cursor.getColumnIndex("action_list") while (cursor.moveToNext()) { val id = cursor.getLong(keyMapIdColumnIndex) - val triggerJson = cursor.getString(keyMapTriggerColumnIndex) + val triggerJson = cursor.getString(triggerColumnIndex) val triggerJsonObject = parser.parse(triggerJson).asJsonObject val actionListJson = cursor.getString(keyMapActionListColumnIndex) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt index 8746b8556a..9f412c96a1 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt @@ -9,7 +9,7 @@ import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.BaseConfigMappingUseCase import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.ConfigMappingUseCase -import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode @@ -435,7 +435,7 @@ class ConfigKeyMapUseCaseImpl( } } - private fun editTrigger(block: (trigger: KeyMapTrigger) -> KeyMapTrigger) { + private fun editTrigger(block: (trigger: CustomTrigger) -> CustomTrigger) { editKeyMap { keyMap -> val newTrigger = block(keyMap.trigger) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt index 9b4366b199..0ead48c9f8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt @@ -61,7 +61,7 @@ class ConfigKeyMapViewModel( resourceProvider, ) - val configTriggerViewModel = ConfigKeyMapTriggerViewModel( + val configTriggerViewModel = ConfigTriggerViewModel( viewModelScope, onboarding, config, diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerOptionsViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerOptionsViewModel.kt similarity index 98% rename from app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerOptionsViewModel.kt rename to app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerOptionsViewModel.kt index d59d6665b2..19577221b2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerOptionsViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerOptionsViewModel.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.withContext /** * Created by sds100 on 29/11/20. */ -class ConfigKeyMapTriggerOptionsViewModel( +class ConfigTriggerOptionsViewModel( private val coroutineScope: CoroutineScope, private val config: ConfigKeyMapUseCase, private val createKeyMapShortcut: CreateKeyMapShortcutUseCase, @@ -109,7 +109,7 @@ class ConfigKeyMapTriggerOptionsViewModel( result.onFailure { error -> val snackBar = PopupUi.SnackBar( - message = error.getFullMessage(this@ConfigKeyMapTriggerOptionsViewModel), + message = error.getFullMessage(this@ConfigTriggerOptionsViewModel), ) showPopup("create_shortcut_result", snackBar) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModel.kt similarity index 93% rename from app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt rename to app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModel.kt index ff661846ad..c019af1ed2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModel.kt @@ -2,11 +2,10 @@ package io.github.sds100.keymapper.mappings.keymaps import android.os.Build import android.view.KeyEvent -import androidx.compose.runtime.getValue import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.ClickType -import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTrigger -import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTriggerError +import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTriggerError import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerState import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerUseCase import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice @@ -57,7 +56,7 @@ import kotlinx.coroutines.runBlocking * Created by sds100 on 24/11/20. */ -class ConfigKeyMapTriggerViewModel( +class ConfigTriggerViewModel( private val coroutineScope: CoroutineScope, private val onboarding: OnboardingUseCase, private val config: ConfigKeyMapUseCase, @@ -69,7 +68,7 @@ class ConfigKeyMapTriggerViewModel( PopupViewModel by PopupViewModelImpl(), NavigationViewModel by NavigationViewModelImpl() { - val optionsViewModel = ConfigKeyMapTriggerOptionsViewModel( + val optionsViewModel = ConfigTriggerOptionsViewModel( coroutineScope, config, createKeyMapShortcut, @@ -269,20 +268,20 @@ class ConfigKeyMapTriggerViewModel( } } - private fun buildTriggerErrorListItems(triggerErrors: List) = + private fun buildTriggerErrorListItems(triggerErrors: List) = triggerErrors.map { error -> when (error) { - KeyMapTriggerError.DND_ACCESS_DENIED -> TextListItem.Error( + CustomTriggerError.DND_ACCESS_DENIED -> TextListItem.Error( id = error.toString(), text = getString(R.string.trigger_error_dnd_access_denied), ) - KeyMapTriggerError.SCREEN_OFF_ROOT_DENIED -> TextListItem.Error( + CustomTriggerError.SCREEN_OFF_ROOT_DENIED -> TextListItem.Error( id = error.toString(), text = getString(R.string.trigger_error_screen_off_root_permission_denied), ) - KeyMapTriggerError.CANT_DETECT_IN_PHONE_CALL -> TextListItem.Error( + CustomTriggerError.CANT_DETECT_IN_PHONE_CALL -> TextListItem.Error( id = error.toString(), text = getString(R.string.trigger_error_cant_detect_in_phone_call), ) @@ -367,8 +366,8 @@ class ConfigKeyMapTriggerViewModel( if (result is Error.AccessibilityServiceDisabled) { ViewModelHelper.handleAccessibilityServiceStoppedSnackBar( - resourceProvider = this@ConfigKeyMapTriggerViewModel, - popupViewModel = this@ConfigKeyMapTriggerViewModel, + resourceProvider = this@ConfigTriggerViewModel, + popupViewModel = this@ConfigTriggerViewModel, startService = displayKeyMap::startAccessibilityService, message = R.string.dialog_message_enable_accessibility_service_to_record_trigger, ) @@ -376,8 +375,8 @@ class ConfigKeyMapTriggerViewModel( if (result is Error.AccessibilityServiceCrashed) { ViewModelHelper.handleAccessibilityServiceCrashedSnackBar( - resourceProvider = this@ConfigKeyMapTriggerViewModel, - popupViewModel = this@ConfigKeyMapTriggerViewModel, + resourceProvider = this@ConfigTriggerViewModel, + popupViewModel = this@ConfigTriggerViewModel, restartService = displayKeyMap::restartAccessibilityService, message = R.string.dialog_message_restart_accessibility_service_to_record_trigger, ) @@ -393,22 +392,22 @@ class ConfigKeyMapTriggerViewModel( fun onTriggerErrorClick(listItemId: String) { coroutineScope.launch { - when (KeyMapTriggerError.valueOf(listItemId)) { - KeyMapTriggerError.DND_ACCESS_DENIED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + when (CustomTriggerError.valueOf(listItemId)) { + CustomTriggerError.DND_ACCESS_DENIED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { ViewModelHelper.showDialogExplainingDndAccessBeingUnavailable( - resourceProvider = this@ConfigKeyMapTriggerViewModel, - popupViewModel = this@ConfigKeyMapTriggerViewModel, + resourceProvider = this@ConfigTriggerViewModel, + popupViewModel = this@ConfigTriggerViewModel, neverShowDndTriggerErrorAgain = { displayKeyMap.neverShowDndTriggerErrorAgain() }, fixError = { displayKeyMap.fixError(it) }, ) } - KeyMapTriggerError.SCREEN_OFF_ROOT_DENIED -> { + CustomTriggerError.SCREEN_OFF_ROOT_DENIED -> { val error = Error.PermissionDenied(Permission.ROOT) displayKeyMap.fixError(error) } - KeyMapTriggerError.CANT_DETECT_IN_PHONE_CALL -> { + CustomTriggerError.CANT_DETECT_IN_PHONE_CALL -> { displayKeyMap.fixError(Error.CantDetectKeyEventsInPhoneCall) } } @@ -416,7 +415,7 @@ class ConfigKeyMapTriggerViewModel( } private fun createListItems( - trigger: KeyMapTrigger, + trigger: CustomTrigger, showDeviceDescriptors: Boolean, ): List = trigger.keys.mapIndexed { index, key -> diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt index cb9eac6c7a..d26574abb7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt @@ -5,7 +5,7 @@ import android.view.KeyEvent import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.DisplaySimpleMappingUseCase -import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTriggerError +import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTriggerError import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.inputmethod.KeyMapperImeHelper import io.github.sds100.keymapper.system.permissions.Permission @@ -40,20 +40,20 @@ class DisplayKeyMapUseCaseImpl( preferenceRepository.get(Keys.neverShowDndError).map { }.drop(1), ) - override suspend fun getTriggerErrors(keyMap: KeyMap): List { + override suspend fun getTriggerErrors(keyMap: KeyMap): List { val trigger = keyMap.trigger - val errors = mutableListOf() + val errors = mutableListOf() // can only detect volume button presses during a phone call with an input method service if (!keyMapperImeHelper.isCompatibleImeChosen() && keyMap.requiresImeKeyEventForwarding()) { - errors.add(KeyMapTriggerError.CANT_DETECT_IN_PHONE_CALL) + errors.add(CustomTriggerError.CANT_DETECT_IN_PHONE_CALL) } if (trigger.keys.any { it.keyCode in keysThatRequireDndAccess }) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !permissionAdapter.isGranted(Permission.ACCESS_NOTIFICATION_POLICY) ) { - errors.add(KeyMapTriggerError.DND_ACCESS_DENIED) + errors.add(CustomTriggerError.DND_ACCESS_DENIED) } } @@ -61,7 +61,7 @@ class DisplayKeyMapUseCaseImpl( !permissionAdapter.isGranted(Permission.ROOT) && trigger.isDetectingWhenScreenOffAllowed() ) { - errors.add(KeyMapTriggerError.SCREEN_OFF_ROOT_DENIED) + errors.add(CustomTriggerError.SCREEN_OFF_ROOT_DENIED) } return errors @@ -70,5 +70,5 @@ class DisplayKeyMapUseCaseImpl( interface DisplayKeyMapUseCase : DisplaySimpleMappingUseCase { val invalidateTriggerErrors: Flow - suspend fun getTriggerErrors(keyMap: KeyMap): List + suspend fun getTriggerErrors(keyMap: KeyMap): List } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt index 48099c3dad..310b92e29b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt @@ -9,8 +9,8 @@ import io.github.sds100.keymapper.constraints.ConstraintState import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.mappings.Mapping import io.github.sds100.keymapper.mappings.keymaps.detection.KeyMapController -import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTrigger -import io.github.sds100.keymapper.mappings.keymaps.trigger.KeymapTriggerEntityMapper +import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTriggerEntityMapper import kotlinx.serialization.Serializable import java.util.UUID @@ -22,7 +22,7 @@ import java.util.UUID data class KeyMap( val dbId: Long? = null, val uid: String = UUID.randomUUID().toString(), - val trigger: KeyMapTrigger = KeyMapTrigger(), + val trigger: CustomTrigger = CustomTrigger(), override val actionList: List = emptyList(), override val constraintState: ConstraintState = ConstraintState(), override val isEnabled: Boolean = true, @@ -81,7 +81,7 @@ object KeyMapEntityMapper { return KeyMap( dbId = entity.id, uid = entity.uid, - trigger = KeymapTriggerEntityMapper.fromEntity(entity.trigger), + trigger = CustomTriggerEntityMapper.fromEntity(entity.trigger), actionList = actionList, constraintState = ConstraintState(constraintList, constraintMode), isEnabled = entity.isEnabled, @@ -93,7 +93,7 @@ object KeyMapEntityMapper { return KeyMapEntity( id = dbId, - trigger = KeymapTriggerEntityMapper.toEntity(keyMap.trigger), + trigger = CustomTriggerEntityMapper.toEntity(keyMap.trigger), actionList = actionEntityList, constraintList = keyMap.constraintState.constraints.map { ConstraintEntityMapper.toEntity( diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt index 38b3502647..85b9480619 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt @@ -3,8 +3,8 @@ package io.github.sds100.keymapper.mappings.keymaps import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.BaseMappingListItemCreator import io.github.sds100.keymapper.mappings.ClickType -import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTrigger -import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTriggerError +import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTriggerError import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode import io.github.sds100.keymapper.system.devices.InputDeviceUtils @@ -111,21 +111,21 @@ class KeyMapListItemCreator( val triggerErrorChips = triggerErrors.map { when (it) { - KeyMapTriggerError.DND_ACCESS_DENIED -> + CustomTriggerError.DND_ACCESS_DENIED -> ChipUi.Error( - id = KeyMapTriggerError.DND_ACCESS_DENIED.toString(), + id = CustomTriggerError.DND_ACCESS_DENIED.toString(), text = getString(R.string.trigger_error_dnd_access_denied_short), error = Error.PermissionDenied(Permission.ACCESS_NOTIFICATION_POLICY), ) - KeyMapTriggerError.SCREEN_OFF_ROOT_DENIED -> ChipUi.Error( - id = KeyMapTriggerError.SCREEN_OFF_ROOT_DENIED.toString(), + CustomTriggerError.SCREEN_OFF_ROOT_DENIED -> ChipUi.Error( + id = CustomTriggerError.SCREEN_OFF_ROOT_DENIED.toString(), text = getString(R.string.trigger_error_screen_off_root_permission_denied_short), error = Error.PermissionDenied(Permission.ROOT), ) - KeyMapTriggerError.CANT_DETECT_IN_PHONE_CALL -> ChipUi.Error( - id = KeyMapTriggerError.SCREEN_OFF_ROOT_DENIED.toString(), + CustomTriggerError.CANT_DETECT_IN_PHONE_CALL -> ChipUi.Error( + id = CustomTriggerError.SCREEN_OFF_ROOT_DENIED.toString(), text = getString(R.string.trigger_error_cant_detect_in_phone_call), error = Error.CantDetectKeyEventsInPhoneCall, ) @@ -143,7 +143,7 @@ class KeyMapListItemCreator( ) } - private fun getTriggerOptionLabels(trigger: KeyMapTrigger): List { + private fun getTriggerOptionLabels(trigger: CustomTrigger): List { val labels = mutableListOf() if (trigger.isVibrateAllowed() && trigger.vibrate) { diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt index c181a35057..95f16cd088 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt @@ -41,7 +41,7 @@ import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerState @Composable fun RecordTriggerButtonRow( modifier: Modifier = Modifier, - viewModel: ConfigKeyMapTriggerViewModel, + viewModel: ConfigTriggerViewModel, ) { val recordTriggerState by viewModel.recordTriggerState.collectAsState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt index 2e5a83a7de..22249f707d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt @@ -15,7 +15,7 @@ import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.keymaps.KeyMap import io.github.sds100.keymapper.mappings.keymaps.KeyMapAction -import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode @@ -57,13 +57,13 @@ class KeyMapController( * @return whether the actions assigned to this trigger will be performed on the down event of the final key * rather than the up event. */ - fun performActionOnDown(trigger: KeyMapTrigger): Boolean = ( - trigger.keys.size <= 1 && - trigger.keys.getOrNull(0)?.clickType != ClickType.DOUBLE_PRESS && - trigger.mode == TriggerMode.Undefined - ) || + fun performActionOnDown(trigger: CustomTrigger): Boolean = ( + trigger.keys.size <= 1 && + trigger.keys.getOrNull(0)?.clickType != ClickType.DOUBLE_PRESS && + trigger.mode == TriggerMode.Undefined + ) || - trigger.mode is TriggerMode.Parallel + trigger.mode is TriggerMode.Parallel } /** @@ -96,7 +96,7 @@ class KeyMapController( setActionMapAndOptions(value.flatMap { it.actionList }.toSet()) - val triggers = mutableListOf() + val triggers = mutableListOf() val sequenceTriggers = mutableListOf() val parallelTriggers = mutableListOf() @@ -407,7 +407,7 @@ class KeyMapController( private var doublePressTimeoutTimes = longArrayOf() private var actionMap: SparseArrayCompat = SparseArrayCompat() - private var triggers: Array = emptyArray() + private var triggers: Array = emptyArray() /** * The events to detect for each sequence trigger. @@ -1470,7 +1470,7 @@ class KeyMapController( } } - private fun KeyMapTrigger.matchingEventAtIndex(event: Event, index: Int): Boolean { + private fun CustomTrigger.matchingEventAtIndex(event: Event, index: Int): Boolean { if (index >= this.keys.size) return false val key = this.keys[index] @@ -1508,16 +1508,16 @@ class KeyMapController( this.clickType == otherKey.clickType } - private fun longPressDelay(trigger: KeyMapTrigger): Long = + private fun longPressDelay(trigger: CustomTrigger): Long = trigger.longPressDelay?.toLong() ?: defaultLongPressDelay.value - private fun doublePressTimeout(trigger: KeyMapTrigger): Long = + private fun doublePressTimeout(trigger: CustomTrigger): Long = trigger.doublePressDelay?.toLong() ?: defaultDoublePressDelay.value - private fun vibrateDuration(trigger: KeyMapTrigger): Long = + private fun vibrateDuration(trigger: CustomTrigger): Long = trigger.vibrateDuration?.toLong() ?: defaultVibrateDuration.value - private fun sequenceTriggerTimeout(trigger: KeyMapTrigger): Long = + private fun sequenceTriggerTimeout(trigger: CustomTrigger): Long = trigger.sequenceTriggerTimeout?.toLong() ?: defaultSequenceTriggerTimeout.value private fun setActionMapAndOptions(actions: Set) { diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerOptionsFragment.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerOptionsFragment.kt index 1f8cb7419b..f250ea72fa 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerOptionsFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerOptionsFragment.kt @@ -6,8 +6,8 @@ import androidx.core.content.getSystemService import androidx.navigation.navGraphViewModels import com.airbnb.epoxy.EpoxyRecyclerView import io.github.sds100.keymapper.R -import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapTriggerOptionsViewModel import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapViewModel +import io.github.sds100.keymapper.mappings.keymaps.ConfigTriggerOptionsViewModel import io.github.sds100.keymapper.system.url.UrlUtils import io.github.sds100.keymapper.triggerFromOtherApps import io.github.sds100.keymapper.ui.utils.configuredCheckBox @@ -39,7 +39,7 @@ class ConfigTriggerOptionsFragment : SimpleRecyclerViewFragment() { Inject.configKeyMapViewModel(requireContext()) } - private val viewModel: ConfigKeyMapTriggerOptionsViewModel + private val viewModel: ConfigTriggerOptionsViewModel get() = configKeyMapViewModel.configTriggerViewModel.optionsViewModel override var isAppBarVisible = false diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyMapTrigger.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/CustomTrigger.kt similarity index 94% rename from app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyMapTrigger.kt rename to app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/CustomTrigger.kt index e00454c0df..5f4fb4cb27 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyMapTrigger.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/CustomTrigger.kt @@ -15,7 +15,7 @@ import splitties.bitflags.withFlag */ @Serializable -data class KeyMapTrigger( +data class CustomTrigger( val keys: List = emptyList(), val mode: TriggerMode = TriggerMode.Undefined, val vibrate: Boolean = false, @@ -51,11 +51,11 @@ data class KeyMapTrigger( !keys.isNullOrEmpty() && keys.size > 1 && mode is TriggerMode.Sequence } -object KeymapTriggerEntityMapper { +object CustomTriggerEntityMapper { fun fromEntity( entity: TriggerEntity, - ): KeyMapTrigger { - val keys = entity.keys.map { KeymapTriggerKeyEntityMapper.fromEntity(it) } + ): CustomTrigger { + val keys = entity.keys.map { TriggerKeyEntityMapper.fromEntity(it) } val mode = when { entity.mode == TriggerEntity.SEQUENCE && keys.size > 1 -> TriggerMode.Sequence @@ -63,7 +63,7 @@ object KeymapTriggerEntityMapper { else -> TriggerMode.Undefined } - return KeyMapTrigger( + return CustomTrigger( keys = keys, mode = mode, @@ -90,7 +90,7 @@ object KeymapTriggerEntityMapper { ) } - fun toEntity(trigger: KeyMapTrigger): TriggerEntity { + fun toEntity(trigger: CustomTrigger): TriggerEntity { val extras = mutableListOf() if (trigger.isChangingSequenceTriggerTimeoutAllowed() && trigger.sequenceTriggerTimeout != null) { @@ -158,7 +158,7 @@ object KeymapTriggerEntityMapper { } return TriggerEntity( - keys = trigger.keys.map { KeymapTriggerKeyEntityMapper.toEntity(it) }, + keys = trigger.keys.map { TriggerKeyEntityMapper.toEntity(it) }, extras = extras, mode = mode, flags = flags, diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyMapTriggerError.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/CustomTriggerError.kt similarity index 85% rename from app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyMapTriggerError.kt rename to app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/CustomTriggerError.kt index c1a996ef6d..93b5e9803f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyMapTriggerError.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/CustomTriggerError.kt @@ -3,7 +3,7 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger /** * Created by sds100 on 04/04/2021. */ -enum class KeyMapTriggerError { +enum class CustomTriggerError { DND_ACCESS_DENIED, SCREEN_OFF_ROOT_DENIED, CANT_DETECT_IN_PHONE_CALL, diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt index 3600cf1534..8cddee8eb9 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt @@ -18,8 +18,8 @@ import io.github.sds100.keymapper.TriggerKeyBindingModel_ import io.github.sds100.keymapper.compose.KeyMapperTheme import io.github.sds100.keymapper.databinding.FragmentTriggerBinding import io.github.sds100.keymapper.fixError -import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapTriggerViewModel import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapViewModel +import io.github.sds100.keymapper.mappings.keymaps.ConfigTriggerViewModel import io.github.sds100.keymapper.mappings.keymaps.RecordTriggerButtonRow import io.github.sds100.keymapper.triggerKey import io.github.sds100.keymapper.util.FragmentInfo @@ -43,7 +43,7 @@ class TriggerFragment : RecyclerViewFragment(R.id.nav_config_keymap) { Inject.configKeyMapViewModel(requireContext()) }.value.configTriggerViewModel @@ -52,7 +52,7 @@ class TriggerFragment : RecyclerViewFragment>> - get() = configKeyMapTriggerViewModel.triggerKeyListItems + get() = configTriggerViewModel.triggerKeyListItems override fun bind(inflater: LayoutInflater, container: ViewGroup?) = FragmentTriggerBinding.inflate(inflater, container, false).apply { @@ -64,19 +64,19 @@ class TriggerFragment : RecyclerViewFragment + configTriggerViewModel.errorListItems.collectLatest { listItems -> binding.enableTriggerKeyDragging(triggerKeyController) @@ -87,7 +87,7 @@ class TriggerFragment : RecyclerViewFragment - configKeyMapTriggerViewModel.onTriggerErrorClick(it.id) + configTriggerViewModel.onTriggerErrorClick(it.id) } } } @@ -111,7 +111,7 @@ class TriggerFragment : RecyclerViewFragment - configKeyMapTriggerViewModel.onRemoveKeyClick(model.id) + configTriggerViewModel.onRemoveKeyClick(model.id) } onMoreClick { _ -> - configKeyMapTriggerViewModel.onTriggerKeyOptionsClick(model.id) + configTriggerViewModel.onTriggerKeyOptionsClick(model.id) } onDeviceClick { _ -> - configKeyMapTriggerViewModel.onChooseDeviceClick(model.id) + configTriggerViewModel.onChooseDeviceClick(model.id) } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt index d437859aa5..b21eeecc5b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt @@ -30,7 +30,7 @@ data class TriggerKey( } } -object KeymapTriggerKeyEntityMapper { +object TriggerKeyEntityMapper { fun fromEntity( entity: TriggerEntity.KeyEntity, ): TriggerKey = TriggerKey( diff --git a/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml b/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml index 93db4f2df4..26b6b399e7 100644 --- a/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml +++ b/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml @@ -9,7 +9,7 @@ + type="io.github.sds100.keymapper.mappings.keymaps.ConfigTriggerViewModel" /> diff --git a/app/src/main/res/layout-w600dp-land/fragment_trigger.xml b/app/src/main/res/layout-w600dp-land/fragment_trigger.xml index 3955cbf244..88710ebe69 100644 --- a/app/src/main/res/layout-w600dp-land/fragment_trigger.xml +++ b/app/src/main/res/layout-w600dp-land/fragment_trigger.xml @@ -10,7 +10,7 @@ + type="io.github.sds100.keymapper.mappings.keymaps.ConfigTriggerViewModel" /> + type="io.github.sds100.keymapper.mappings.keymaps.ConfigTriggerViewModel" /> + type="io.github.sds100.keymapper.mappings.keymaps.ConfigTriggerViewModel" /> diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModelTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigCustomTriggerViewModelTest.kt similarity index 95% rename from app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModelTest.kt rename to app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigCustomTriggerViewModelTest.kt index 608eda705d..5e8250472a 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapTriggerViewModelTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigCustomTriggerViewModelTest.kt @@ -34,12 +34,12 @@ import org.mockito.kotlin.mock @ExperimentalCoroutinesApi @RunWith(MockitoJUnitRunner::class) -class ConfigKeyMapTriggerViewModelTest { +class ConfigCustomTriggerViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() private val testScope = TestScope(testDispatcher) - private lateinit var viewModel: ConfigKeyMapTriggerViewModel + private lateinit var viewModel: ConfigTriggerViewModel private lateinit var mockConfigKeyMapUseCase: ConfigKeyMapUseCase private lateinit var mockRecordTrigger: RecordTriggerUseCase private lateinit var fakeOnboarding: FakeOnboardingUseCase @@ -66,7 +66,7 @@ class ConfigKeyMapTriggerViewModelTest { fakeResourceProvider = FakeResourceProvider() - viewModel = ConfigKeyMapTriggerViewModel( + viewModel = ConfigTriggerViewModel( testScope, fakeOnboarding, mockConfigKeyMapUseCase, diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt index 0cbfa8b502..411076a8e8 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt @@ -12,7 +12,7 @@ import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase import io.github.sds100.keymapper.mappings.keymaps.detection.KeyMapController -import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode @@ -378,7 +378,7 @@ class KeyMapControllerTest { runTest(testDispatcher) { // GIVEN val keyMap1 = KeyMap( - trigger = KeyMapTrigger( + trigger = CustomTrigger( keys = listOf(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)), longPressDelay = 500, ), @@ -386,7 +386,7 @@ class KeyMapControllerTest { ) val keyMap2 = KeyMap( - trigger = KeyMapTrigger( + trigger = CustomTrigger( keys = listOf(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)), longPressDelay = 1000, ), @@ -1980,7 +1980,7 @@ class KeyMapControllerTest { @Test @Parameters(method = "params_repeatAction") - fun parallelTrigger_holdDown_repeatAction10Times(description: String, trigger: KeyMapTrigger) = + fun parallelTrigger_holdDown_repeatAction10Times(description: String, trigger: CustomTrigger) = runTest(testDispatcher) { // given val action = KeyMapAction( @@ -2032,7 +2032,7 @@ class KeyMapControllerTest { @Parameters(method = "params_dualParallelTrigger_input2ndKey_doNotConsumeUp") fun dualParallelTrigger_input2ndKey_doNotConsumeUp( description: String, - trigger: KeyMapTrigger, + trigger: CustomTrigger, ) = runTest(testDispatcher) { // given @@ -2304,7 +2304,7 @@ class KeyMapControllerTest { @Test @Parameters(method = "params_multipleActionsPerformed") - fun validInput_multipleActionsPerformed(description: String, trigger: KeyMapTrigger) = + fun validInput_multipleActionsPerformed(description: String, trigger: CustomTrigger) = runTest(testDispatcher) { val actionList = listOf(TEST_ACTION, TEST_ACTION_2) // GIVEN @@ -3259,7 +3259,7 @@ class KeyMapControllerTest { ) private suspend fun mockParallelTrigger( - trigger: KeyMapTrigger, + trigger: CustomTrigger, delay: Long? = null, ) { require(trigger.mode is TriggerMode.Parallel) diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt index 429e26b30f..8bb79a0b3a 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt @@ -6,7 +6,7 @@ import io.github.sds100.keymapper.actions.RepeatMode import io.github.sds100.keymapper.constraints.ConstraintSnapshotImpl import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase -import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger import junitparams.JUnitParamsRunner import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -115,7 +115,7 @@ class TriggerKeyMapFromOtherAppsControllerTest { ) val keyMap = KeyMap( actionList = listOf(action), - trigger = KeyMapTrigger(triggerFromOtherApps = true), + trigger = CustomTrigger(triggerFromOtherApps = true), ) keyMapListFlow.value = listOf(keyMap) diff --git a/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt b/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt index aeb5af1aed..e47b2b8151 100644 --- a/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt +++ b/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt @@ -1,7 +1,7 @@ package io.github.sds100.keymapper.util import io.github.sds100.keymapper.mappings.ClickType -import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyMapTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode @@ -10,17 +10,17 @@ import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode * Created by sds100 on 19/04/2021. */ -fun singleKeyTrigger(key: TriggerKey): KeyMapTrigger = KeyMapTrigger( +fun singleKeyTrigger(key: TriggerKey): CustomTrigger = CustomTrigger( keys = listOf(key), mode = TriggerMode.Undefined, ) -fun parallelTrigger(vararg keys: TriggerKey): KeyMapTrigger = KeyMapTrigger( +fun parallelTrigger(vararg keys: TriggerKey): CustomTrigger = CustomTrigger( keys = keys.toList(), mode = TriggerMode.Parallel(keys[0].clickType), ) -fun sequenceTrigger(vararg keys: TriggerKey): KeyMapTrigger = KeyMapTrigger( +fun sequenceTrigger(vararg keys: TriggerKey): CustomTrigger = CustomTrigger( keys = keys.toList(), mode = TriggerMode.Sequence, ) From 6ae4cc9577e88cc703e9d4987e57d9a70cd5eda6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 3 Oct 2024 15:32:17 +0200 Subject: [PATCH 042/118] #1274 move TriggerKeyEntity to its own file --- .../keymapper/data/db/SeedDatabaseWorker.kt | 13 ++-- .../db/typeconverter/TriggerTypeConverter.kt | 3 +- .../keymapper/data/entities/TriggerEntity.kt | 66 +----------------- .../data/entities/TriggerKeyEntity.kt | 69 +++++++++++++++++++ .../keymapper/data/migration/Migration1To2.kt | 4 +- .../keymapper/data/migration/Migration6To7.kt | 3 +- .../data/repositories/RoomKeyMapRepository.kt | 6 +- .../mappings/keymaps/trigger/TriggerKey.kt | 19 ++--- .../data/repositories/KeyMapRepositoryTest.kt | 7 +- 9 files changed, 101 insertions(+), 89 deletions(-) create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/SeedDatabaseWorker.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/SeedDatabaseWorker.kt index 3fba3334c5..4a8ef823d7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/SeedDatabaseWorker.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/SeedDatabaseWorker.kt @@ -9,6 +9,7 @@ import io.github.sds100.keymapper.ServiceLocator import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.entities.TriggerEntity +import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import kotlinx.coroutines.coroutineScope /** @@ -45,25 +46,25 @@ class SeedDatabaseWorker( private fun createRandomTrigger(): TriggerEntity { val keys = sequence { yield( - TriggerEntity.KeyEntity( + TriggerKeyEntity( KeyEvent.KEYCODE_CTRL_LEFT, - TriggerEntity.KeyEntity.DEVICE_ID_THIS_DEVICE, + TriggerKeyEntity.DEVICE_ID_THIS_DEVICE, null, TriggerEntity.SHORT_PRESS, ), ) yield( - TriggerEntity.KeyEntity( + TriggerKeyEntity( KeyEvent.KEYCODE_ALT_LEFT, - TriggerEntity.KeyEntity.DEVICE_ID_ANY_DEVICE, + TriggerKeyEntity.DEVICE_ID_ANY_DEVICE, null, TriggerEntity.LONG_PRESS, ), ) yield( - TriggerEntity.KeyEntity( + TriggerKeyEntity( KeyEvent.KEYCODE_DEL, - TriggerEntity.KeyEntity.DEVICE_ID_THIS_DEVICE, + TriggerKeyEntity.DEVICE_ID_THIS_DEVICE, null, TriggerEntity.SHORT_PRESS, ), diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/TriggerTypeConverter.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/TriggerTypeConverter.kt index 497e31718a..392e2d9015 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/TriggerTypeConverter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/TriggerTypeConverter.kt @@ -7,6 +7,7 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder import io.github.sds100.keymapper.data.entities.Extra import io.github.sds100.keymapper.data.entities.TriggerEntity +import io.github.sds100.keymapper.data.entities.TriggerKeyEntity /** * Created by sds100 on 05/09/2018. @@ -17,7 +18,7 @@ class TriggerTypeConverter { fun toTrigger(json: String): TriggerEntity { val gson = GsonBuilder() .registerTypeAdapter(TriggerEntity.DESERIALIZER) - .registerTypeAdapter(TriggerEntity.KeyEntity.DESERIALIZER) + .registerTypeAdapter(TriggerKeyEntity.DESERIALIZER) .registerTypeAdapter(Extra.DESERIALIZER).create() return gson.fromJson(json) diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt index 650712d408..1b5a86df39 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt @@ -5,12 +5,9 @@ import androidx.annotation.IntDef import com.github.salomonbrys.kotson.byArray import com.github.salomonbrys.kotson.byInt import com.github.salomonbrys.kotson.byNullableInt -import com.github.salomonbrys.kotson.byNullableString -import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName import kotlinx.android.parcel.Parcelize -import java.util.UUID /** * Created by sds100 on 16/07/2018. @@ -19,7 +16,7 @@ import java.util.UUID @Parcelize data class TriggerEntity( @SerializedName(NAME_KEYS) - val keys: List = listOf(), + val keys: List = listOf(), @SerializedName(NAME_EXTRAS) val extras: List = listOf(), @@ -64,7 +61,7 @@ data class TriggerEntity( val DESERIALIZER = jsonDeserializer { val triggerKeysJsonArray by it.json.byArray(NAME_KEYS) - val keys = it.context.deserialize>(triggerKeysJsonArray) + val keys = it.context.deserialize>(triggerKeysJsonArray) val extrasJsonArray by it.json.byArray(NAME_EXTRAS) val extraList = it.context.deserialize>(extrasJsonArray) ?: listOf() @@ -77,65 +74,6 @@ data class TriggerEntity( } } - // TODO move to separate file - @Parcelize - data class KeyEntity( - @SerializedName(NAME_KEYCODE) - val keyCode: Int, - @SerializedName(NAME_DEVICE_ID) - val deviceId: String = DEVICE_ID_THIS_DEVICE, - - @SerializedName(NAME_DEVICE_NAME) - val deviceName: String? = null, - - @ClickType - @SerializedName(NAME_CLICK_TYPE) - val clickType: Int = SHORT_PRESS, - - @SerializedName(NAME_FLAGS) - val flags: Int = 0, - - @SerializedName(NAME_UID) - val uid: String = UUID.randomUUID().toString(), - ) : Parcelable { - - companion object { - // DON'T CHANGE THESE. Used for JSON serialization and parsing. - const val NAME_KEYCODE = "keyCode" - const val NAME_DEVICE_ID = "deviceId" - const val NAME_DEVICE_NAME = "deviceName" - const val NAME_CLICK_TYPE = "clickType" - const val NAME_FLAGS = "flags" - const val NAME_UID = "uid" - - // IDS! DON'T CHANGE - const val DEVICE_ID_THIS_DEVICE = "io.github.sds100.keymapper.THIS_DEVICE" - const val DEVICE_ID_ANY_DEVICE = "io.github.sds100.keymapper.ANY_DEVICE" - - const val FLAG_DO_NOT_CONSUME_KEY_EVENT = 1 - - val DESERIALIZER = jsonDeserializer { - val keycode by it.json.byInt(NAME_KEYCODE) - val deviceId by it.json.byString(NAME_DEVICE_ID) - val deviceName by it.json.byNullableString(NAME_DEVICE_NAME) - val clickType by it.json.byInt(NAME_CLICK_TYPE) - - // nullable because this property was added after backup and restore was released. - val flags by it.json.byNullableInt(NAME_FLAGS) - val uid by it.json.byNullableString(NAME_UID) - - KeyEntity( - keycode, - deviceId, - deviceName, - clickType, - flags ?: 0, - uid ?: UUID.randomUUID().toString(), - ) - } - } - } - @IntDef(value = [PARALLEL, SEQUENCE, UNDEFINED]) annotation class Mode diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt new file mode 100644 index 0000000000..e21ff95a15 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt @@ -0,0 +1,69 @@ +package io.github.sds100.keymapper.data.entities + +import android.os.Parcelable +import com.github.salomonbrys.kotson.byInt +import com.github.salomonbrys.kotson.byNullableInt +import com.github.salomonbrys.kotson.byNullableString +import com.github.salomonbrys.kotson.byString +import com.github.salomonbrys.kotson.jsonDeserializer +import com.google.gson.annotations.SerializedName +import kotlinx.android.parcel.Parcelize +import java.util.UUID + +@Parcelize +data class TriggerKeyEntity( + @SerializedName(NAME_KEYCODE) + val keyCode: Int, + @SerializedName(NAME_DEVICE_ID) + val deviceId: String = DEVICE_ID_THIS_DEVICE, + + @SerializedName(NAME_DEVICE_NAME) + val deviceName: String? = null, + + @TriggerEntity.ClickType + @SerializedName(NAME_CLICK_TYPE) + val clickType: Int = TriggerEntity.SHORT_PRESS, + + @SerializedName(NAME_FLAGS) + val flags: Int = 0, + + @SerializedName(NAME_UID) + val uid: String = UUID.randomUUID().toString(), +) : Parcelable { + + companion object { + // DON'T CHANGE THESE. Used for JSON serialization and parsing. + const val NAME_KEYCODE = "keyCode" + const val NAME_DEVICE_ID = "deviceId" + const val NAME_DEVICE_NAME = "deviceName" + const val NAME_CLICK_TYPE = "clickType" + const val NAME_FLAGS = "flags" + const val NAME_UID = "uid" + + // IDS! DON'T CHANGE + const val DEVICE_ID_THIS_DEVICE = "io.github.sds100.keymapper.THIS_DEVICE" + const val DEVICE_ID_ANY_DEVICE = "io.github.sds100.keymapper.ANY_DEVICE" + + const val FLAG_DO_NOT_CONSUME_KEY_EVENT = 1 + + val DESERIALIZER = jsonDeserializer { + val keycode by it.json.byInt(NAME_KEYCODE) + val deviceId by it.json.byString(NAME_DEVICE_ID) + val deviceName by it.json.byNullableString(NAME_DEVICE_NAME) + val clickType by it.json.byInt(NAME_CLICK_TYPE) + + // nullable because this property was added after backup and restore was released. + val flags by it.json.byNullableInt(NAME_FLAGS) + val uid by it.json.byNullableString(NAME_UID) + + TriggerKeyEntity( + keycode, + deviceId, + deviceName, + clickType, + flags ?: 0, + uid ?: UUID.randomUUID().toString(), + ) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration1To2.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration1To2.kt index 30e5928f55..1f073afdb8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration1To2.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration1To2.kt @@ -13,7 +13,7 @@ import com.google.gson.JsonArray import com.google.gson.JsonObject import com.google.gson.JsonParser import io.github.sds100.keymapper.data.entities.ActionEntity -import io.github.sds100.keymapper.data.entities.TriggerEntity +import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import splitties.bitflags.hasFlag import timber.log.Timber @@ -107,7 +107,7 @@ object Migration1To2 { createTriggerKey2( it.asInt, - TriggerEntity.KeyEntity.DEVICE_ID_ANY_DEVICE, + TriggerKeyEntity.DEVICE_ID_ANY_DEVICE, clickType, ) } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration6To7.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration6To7.kt index 73cca21b58..e8e8693a1c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration6To7.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration6To7.kt @@ -9,6 +9,7 @@ import com.github.salomonbrys.kotson.registerTypeAdapter import com.google.gson.Gson import com.google.gson.GsonBuilder import io.github.sds100.keymapper.data.entities.TriggerEntity +import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import splitties.bitflags.hasFlag import splitties.bitflags.minusFlag import splitties.bitflags.withFlag @@ -40,7 +41,7 @@ object Migration6To7 { val newTriggerKeys = trigger.keys.map { if (trigger.flags.hasFlag(TRIGGER_FLAG_DONT_OVERRIDE_DEFAULT_ACTION)) { - it.copy(flags = it.flags.withFlag(TriggerEntity.KeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT)) + it.copy(flags = it.flags.withFlag(TriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT)) } else { it } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt index 8ab8745a38..a1545f9583 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt @@ -4,7 +4,7 @@ import io.github.sds100.keymapper.data.db.dao.KeyMapDao import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.data.entities.Extra import io.github.sds100.keymapper.data.entities.KeyMapEntity -import io.github.sds100.keymapper.data.entities.TriggerEntity +import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import io.github.sds100.keymapper.mappings.keymaps.KeyMapRepository import io.github.sds100.keymapper.system.devices.DevicesAdapter import io.github.sds100.keymapper.util.DefaultDispatcherProvider @@ -149,8 +149,8 @@ class RoomKeyMapRepository( var updateKeyMap = false val newTriggerKeys = keyMap.trigger.keys.map { triggerKey -> - if (triggerKey.deviceId != TriggerEntity.KeyEntity.DEVICE_ID_THIS_DEVICE || - triggerKey.deviceId != TriggerEntity.KeyEntity.DEVICE_ID_ANY_DEVICE + if (triggerKey.deviceId != TriggerKeyEntity.DEVICE_ID_THIS_DEVICE || + triggerKey.deviceId != TriggerKeyEntity.DEVICE_ID_ANY_DEVICE ) { val deviceDescriptor = triggerKey.deviceId diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt index b21eeecc5b..51094185d8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger import io.github.sds100.keymapper.data.entities.TriggerEntity +import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import io.github.sds100.keymapper.mappings.ClickType import kotlinx.serialization.Serializable import splitties.bitflags.hasFlag @@ -32,13 +33,13 @@ data class TriggerKey( object TriggerKeyEntityMapper { fun fromEntity( - entity: TriggerEntity.KeyEntity, + entity: TriggerKeyEntity, ): TriggerKey = TriggerKey( uid = entity.uid, keyCode = entity.keyCode, device = when (entity.deviceId) { - TriggerEntity.KeyEntity.DEVICE_ID_THIS_DEVICE -> TriggerKeyDevice.Internal - TriggerEntity.KeyEntity.DEVICE_ID_ANY_DEVICE -> TriggerKeyDevice.Any + TriggerKeyEntity.DEVICE_ID_THIS_DEVICE -> TriggerKeyDevice.Internal + TriggerKeyEntity.DEVICE_ID_ANY_DEVICE -> TriggerKeyDevice.Any else -> TriggerKeyDevice.External( entity.deviceId, entity.deviceName ?: "", @@ -50,14 +51,14 @@ object TriggerKeyEntityMapper { TriggerEntity.DOUBLE_PRESS -> ClickType.DOUBLE_PRESS else -> ClickType.SHORT_PRESS }, - consumeKeyEvent = !entity.flags.hasFlag(TriggerEntity.KeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT), + consumeKeyEvent = !entity.flags.hasFlag(TriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT), ) - fun toEntity(key: TriggerKey): TriggerEntity.KeyEntity { + fun toEntity(key: TriggerKey): TriggerKeyEntity { val deviceId = when (key.device) { - TriggerKeyDevice.Any -> TriggerEntity.KeyEntity.DEVICE_ID_ANY_DEVICE + TriggerKeyDevice.Any -> TriggerKeyEntity.DEVICE_ID_ANY_DEVICE is TriggerKeyDevice.External -> key.device.descriptor - TriggerKeyDevice.Internal -> TriggerEntity.KeyEntity.DEVICE_ID_THIS_DEVICE + TriggerKeyDevice.Internal -> TriggerKeyEntity.DEVICE_ID_THIS_DEVICE } val deviceName = if (key.device is TriggerKeyDevice.External) { @@ -75,10 +76,10 @@ object TriggerKeyEntityMapper { var flags = 0 if (!key.consumeKeyEvent) { - flags = flags.withFlag(TriggerEntity.KeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT) + flags = flags.withFlag(TriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT) } - return TriggerEntity.KeyEntity( + return TriggerKeyEntity( keyCode = key.keyCode, deviceId = deviceId, deviceName = deviceName, diff --git a/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt b/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt index 9a390733d9..1ec372147b 100644 --- a/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt @@ -6,6 +6,7 @@ import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.data.entities.Extra import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.entities.TriggerEntity +import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import io.github.sds100.keymapper.system.devices.FakeDevicesAdapter import io.github.sds100.keymapper.system.devices.InputDeviceInfo import io.github.sds100.keymapper.util.State @@ -254,7 +255,7 @@ class KeyMapRepositoryTest { fun `key map with device name for trigger key, if device for trigger key is connected, do not update trigger key device name`() = runTest(testDispatcher) { // GIVEN - val triggerKey = TriggerEntity.KeyEntity( + val triggerKey = TriggerKeyEntity( keyCode = 1, deviceId = FAKE_KEYBOARD.descriptor, deviceName = FAKE_KEYBOARD.name, @@ -277,7 +278,7 @@ class KeyMapRepositoryTest { fun `key map with device name for trigger key, if device for trigger key is disconnected, do not update trigger key device name`() = runTest(testDispatcher) { // GIVEN - val triggerKey = TriggerEntity.KeyEntity( + val triggerKey = TriggerKeyEntity( keyCode = 1, deviceId = FAKE_KEYBOARD.descriptor, deviceName = FAKE_KEYBOARD.name, @@ -298,7 +299,7 @@ class KeyMapRepositoryTest { fun `key map with no device name for trigger key, if device for trigger key is connected, update trigger key device name`() = runTest(testDispatcher) { // GIVEN - val triggerKey = TriggerEntity.KeyEntity( + val triggerKey = TriggerKeyEntity( keyCode = 1, deviceId = FAKE_KEYBOARD.descriptor, deviceName = "", From 2b2ac172e85257e2007aa534065da92f88790e4f Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 3 Oct 2024 15:33:16 +0200 Subject: [PATCH 043/118] use kotlinx.parcelize instead of kotlinx.android.parcel --- .../io/github/sds100/keymapper/data/entities/ActionEntity.kt | 2 +- .../main/java/io/github/sds100/keymapper/data/entities/Extra.kt | 2 +- .../io/github/sds100/keymapper/data/entities/TriggerEntity.kt | 2 +- .../github/sds100/keymapper/data/entities/TriggerKeyEntity.kt | 2 +- .../github/sds100/keymapper/system/devices/InputDeviceInfo.kt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index b0e14180df..9ff2daac77 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -8,7 +8,7 @@ import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName import io.github.sds100.keymapper.data.entities.ActionEntity.Type -import kotlinx.android.parcel.Parcelize +import kotlinx.parcelize.Parcelize import java.util.UUID /** diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/Extra.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/Extra.kt index 8f5ea59dc8..2fd4b83b2f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/Extra.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/Extra.kt @@ -7,7 +7,7 @@ import com.google.gson.annotations.SerializedName import io.github.sds100.keymapper.util.Error import io.github.sds100.keymapper.util.Result import io.github.sds100.keymapper.util.Success -import kotlinx.android.parcel.Parcelize +import kotlinx.parcelize.Parcelize /** * Created by sds100 on 26/01/2019. diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt index 1b5a86df39..7259f427af 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt @@ -7,7 +7,7 @@ import com.github.salomonbrys.kotson.byInt import com.github.salomonbrys.kotson.byNullableInt import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName -import kotlinx.android.parcel.Parcelize +import kotlinx.parcelize.Parcelize /** * Created by sds100 on 16/07/2018. diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt index e21ff95a15..94a5b8adb7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt @@ -7,7 +7,7 @@ import com.github.salomonbrys.kotson.byNullableString import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer import com.google.gson.annotations.SerializedName -import kotlinx.android.parcel.Parcelize +import kotlinx.parcelize.Parcelize import java.util.UUID @Parcelize diff --git a/app/src/main/java/io/github/sds100/keymapper/system/devices/InputDeviceInfo.kt b/app/src/main/java/io/github/sds100/keymapper/system/devices/InputDeviceInfo.kt index b2f70596f6..465c3b5adf 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/devices/InputDeviceInfo.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/devices/InputDeviceInfo.kt @@ -1,7 +1,7 @@ package io.github.sds100.keymapper.system.devices import android.os.Parcelable -import kotlinx.android.parcel.Parcelize +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable /** From db8b2d4b5b5cdd2c048982d1d1935e4c890d5624 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 3 Oct 2024 16:51:38 +0200 Subject: [PATCH 044/118] refactor: rename CustomTrigger to Trigger --- .../mappings/keymaps/ConfigKeyMapUseCase.kt | 4 ++-- .../keymaps/ConfigTriggerViewModel.kt | 22 +++++++++---------- .../mappings/keymaps/DisplayKeyMapUseCase.kt | 14 ++++++------ .../keymapper/mappings/keymaps/KeyMap.kt | 10 ++++----- .../mappings/keymaps/KeyMapListItemCreator.kt | 18 +++++++-------- .../keymaps/detection/KeyMapController.kt | 18 +++++++-------- .../trigger/{CustomTrigger.kt => Trigger.kt} | 10 ++++----- ...{CustomTriggerError.kt => TriggerError.kt} | 2 +- ...lTest.kt => ConfigTriggerViewModelTest.kt} | 2 +- .../mappings/keymaps/KeyMapControllerTest.kt | 14 ++++++------ ...riggerKeyMapFromOtherAppsControllerTest.kt | 4 ++-- .../sds100/keymapper/util/KeyMapUtils.kt | 8 +++---- 12 files changed, 63 insertions(+), 63 deletions(-) rename app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/{CustomTrigger.kt => Trigger.kt} (97%) rename app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/{CustomTriggerError.kt => TriggerError.kt} (85%) rename app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/{ConfigCustomTriggerViewModelTest.kt => ConfigTriggerViewModelTest.kt} (98%) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt index 9f412c96a1..f97cf024f9 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt @@ -9,7 +9,7 @@ import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.BaseConfigMappingUseCase import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.ConfigMappingUseCase -import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode @@ -435,7 +435,7 @@ class ConfigKeyMapUseCaseImpl( } } - private fun editTrigger(block: (trigger: CustomTrigger) -> CustomTrigger) { + private fun editTrigger(block: (trigger: Trigger) -> Trigger) { editKeyMap { keyMap -> val newTrigger = block(keyMap.trigger) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModel.kt index c019af1ed2..ee8b88981e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModel.kt @@ -4,10 +4,10 @@ import android.os.Build import android.view.KeyEvent import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.ClickType -import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger -import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTriggerError import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerState import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerUseCase +import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerError import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyLinkType import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyListItem @@ -268,20 +268,20 @@ class ConfigTriggerViewModel( } } - private fun buildTriggerErrorListItems(triggerErrors: List) = + private fun buildTriggerErrorListItems(triggerErrors: List) = triggerErrors.map { error -> when (error) { - CustomTriggerError.DND_ACCESS_DENIED -> TextListItem.Error( + TriggerError.DND_ACCESS_DENIED -> TextListItem.Error( id = error.toString(), text = getString(R.string.trigger_error_dnd_access_denied), ) - CustomTriggerError.SCREEN_OFF_ROOT_DENIED -> TextListItem.Error( + TriggerError.SCREEN_OFF_ROOT_DENIED -> TextListItem.Error( id = error.toString(), text = getString(R.string.trigger_error_screen_off_root_permission_denied), ) - CustomTriggerError.CANT_DETECT_IN_PHONE_CALL -> TextListItem.Error( + TriggerError.CANT_DETECT_IN_PHONE_CALL -> TextListItem.Error( id = error.toString(), text = getString(R.string.trigger_error_cant_detect_in_phone_call), ) @@ -392,8 +392,8 @@ class ConfigTriggerViewModel( fun onTriggerErrorClick(listItemId: String) { coroutineScope.launch { - when (CustomTriggerError.valueOf(listItemId)) { - CustomTriggerError.DND_ACCESS_DENIED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + when (TriggerError.valueOf(listItemId)) { + TriggerError.DND_ACCESS_DENIED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { ViewModelHelper.showDialogExplainingDndAccessBeingUnavailable( resourceProvider = this@ConfigTriggerViewModel, popupViewModel = this@ConfigTriggerViewModel, @@ -402,12 +402,12 @@ class ConfigTriggerViewModel( ) } - CustomTriggerError.SCREEN_OFF_ROOT_DENIED -> { + TriggerError.SCREEN_OFF_ROOT_DENIED -> { val error = Error.PermissionDenied(Permission.ROOT) displayKeyMap.fixError(error) } - CustomTriggerError.CANT_DETECT_IN_PHONE_CALL -> { + TriggerError.CANT_DETECT_IN_PHONE_CALL -> { displayKeyMap.fixError(Error.CantDetectKeyEventsInPhoneCall) } } @@ -415,7 +415,7 @@ class ConfigTriggerViewModel( } private fun createListItems( - trigger: CustomTrigger, + trigger: Trigger, showDeviceDescriptors: Boolean, ): List = trigger.keys.mapIndexed { index, key -> diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt index d26574abb7..849db6ef5b 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt @@ -5,7 +5,7 @@ import android.view.KeyEvent import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.DisplaySimpleMappingUseCase -import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTriggerError +import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerError import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.inputmethod.KeyMapperImeHelper import io.github.sds100.keymapper.system.permissions.Permission @@ -40,20 +40,20 @@ class DisplayKeyMapUseCaseImpl( preferenceRepository.get(Keys.neverShowDndError).map { }.drop(1), ) - override suspend fun getTriggerErrors(keyMap: KeyMap): List { + override suspend fun getTriggerErrors(keyMap: KeyMap): List { val trigger = keyMap.trigger - val errors = mutableListOf() + val errors = mutableListOf() // can only detect volume button presses during a phone call with an input method service if (!keyMapperImeHelper.isCompatibleImeChosen() && keyMap.requiresImeKeyEventForwarding()) { - errors.add(CustomTriggerError.CANT_DETECT_IN_PHONE_CALL) + errors.add(TriggerError.CANT_DETECT_IN_PHONE_CALL) } if (trigger.keys.any { it.keyCode in keysThatRequireDndAccess }) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !permissionAdapter.isGranted(Permission.ACCESS_NOTIFICATION_POLICY) ) { - errors.add(CustomTriggerError.DND_ACCESS_DENIED) + errors.add(TriggerError.DND_ACCESS_DENIED) } } @@ -61,7 +61,7 @@ class DisplayKeyMapUseCaseImpl( !permissionAdapter.isGranted(Permission.ROOT) && trigger.isDetectingWhenScreenOffAllowed() ) { - errors.add(CustomTriggerError.SCREEN_OFF_ROOT_DENIED) + errors.add(TriggerError.SCREEN_OFF_ROOT_DENIED) } return errors @@ -70,5 +70,5 @@ class DisplayKeyMapUseCaseImpl( interface DisplayKeyMapUseCase : DisplaySimpleMappingUseCase { val invalidateTriggerErrors: Flow - suspend fun getTriggerErrors(keyMap: KeyMap): List + suspend fun getTriggerErrors(keyMap: KeyMap): List } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt index 310b92e29b..65284bd306 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt @@ -9,8 +9,8 @@ import io.github.sds100.keymapper.constraints.ConstraintState import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.mappings.Mapping import io.github.sds100.keymapper.mappings.keymaps.detection.KeyMapController -import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger -import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTriggerEntityMapper +import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerEntityMapper import kotlinx.serialization.Serializable import java.util.UUID @@ -22,7 +22,7 @@ import java.util.UUID data class KeyMap( val dbId: Long? = null, val uid: String = UUID.randomUUID().toString(), - val trigger: CustomTrigger = CustomTrigger(), + val trigger: Trigger = Trigger(), override val actionList: List = emptyList(), override val constraintState: ConstraintState = ConstraintState(), override val isEnabled: Boolean = true, @@ -81,7 +81,7 @@ object KeyMapEntityMapper { return KeyMap( dbId = entity.id, uid = entity.uid, - trigger = CustomTriggerEntityMapper.fromEntity(entity.trigger), + trigger = TriggerEntityMapper.fromEntity(entity.trigger), actionList = actionList, constraintState = ConstraintState(constraintList, constraintMode), isEnabled = entity.isEnabled, @@ -93,7 +93,7 @@ object KeyMapEntityMapper { return KeyMapEntity( id = dbId, - trigger = CustomTriggerEntityMapper.toEntity(keyMap.trigger), + trigger = TriggerEntityMapper.toEntity(keyMap.trigger), actionList = actionEntityList, constraintList = keyMap.constraintState.constraints.map { ConstraintEntityMapper.toEntity( diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt index 85b9480619..0e4ece49bb 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt @@ -3,8 +3,8 @@ package io.github.sds100.keymapper.mappings.keymaps import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.BaseMappingListItemCreator import io.github.sds100.keymapper.mappings.ClickType -import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger -import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTriggerError +import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerError import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode import io.github.sds100.keymapper.system.devices.InputDeviceUtils @@ -111,21 +111,21 @@ class KeyMapListItemCreator( val triggerErrorChips = triggerErrors.map { when (it) { - CustomTriggerError.DND_ACCESS_DENIED -> + TriggerError.DND_ACCESS_DENIED -> ChipUi.Error( - id = CustomTriggerError.DND_ACCESS_DENIED.toString(), + id = TriggerError.DND_ACCESS_DENIED.toString(), text = getString(R.string.trigger_error_dnd_access_denied_short), error = Error.PermissionDenied(Permission.ACCESS_NOTIFICATION_POLICY), ) - CustomTriggerError.SCREEN_OFF_ROOT_DENIED -> ChipUi.Error( - id = CustomTriggerError.SCREEN_OFF_ROOT_DENIED.toString(), + TriggerError.SCREEN_OFF_ROOT_DENIED -> ChipUi.Error( + id = TriggerError.SCREEN_OFF_ROOT_DENIED.toString(), text = getString(R.string.trigger_error_screen_off_root_permission_denied_short), error = Error.PermissionDenied(Permission.ROOT), ) - CustomTriggerError.CANT_DETECT_IN_PHONE_CALL -> ChipUi.Error( - id = CustomTriggerError.SCREEN_OFF_ROOT_DENIED.toString(), + TriggerError.CANT_DETECT_IN_PHONE_CALL -> ChipUi.Error( + id = TriggerError.SCREEN_OFF_ROOT_DENIED.toString(), text = getString(R.string.trigger_error_cant_detect_in_phone_call), error = Error.CantDetectKeyEventsInPhoneCall, ) @@ -143,7 +143,7 @@ class KeyMapListItemCreator( ) } - private fun getTriggerOptionLabels(trigger: CustomTrigger): List { + private fun getTriggerOptionLabels(trigger: Trigger): List { val labels = mutableListOf() if (trigger.isVibrateAllowed() && trigger.vibrate) { diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt index 22249f707d..4c7ead0b7f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt @@ -15,7 +15,7 @@ import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.keymaps.KeyMap import io.github.sds100.keymapper.mappings.keymaps.KeyMapAction -import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode @@ -57,7 +57,7 @@ class KeyMapController( * @return whether the actions assigned to this trigger will be performed on the down event of the final key * rather than the up event. */ - fun performActionOnDown(trigger: CustomTrigger): Boolean = ( + fun performActionOnDown(trigger: Trigger): Boolean = ( trigger.keys.size <= 1 && trigger.keys.getOrNull(0)?.clickType != ClickType.DOUBLE_PRESS && trigger.mode == TriggerMode.Undefined @@ -96,7 +96,7 @@ class KeyMapController( setActionMapAndOptions(value.flatMap { it.actionList }.toSet()) - val triggers = mutableListOf() + val triggers = mutableListOf() val sequenceTriggers = mutableListOf() val parallelTriggers = mutableListOf() @@ -407,7 +407,7 @@ class KeyMapController( private var doublePressTimeoutTimes = longArrayOf() private var actionMap: SparseArrayCompat = SparseArrayCompat() - private var triggers: Array = emptyArray() + private var triggers: Array = emptyArray() /** * The events to detect for each sequence trigger. @@ -1470,7 +1470,7 @@ class KeyMapController( } } - private fun CustomTrigger.matchingEventAtIndex(event: Event, index: Int): Boolean { + private fun Trigger.matchingEventAtIndex(event: Event, index: Int): Boolean { if (index >= this.keys.size) return false val key = this.keys[index] @@ -1508,16 +1508,16 @@ class KeyMapController( this.clickType == otherKey.clickType } - private fun longPressDelay(trigger: CustomTrigger): Long = + private fun longPressDelay(trigger: Trigger): Long = trigger.longPressDelay?.toLong() ?: defaultLongPressDelay.value - private fun doublePressTimeout(trigger: CustomTrigger): Long = + private fun doublePressTimeout(trigger: Trigger): Long = trigger.doublePressDelay?.toLong() ?: defaultDoublePressDelay.value - private fun vibrateDuration(trigger: CustomTrigger): Long = + private fun vibrateDuration(trigger: Trigger): Long = trigger.vibrateDuration?.toLong() ?: defaultVibrateDuration.value - private fun sequenceTriggerTimeout(trigger: CustomTrigger): Long = + private fun sequenceTriggerTimeout(trigger: Trigger): Long = trigger.sequenceTriggerTimeout?.toLong() ?: defaultSequenceTriggerTimeout.value private fun setActionMapAndOptions(actions: Set) { diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/CustomTrigger.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/Trigger.kt similarity index 97% rename from app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/CustomTrigger.kt rename to app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/Trigger.kt index 5f4fb4cb27..f93c7ef626 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/CustomTrigger.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/Trigger.kt @@ -15,7 +15,7 @@ import splitties.bitflags.withFlag */ @Serializable -data class CustomTrigger( +data class Trigger( val keys: List = emptyList(), val mode: TriggerMode = TriggerMode.Undefined, val vibrate: Boolean = false, @@ -51,10 +51,10 @@ data class CustomTrigger( !keys.isNullOrEmpty() && keys.size > 1 && mode is TriggerMode.Sequence } -object CustomTriggerEntityMapper { +object TriggerEntityMapper { fun fromEntity( entity: TriggerEntity, - ): CustomTrigger { + ): Trigger { val keys = entity.keys.map { TriggerKeyEntityMapper.fromEntity(it) } val mode = when { @@ -63,7 +63,7 @@ object CustomTriggerEntityMapper { else -> TriggerMode.Undefined } - return CustomTrigger( + return Trigger( keys = keys, mode = mode, @@ -90,7 +90,7 @@ object CustomTriggerEntityMapper { ) } - fun toEntity(trigger: CustomTrigger): TriggerEntity { + fun toEntity(trigger: Trigger): TriggerEntity { val extras = mutableListOf() if (trigger.isChangingSequenceTriggerTimeoutAllowed() && trigger.sequenceTriggerTimeout != null) { diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/CustomTriggerError.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerError.kt similarity index 85% rename from app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/CustomTriggerError.kt rename to app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerError.kt index 93b5e9803f..c750d4dfe3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/CustomTriggerError.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerError.kt @@ -3,7 +3,7 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger /** * Created by sds100 on 04/04/2021. */ -enum class CustomTriggerError { +enum class TriggerError { DND_ACCESS_DENIED, SCREEN_OFF_ROOT_DENIED, CANT_DETECT_IN_PHONE_CALL, diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigCustomTriggerViewModelTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModelTest.kt similarity index 98% rename from app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigCustomTriggerViewModelTest.kt rename to app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModelTest.kt index 5e8250472a..06d669148d 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigCustomTriggerViewModelTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModelTest.kt @@ -34,7 +34,7 @@ import org.mockito.kotlin.mock @ExperimentalCoroutinesApi @RunWith(MockitoJUnitRunner::class) -class ConfigCustomTriggerViewModelTest { +class ConfigTriggerViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() private val testScope = TestScope(testDispatcher) diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt index 411076a8e8..21f03e2961 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt @@ -12,7 +12,7 @@ import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase import io.github.sds100.keymapper.mappings.keymaps.detection.KeyMapController -import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode @@ -378,7 +378,7 @@ class KeyMapControllerTest { runTest(testDispatcher) { // GIVEN val keyMap1 = KeyMap( - trigger = CustomTrigger( + trigger = Trigger( keys = listOf(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)), longPressDelay = 500, ), @@ -386,7 +386,7 @@ class KeyMapControllerTest { ) val keyMap2 = KeyMap( - trigger = CustomTrigger( + trigger = Trigger( keys = listOf(triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN)), longPressDelay = 1000, ), @@ -1980,7 +1980,7 @@ class KeyMapControllerTest { @Test @Parameters(method = "params_repeatAction") - fun parallelTrigger_holdDown_repeatAction10Times(description: String, trigger: CustomTrigger) = + fun parallelTrigger_holdDown_repeatAction10Times(description: String, trigger: Trigger) = runTest(testDispatcher) { // given val action = KeyMapAction( @@ -2032,7 +2032,7 @@ class KeyMapControllerTest { @Parameters(method = "params_dualParallelTrigger_input2ndKey_doNotConsumeUp") fun dualParallelTrigger_input2ndKey_doNotConsumeUp( description: String, - trigger: CustomTrigger, + trigger: Trigger, ) = runTest(testDispatcher) { // given @@ -2304,7 +2304,7 @@ class KeyMapControllerTest { @Test @Parameters(method = "params_multipleActionsPerformed") - fun validInput_multipleActionsPerformed(description: String, trigger: CustomTrigger) = + fun validInput_multipleActionsPerformed(description: String, trigger: Trigger) = runTest(testDispatcher) { val actionList = listOf(TEST_ACTION, TEST_ACTION_2) // GIVEN @@ -3259,7 +3259,7 @@ class KeyMapControllerTest { ) private suspend fun mockParallelTrigger( - trigger: CustomTrigger, + trigger: Trigger, delay: Long? = null, ) { require(trigger.mode is TriggerMode.Parallel) diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt index 8bb79a0b3a..342dc0d65f 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt @@ -6,7 +6,7 @@ import io.github.sds100.keymapper.actions.RepeatMode import io.github.sds100.keymapper.constraints.ConstraintSnapshotImpl import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase -import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger import junitparams.JUnitParamsRunner import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -115,7 +115,7 @@ class TriggerKeyMapFromOtherAppsControllerTest { ) val keyMap = KeyMap( actionList = listOf(action), - trigger = CustomTrigger(triggerFromOtherApps = true), + trigger = Trigger(triggerFromOtherApps = true), ) keyMapListFlow.value = listOf(keyMap) diff --git a/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt b/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt index e47b2b8151..9d5508a13e 100644 --- a/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt +++ b/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt @@ -1,7 +1,7 @@ package io.github.sds100.keymapper.util import io.github.sds100.keymapper.mappings.ClickType -import io.github.sds100.keymapper.mappings.keymaps.trigger.CustomTrigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode @@ -10,17 +10,17 @@ import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode * Created by sds100 on 19/04/2021. */ -fun singleKeyTrigger(key: TriggerKey): CustomTrigger = CustomTrigger( +fun singleKeyTrigger(key: TriggerKey): Trigger = Trigger( keys = listOf(key), mode = TriggerMode.Undefined, ) -fun parallelTrigger(vararg keys: TriggerKey): CustomTrigger = CustomTrigger( +fun parallelTrigger(vararg keys: TriggerKey): Trigger = Trigger( keys = keys.toList(), mode = TriggerMode.Parallel(keys[0].clickType), ) -fun sequenceTrigger(vararg keys: TriggerKey): CustomTrigger = CustomTrigger( +fun sequenceTrigger(vararg keys: TriggerKey): Trigger = Trigger( keys = keys.toList(), mode = TriggerMode.Sequence, ) From af0c895f792954d885896b4b37e95f7ffe2995e7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 3 Oct 2024 16:58:47 +0200 Subject: [PATCH 045/118] refactor: move TriggerKeyMapFromOtherAppsController to different package --- .../{ => detection}/TriggerKeyMapFromOtherAppsController.kt | 4 ++-- .../accessibility/BaseAccessibilityServiceController.kt | 2 +- .../keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) rename app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/{ => detection}/TriggerKeyMapFromOtherAppsController.kt (91%) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsController.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/TriggerKeyMapFromOtherAppsController.kt similarity index 91% rename from app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsController.kt rename to app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/TriggerKeyMapFromOtherAppsController.kt index 8863c23a11..a60d8b04cb 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/TriggerKeyMapFromOtherAppsController.kt @@ -1,9 +1,9 @@ -package io.github.sds100.keymapper.mappings.keymaps +package io.github.sds100.keymapper.mappings.keymaps.detection import io.github.sds100.keymapper.actions.PerformActionsUseCase import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.mappings.SimpleMappingController -import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase +import io.github.sds100.keymapper.mappings.keymaps.KeyMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt index 1e635420a3..9f8d85ab00 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt @@ -15,10 +15,10 @@ import io.github.sds100.keymapper.mappings.PauseMappingsUseCase import io.github.sds100.keymapper.mappings.fingerprintmaps.DetectFingerprintMapsUseCase import io.github.sds100.keymapper.mappings.fingerprintmaps.FingerprintGestureMapController import io.github.sds100.keymapper.mappings.fingerprintmaps.FingerprintMapId -import io.github.sds100.keymapper.mappings.keymaps.TriggerKeyMapFromOtherAppsController import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase import io.github.sds100.keymapper.mappings.keymaps.detection.DetectScreenOffKeyEventsController import io.github.sds100.keymapper.mappings.keymaps.detection.KeyMapController +import io.github.sds100.keymapper.mappings.keymaps.detection.TriggerKeyMapFromOtherAppsController import io.github.sds100.keymapper.reroutekeyevents.RerouteKeyEventsController import io.github.sds100.keymapper.reroutekeyevents.RerouteKeyEventsUseCase import io.github.sds100.keymapper.system.devices.DevicesAdapter diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt index 342dc0d65f..0ce85607dc 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt @@ -6,6 +6,7 @@ import io.github.sds100.keymapper.actions.RepeatMode import io.github.sds100.keymapper.constraints.ConstraintSnapshotImpl import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase +import io.github.sds100.keymapper.mappings.keymaps.detection.TriggerKeyMapFromOtherAppsController import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger import junitparams.JUnitParamsRunner import kotlinx.coroutines.ExperimentalCoroutinesApi From c82459a7455ad6a1791083854d259411cb308f9f Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 3 Oct 2024 17:55:14 +0200 Subject: [PATCH 046/118] #1274: represent assistant triggers as normal key map trigger keys --- .../keymaps/trigger/ConfigTriggerViewModel.kt | 30 +++++++++++++++++++ .../mappings/keymaps/ConfigKeyMapViewModel.kt | 1 + .../BaseConfigTriggerViewModel.kt} | 28 ++++++++--------- .../trigger/ConfigTriggerOptionsFragment.kt | 1 - .../ConfigTriggerOptionsViewModel.kt | 6 ++-- .../{ => trigger}/RecordTriggerButtonRow.kt | 5 ++-- .../keymaps/trigger/TriggerFragment.kt | 2 -- .../fragment_trigger.xml | 2 +- .../layout-w600dp-land/fragment_trigger.xml | 2 +- .../layout-w900dp-h600dp/fragment_trigger.xml | 2 +- app/src/main/res/layout/fragment_trigger.xml | 2 +- .../keymaps/ConfigTriggerViewModelTest.kt | 1 + 12 files changed, 54 insertions(+), 28 deletions(-) create mode 100644 app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt rename app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/{ConfigTriggerViewModel.kt => trigger/BaseConfigTriggerViewModel.kt} (94%) rename app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/{ => trigger}/ConfigTriggerOptionsViewModel.kt (97%) rename app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/{ => trigger}/RecordTriggerButtonRow.kt (96%) diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt new file mode 100644 index 0000000000..448c9190c8 --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt @@ -0,0 +1,30 @@ +package io.github.sds100.keymapper.mappings.keymaps.trigger + +import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapUseCase +import io.github.sds100.keymapper.mappings.keymaps.CreateKeyMapShortcutUseCase +import io.github.sds100.keymapper.mappings.keymaps.DisplayKeyMapUseCase +import io.github.sds100.keymapper.onboarding.OnboardingUseCase +import io.github.sds100.keymapper.util.ui.ResourceProvider +import kotlinx.coroutines.CoroutineScope + +/** + * Created by sds100 on 24/11/20. + */ + +class ConfigTriggerViewModel( + coroutineScope: CoroutineScope, + onboarding: OnboardingUseCase, + config: ConfigKeyMapUseCase, + recordTrigger: RecordTriggerUseCase, + createKeyMapShortcut: CreateKeyMapShortcutUseCase, + displayKeyMap: DisplayKeyMapUseCase, + resourceProvider: ResourceProvider, +) : BaseConfigTriggerViewModel( + coroutineScope, + onboarding, + config, + recordTrigger, + createKeyMapShortcut, + displayKeyMap, + resourceProvider, +) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt index 0ead48c9f8..f50029da10 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt @@ -12,6 +12,7 @@ import io.github.sds100.keymapper.constraints.ConstraintUtils import io.github.sds100.keymapper.mappings.ConfigMappingUiState import io.github.sds100.keymapper.mappings.ConfigMappingViewModel import io.github.sds100.keymapper.mappings.keymaps.trigger.ConfigTriggerKeyViewModel +import io.github.sds100.keymapper.mappings.keymaps.trigger.ConfigTriggerViewModel import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerUseCase import io.github.sds100.keymapper.onboarding.OnboardingUseCase import io.github.sds100.keymapper.ui.utils.getJsonSerializable diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt similarity index 94% rename from app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModel.kt rename to app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt index ee8b88981e..f1723376c7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt @@ -1,17 +1,13 @@ -package io.github.sds100.keymapper.mappings.keymaps +package io.github.sds100.keymapper.mappings.keymaps.trigger import android.os.Build import android.view.KeyEvent import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.ClickType -import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerState -import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerUseCase -import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger -import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerError -import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice -import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyLinkType -import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyListItem -import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode +import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapUseCase +import io.github.sds100.keymapper.mappings.keymaps.CreateKeyMapShortcutUseCase +import io.github.sds100.keymapper.mappings.keymaps.DisplayKeyMapUseCase +import io.github.sds100.keymapper.mappings.keymaps.KeyMap import io.github.sds100.keymapper.onboarding.OnboardingUseCase import io.github.sds100.keymapper.system.devices.InputDeviceUtils import io.github.sds100.keymapper.system.keyevents.KeyEventUtils @@ -56,7 +52,7 @@ import kotlinx.coroutines.runBlocking * Created by sds100 on 24/11/20. */ -class ConfigTriggerViewModel( +abstract class BaseConfigTriggerViewModel( private val coroutineScope: CoroutineScope, private val onboarding: OnboardingUseCase, private val config: ConfigKeyMapUseCase, @@ -366,8 +362,8 @@ class ConfigTriggerViewModel( if (result is Error.AccessibilityServiceDisabled) { ViewModelHelper.handleAccessibilityServiceStoppedSnackBar( - resourceProvider = this@ConfigTriggerViewModel, - popupViewModel = this@ConfigTriggerViewModel, + resourceProvider = this@BaseConfigTriggerViewModel, + popupViewModel = this@BaseConfigTriggerViewModel, startService = displayKeyMap::startAccessibilityService, message = R.string.dialog_message_enable_accessibility_service_to_record_trigger, ) @@ -375,8 +371,8 @@ class ConfigTriggerViewModel( if (result is Error.AccessibilityServiceCrashed) { ViewModelHelper.handleAccessibilityServiceCrashedSnackBar( - resourceProvider = this@ConfigTriggerViewModel, - popupViewModel = this@ConfigTriggerViewModel, + resourceProvider = this@BaseConfigTriggerViewModel, + popupViewModel = this@BaseConfigTriggerViewModel, restartService = displayKeyMap::restartAccessibilityService, message = R.string.dialog_message_restart_accessibility_service_to_record_trigger, ) @@ -395,8 +391,8 @@ class ConfigTriggerViewModel( when (TriggerError.valueOf(listItemId)) { TriggerError.DND_ACCESS_DENIED -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { ViewModelHelper.showDialogExplainingDndAccessBeingUnavailable( - resourceProvider = this@ConfigTriggerViewModel, - popupViewModel = this@ConfigTriggerViewModel, + resourceProvider = this@BaseConfigTriggerViewModel, + popupViewModel = this@BaseConfigTriggerViewModel, neverShowDndTriggerErrorAgain = { displayKeyMap.neverShowDndTriggerErrorAgain() }, fixError = { displayKeyMap.fixError(it) }, ) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerOptionsFragment.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerOptionsFragment.kt index f250ea72fa..0f6791648e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerOptionsFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerOptionsFragment.kt @@ -7,7 +7,6 @@ import androidx.navigation.navGraphViewModels import com.airbnb.epoxy.EpoxyRecyclerView import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapViewModel -import io.github.sds100.keymapper.mappings.keymaps.ConfigTriggerOptionsViewModel import io.github.sds100.keymapper.system.url.UrlUtils import io.github.sds100.keymapper.triggerFromOtherApps import io.github.sds100.keymapper.ui.utils.configuredCheckBox diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerOptionsViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerOptionsViewModel.kt similarity index 97% rename from app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerOptionsViewModel.kt rename to app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerOptionsViewModel.kt index 19577221b2..dd3c3648b7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerOptionsViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerOptionsViewModel.kt @@ -1,8 +1,10 @@ -package io.github.sds100.keymapper.mappings.keymaps +package io.github.sds100.keymapper.mappings.keymaps.trigger import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.OptionMinimums -import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerFromOtherAppsListItem +import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapUseCase +import io.github.sds100.keymapper.mappings.keymaps.CreateKeyMapShortcutUseCase +import io.github.sds100.keymapper.mappings.keymaps.KeyMap import io.github.sds100.keymapper.util.Defaultable import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.dataOrNull diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt similarity index 96% rename from app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt rename to app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt index 95f16cd088..aacff7e842 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/RecordTriggerButtonRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.mappings.keymaps +package io.github.sds100.keymapper.mappings.keymaps.trigger import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -31,8 +31,6 @@ import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.R import io.github.sds100.keymapper.compose.KeyMapperTheme import io.github.sds100.keymapper.compose.LocalCustomColorsPalette -import io.github.sds100.keymapper.mappings.keymaps.trigger.AdvancedTriggersBottomSheet -import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerState /** * This row of buttons is shown at the bottom of the TriggerFragment. @@ -53,6 +51,7 @@ fun RecordTriggerButtonRow( onDismissRequest = { showBottomSheet = false }, + onChooseAssistantTrigger = viewModel::onChooseAssistantTrigger, sheetState = sheetState, ) } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt index 8cddee8eb9..9564b287e7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerFragment.kt @@ -19,8 +19,6 @@ import io.github.sds100.keymapper.compose.KeyMapperTheme import io.github.sds100.keymapper.databinding.FragmentTriggerBinding import io.github.sds100.keymapper.fixError import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapViewModel -import io.github.sds100.keymapper.mappings.keymaps.ConfigTriggerViewModel -import io.github.sds100.keymapper.mappings.keymaps.RecordTriggerButtonRow import io.github.sds100.keymapper.triggerKey import io.github.sds100.keymapper.util.FragmentInfo import io.github.sds100.keymapper.util.Inject diff --git a/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml b/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml index 26b6b399e7..6a19fa20f4 100644 --- a/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml +++ b/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml @@ -9,7 +9,7 @@ + type="io.github.sds100.keymapper.mappings.keymaps.trigger.ConfigTriggerViewModel" /> diff --git a/app/src/main/res/layout-w600dp-land/fragment_trigger.xml b/app/src/main/res/layout-w600dp-land/fragment_trigger.xml index 88710ebe69..a5b28d57fd 100644 --- a/app/src/main/res/layout-w600dp-land/fragment_trigger.xml +++ b/app/src/main/res/layout-w600dp-land/fragment_trigger.xml @@ -10,7 +10,7 @@ + type="io.github.sds100.keymapper.mappings.keymaps.trigger.ConfigTriggerViewModel" /> + type="io.github.sds100.keymapper.mappings.keymaps.trigger.ConfigTriggerViewModel" /> + type="io.github.sds100.keymapper.mappings.keymaps.trigger.ConfigTriggerViewModel" /> diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModelTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModelTest.kt index 06d669148d..62993d252c 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModelTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModelTest.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.mappings.keymaps import android.view.KeyEvent import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.mappings.keymaps.trigger.ConfigTriggerViewModel import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerState import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerUseCase import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordedKey From 302224958346756b6f3f7e8e8be9968bc7df4f3d Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 4 Oct 2024 14:19:48 +0200 Subject: [PATCH 047/118] refactor: move all click types and annotation to TriggerKey --- .../sds100/keymapper/data/db/SeedDatabaseWorker.kt | 6 +++--- .../sds100/keymapper/data/entities/TriggerEntity.kt | 8 -------- .../keymapper/data/entities/TriggerKeyEntity.kt | 13 +++++++++++-- .../mappings/keymaps/trigger/TriggerKey.kt | 13 ++++++------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/SeedDatabaseWorker.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/SeedDatabaseWorker.kt index 4a8ef823d7..4dedb3f774 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/SeedDatabaseWorker.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/db/SeedDatabaseWorker.kt @@ -50,7 +50,7 @@ class SeedDatabaseWorker( KeyEvent.KEYCODE_CTRL_LEFT, TriggerKeyEntity.DEVICE_ID_THIS_DEVICE, null, - TriggerEntity.SHORT_PRESS, + TriggerKeyEntity.SHORT_PRESS, ), ) yield( @@ -58,7 +58,7 @@ class SeedDatabaseWorker( KeyEvent.KEYCODE_ALT_LEFT, TriggerKeyEntity.DEVICE_ID_ANY_DEVICE, null, - TriggerEntity.LONG_PRESS, + TriggerKeyEntity.LONG_PRESS, ), ) yield( @@ -66,7 +66,7 @@ class SeedDatabaseWorker( KeyEvent.KEYCODE_DEL, TriggerKeyEntity.DEVICE_ID_THIS_DEVICE, null, - TriggerEntity.SHORT_PRESS, + TriggerKeyEntity.SHORT_PRESS, ), ) }.toList() diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt index 7259f427af..6ab8d4ead3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt @@ -49,11 +49,6 @@ data class TriggerEntity( const val DEFAULT_TRIGGER_MODE = UNDEFINED - const val UNDETERMINED = -1 - const val SHORT_PRESS = 0 - const val LONG_PRESS = 1 - const val DOUBLE_PRESS = 2 - const val EXTRA_SEQUENCE_TRIGGER_TIMEOUT = "extra_sequence_trigger_timeout" const val EXTRA_LONG_PRESS_DELAY = "extra_long_press_delay" const val EXTRA_DOUBLE_PRESS_DELAY = "extra_double_press_timeout" @@ -76,7 +71,4 @@ data class TriggerEntity( @IntDef(value = [PARALLEL, SEQUENCE, UNDEFINED]) annotation class Mode - - @IntDef(value = [UNDETERMINED, SHORT_PRESS, LONG_PRESS, DOUBLE_PRESS]) - annotation class ClickType } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt index 94a5b8adb7..6c35cdab6f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.data.entities import android.os.Parcelable +import androidx.annotation.IntDef import com.github.salomonbrys.kotson.byInt import com.github.salomonbrys.kotson.byNullableInt import com.github.salomonbrys.kotson.byNullableString @@ -20,9 +21,9 @@ data class TriggerKeyEntity( @SerializedName(NAME_DEVICE_NAME) val deviceName: String? = null, - @TriggerEntity.ClickType + @ClickType @SerializedName(NAME_CLICK_TYPE) - val clickType: Int = TriggerEntity.SHORT_PRESS, + val clickType: Int = SHORT_PRESS, @SerializedName(NAME_FLAGS) val flags: Int = 0, @@ -46,6 +47,11 @@ data class TriggerKeyEntity( const val FLAG_DO_NOT_CONSUME_KEY_EVENT = 1 + const val UNDETERMINED = -1 + const val SHORT_PRESS = 0 + const val LONG_PRESS = 1 + const val DOUBLE_PRESS = 2 + val DESERIALIZER = jsonDeserializer { val keycode by it.json.byInt(NAME_KEYCODE) val deviceId by it.json.byString(NAME_DEVICE_ID) @@ -66,4 +72,7 @@ data class TriggerKeyEntity( ) } } + + @IntDef(value = [UNDETERMINED, SHORT_PRESS, LONG_PRESS, DOUBLE_PRESS]) + annotation class ClickType } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt index 51094185d8..8046500352 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt @@ -1,6 +1,5 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger -import io.github.sds100.keymapper.data.entities.TriggerEntity import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import io.github.sds100.keymapper.mappings.ClickType import kotlinx.serialization.Serializable @@ -46,9 +45,9 @@ object TriggerKeyEntityMapper { ) }, clickType = when (entity.clickType) { - TriggerEntity.SHORT_PRESS -> ClickType.SHORT_PRESS - TriggerEntity.LONG_PRESS -> ClickType.LONG_PRESS - TriggerEntity.DOUBLE_PRESS -> ClickType.DOUBLE_PRESS + TriggerKeyEntity.SHORT_PRESS -> ClickType.SHORT_PRESS + TriggerKeyEntity.LONG_PRESS -> ClickType.LONG_PRESS + TriggerKeyEntity.DOUBLE_PRESS -> ClickType.DOUBLE_PRESS else -> ClickType.SHORT_PRESS }, consumeKeyEvent = !entity.flags.hasFlag(TriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT), @@ -68,9 +67,9 @@ object TriggerKeyEntityMapper { } val clickType = when (key.clickType) { - ClickType.SHORT_PRESS -> TriggerEntity.SHORT_PRESS - ClickType.LONG_PRESS -> TriggerEntity.LONG_PRESS - ClickType.DOUBLE_PRESS -> TriggerEntity.DOUBLE_PRESS + ClickType.SHORT_PRESS -> TriggerKeyEntity.SHORT_PRESS + ClickType.LONG_PRESS -> TriggerKeyEntity.LONG_PRESS + ClickType.DOUBLE_PRESS -> TriggerKeyEntity.DOUBLE_PRESS } var flags = 0 From 0dbd0222c2455a6cb92eb461ca6c3e6864cf3f6d Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 5 Oct 2024 21:59:17 +0200 Subject: [PATCH 048/118] chore: disable writing function bodies inline --- .editorconfig | 1 + 1 file changed, 1 insertion(+) diff --git a/.editorconfig b/.editorconfig index bb6ba13598..0c79c6b37b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,5 @@ [*.{kt,kts}] +ktlint_standard_function-expression-body = disabled ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_ignore_back_ticked_identifier = true ktlint_code_style = intellij_idea # Use IntelliJ style because it has trailing commas \ No newline at end of file From bafb15bdc0ded85b71b01bbbeb2f0d79688d7354 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 5 Oct 2024 22:28:32 +0200 Subject: [PATCH 049/118] #1274 WIP: add assistant trigger key --- .../AccessibilityServiceController.kt | 4 +- .../keymapper/data/db/SeedDatabaseWorker.kt | 95 ------------------- .../entities/AssistantTriggerKeyEntity.kt | 36 +++++++ .../data/entities/KeyCodeTriggerKeyEntity.kt | 43 +++++++++ .../data/entities/TriggerKeyEntity.kt | 88 ++++++++--------- .../keymapper/data/migration/Migration1To2.kt | 4 +- .../keymapper/data/migration/Migration6To7.kt | 20 ++-- .../data/repositories/RoomKeyMapRepository.kt | 24 +++-- .../sds100/keymapper/home/HomeFragment.kt | 9 -- .../mappings/keymaps/ConfigKeyMapUseCase.kt | 6 +- .../keymaps/detection/KeyMapController.kt | 48 +++++----- .../keymaps/trigger/AssistantTriggerKey.kt | 62 ++++++++++++ .../keymaps/trigger/AssistantTriggerType.kt | 16 ++++ .../trigger/ConfigTriggerKeyViewModel.kt | 2 +- .../keymaps/trigger/KeyCodeTriggerKey.kt | 88 +++++++++++++++++ .../mappings/keymaps/trigger/Trigger.kt | 21 ++-- .../mappings/keymaps/trigger/TriggerKey.kt | 95 ++++--------------- .../AccessibilityServiceAdapter.kt | 4 +- app/src/main/res/menu/menu_home.xml | 6 -- .../data/repositories/KeyMapRepositoryTest.kt | 8 +- ...iggerKeyMapFromOtherAppsControllerTest.kt} | 2 +- .../mappings/keymaps/KeyMapControllerTest.kt | 4 +- .../sds100/keymapper/util/KeyMapUtils.kt | 10 +- 23 files changed, 389 insertions(+), 306 deletions(-) delete mode 100644 app/src/main/java/io/github/sds100/keymapper/data/db/SeedDatabaseWorker.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/entities/AssistantTriggerKeyEntity.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/data/entities/KeyCodeTriggerKeyEntity.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerType.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyCodeTriggerKey.kt rename app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/{TriggerKeyMapFromOtherAppsControllerTest.kt => KeyCodeTriggerKeyMapFromOtherAppsControllerTest.kt} (98%) diff --git a/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt b/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt index 4690a1e34e..9e7bfe2a1b 100644 --- a/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt +++ b/app/src/free/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceController.kt @@ -44,5 +44,5 @@ class AccessibilityServiceController( devicesAdapter, suAdapter, inputMethodAdapter, - settingsRepository -) \ No newline at end of file + settingsRepository, +) diff --git a/app/src/main/java/io/github/sds100/keymapper/data/db/SeedDatabaseWorker.kt b/app/src/main/java/io/github/sds100/keymapper/data/db/SeedDatabaseWorker.kt deleted file mode 100644 index 4dedb3f774..0000000000 --- a/app/src/main/java/io/github/sds100/keymapper/data/db/SeedDatabaseWorker.kt +++ /dev/null @@ -1,95 +0,0 @@ -package io.github.sds100.keymapper.data.db - -import android.content.Context -import android.view.KeyEvent -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import io.github.sds100.keymapper.Constants -import io.github.sds100.keymapper.ServiceLocator -import io.github.sds100.keymapper.data.entities.ActionEntity -import io.github.sds100.keymapper.data.entities.KeyMapEntity -import io.github.sds100.keymapper.data.entities.TriggerEntity -import io.github.sds100.keymapper.data.entities.TriggerKeyEntity -import kotlinx.coroutines.coroutineScope - -/** - * Created by sds100 on 26/01/2020. - */ - -class SeedDatabaseWorker( - context: Context, - workerParams: WorkerParameters, -) : CoroutineWorker(context, workerParams) { - override suspend fun doWork(): Result = coroutineScope { - try { - val keymaps = sequence { - for (i in 1..100) { - yield( - KeyMapEntity( - id = 0, - trigger = createRandomTrigger(), - actionList = createRandomActionList(), - flags = 0, - ), - ) - } - }.toList().toTypedArray() - - ServiceLocator.roomKeymapRepository(applicationContext).insert(*keymaps) - - Result.success() - } catch (e: Exception) { - Result.failure() - } - } - - private fun createRandomTrigger(): TriggerEntity { - val keys = sequence { - yield( - TriggerKeyEntity( - KeyEvent.KEYCODE_CTRL_LEFT, - TriggerKeyEntity.DEVICE_ID_THIS_DEVICE, - null, - TriggerKeyEntity.SHORT_PRESS, - ), - ) - yield( - TriggerKeyEntity( - KeyEvent.KEYCODE_ALT_LEFT, - TriggerKeyEntity.DEVICE_ID_ANY_DEVICE, - null, - TriggerKeyEntity.LONG_PRESS, - ), - ) - yield( - TriggerKeyEntity( - KeyEvent.KEYCODE_DEL, - TriggerKeyEntity.DEVICE_ID_THIS_DEVICE, - null, - TriggerKeyEntity.SHORT_PRESS, - ), - ) - }.toList() - - return TriggerEntity( - keys, - mode = TriggerEntity.SEQUENCE, - flags = TriggerEntity.TRIGGER_FLAG_VIBRATE, - ) - } - - private fun createRandomActionList(): List = sequence { - yield( - ActionEntity( - type = ActionEntity.Type.APP, - data = Constants.PACKAGE_NAME, - ), - ) - yield( - ActionEntity( - type = ActionEntity.Type.APP, - data = "this.app.doesnt.exist", - ), - ) - }.toList() -} diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/AssistantTriggerKeyEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/AssistantTriggerKeyEntity.kt new file mode 100644 index 0000000000..6c55705467 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/AssistantTriggerKeyEntity.kt @@ -0,0 +1,36 @@ +package io.github.sds100.keymapper.data.entities + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +import java.util.UUID + +@Parcelize +data class AssistantTriggerKeyEntity( + /** + * The type of assistant that triggers this key. The voice assistant + * is the assistant that handles voice commands and the device assistant + * is the one selected in the settings as the default for reading on-screen + * content. + */ + @SerializedName(NAME_ASSISTANT_TYPE) + val type: String = ASSISTANT_TYPE_ANY, + + @SerializedName(NAME_CLICK_TYPE) + override val clickType: Int = SHORT_PRESS, + + @SerializedName(NAME_UID) + override val uid: String = UUID.randomUUID().toString(), +) : TriggerKeyEntity(), + Parcelable { + + companion object { + // DON'T CHANGE THESE. Used for JSON serialization and parsing. + const val NAME_ASSISTANT_TYPE = "assistantType" + + // IDS! DON'T CHANGE + const val ASSISTANT_TYPE_ANY = "any" + const val ASSISTANT_TYPE_VOICE = "voice" + const val ASSISTANT_TYPE_DEVICE = "device" + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/KeyCodeTriggerKeyEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/KeyCodeTriggerKeyEntity.kt new file mode 100644 index 0000000000..69d9c2ecb5 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/KeyCodeTriggerKeyEntity.kt @@ -0,0 +1,43 @@ +package io.github.sds100.keymapper.data.entities + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.Parcelize +import java.util.UUID + +@Parcelize +data class KeyCodeTriggerKeyEntity( + @SerializedName(NAME_KEYCODE) + val keyCode: Int, + + @SerializedName(NAME_DEVICE_ID) + val deviceId: String = DEVICE_ID_THIS_DEVICE, + + @SerializedName(NAME_DEVICE_NAME) + val deviceName: String? = null, + + @SerializedName(NAME_CLICK_TYPE) + override val clickType: Int = SHORT_PRESS, + + @SerializedName(NAME_FLAGS) + val flags: Int = 0, + + @SerializedName(NAME_UID) + override val uid: String = UUID.randomUUID().toString(), +) : TriggerKeyEntity(), + Parcelable { + + companion object { + // DON'T CHANGE THESE. Used for JSON serialization and parsing. + const val NAME_KEYCODE = "keyCode" + const val NAME_DEVICE_ID = "deviceId" + const val NAME_DEVICE_NAME = "deviceName" + const val NAME_FLAGS = "flags" + + // IDS! DON'T CHANGE + const val DEVICE_ID_THIS_DEVICE = "io.github.sds100.keymapper.THIS_DEVICE" + const val DEVICE_ID_ANY_DEVICE = "io.github.sds100.keymapper.ANY_DEVICE" + + const val FLAG_DO_NOT_CONSUME_KEY_EVENT = 1 + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt index 6c35cdab6f..0be4e707a2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/entities/TriggerKeyEntity.kt @@ -1,78 +1,72 @@ package io.github.sds100.keymapper.data.entities import android.os.Parcelable -import androidx.annotation.IntDef import com.github.salomonbrys.kotson.byInt import com.github.salomonbrys.kotson.byNullableInt import com.github.salomonbrys.kotson.byNullableString import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer -import com.google.gson.annotations.SerializedName -import kotlinx.parcelize.Parcelize +import com.github.salomonbrys.kotson.obj +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement import java.util.UUID -@Parcelize -data class TriggerKeyEntity( - @SerializedName(NAME_KEYCODE) - val keyCode: Int, - @SerializedName(NAME_DEVICE_ID) - val deviceId: String = DEVICE_ID_THIS_DEVICE, - - @SerializedName(NAME_DEVICE_NAME) - val deviceName: String? = null, - - @ClickType - @SerializedName(NAME_CLICK_TYPE) - val clickType: Int = SHORT_PRESS, - - @SerializedName(NAME_FLAGS) - val flags: Int = 0, - - @SerializedName(NAME_UID) - val uid: String = UUID.randomUUID().toString(), -) : Parcelable { +sealed class TriggerKeyEntity : Parcelable { + abstract val clickType: Int + abstract val uid: String companion object { // DON'T CHANGE THESE. Used for JSON serialization and parsing. - const val NAME_KEYCODE = "keyCode" - const val NAME_DEVICE_ID = "deviceId" - const val NAME_DEVICE_NAME = "deviceName" const val NAME_CLICK_TYPE = "clickType" - const val NAME_FLAGS = "flags" const val NAME_UID = "uid" - // IDS! DON'T CHANGE - const val DEVICE_ID_THIS_DEVICE = "io.github.sds100.keymapper.THIS_DEVICE" - const val DEVICE_ID_ANY_DEVICE = "io.github.sds100.keymapper.ANY_DEVICE" - - const val FLAG_DO_NOT_CONSUME_KEY_EVENT = 1 - + // Click types const val UNDETERMINED = -1 const val SHORT_PRESS = 0 const val LONG_PRESS = 1 const val DOUBLE_PRESS = 2 - val DESERIALIZER = jsonDeserializer { - val keycode by it.json.byInt(NAME_KEYCODE) - val deviceId by it.json.byString(NAME_DEVICE_ID) - val deviceName by it.json.byNullableString(NAME_DEVICE_NAME) - val clickType by it.json.byInt(NAME_CLICK_TYPE) + val DESERIALIZER: JsonDeserializer = + jsonDeserializer { (json, _, _) -> + // nullable because this property was added after backup and restore was released. + var uid: String? by json.byNullableString(key = NAME_UID) + uid = uid ?: UUID.randomUUID().toString() - // nullable because this property was added after backup and restore was released. - val flags by it.json.byNullableInt(NAME_FLAGS) - val uid by it.json.byNullableString(NAME_UID) + if (json.obj.has(AssistantTriggerKeyEntity.NAME_ASSISTANT_TYPE)) { + return@jsonDeserializer deserializeAssistantTriggerKey(json, uid!!) + } else { + return@jsonDeserializer deserializeKeyCodeTriggerKey(json, uid!!) + } + } - TriggerKeyEntity( - keycode, + private fun deserializeAssistantTriggerKey( + json: JsonElement, + uid: String, + ): AssistantTriggerKeyEntity { + val type by json.byString(AssistantTriggerKeyEntity.NAME_ASSISTANT_TYPE) + val clickType by json.byInt(NAME_CLICK_TYPE) + + return AssistantTriggerKeyEntity(type, clickType, uid) + } + + private fun deserializeKeyCodeTriggerKey( + json: JsonElement, + uid: String, + ): KeyCodeTriggerKeyEntity { + val keyCode by json.byInt(KeyCodeTriggerKeyEntity.NAME_KEYCODE) + val deviceId by json.byString(KeyCodeTriggerKeyEntity.NAME_DEVICE_ID) + val deviceName by json.byNullableString(KeyCodeTriggerKeyEntity.NAME_DEVICE_NAME) + val clickType by json.byInt(NAME_CLICK_TYPE) + val flags by json.byNullableInt(KeyCodeTriggerKeyEntity.NAME_FLAGS) + + return KeyCodeTriggerKeyEntity( + keyCode, deviceId, deviceName, clickType, flags ?: 0, - uid ?: UUID.randomUUID().toString(), + uid, ) } } - - @IntDef(value = [UNDETERMINED, SHORT_PRESS, LONG_PRESS, DOUBLE_PRESS]) - annotation class ClickType } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration1To2.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration1To2.kt index 1f073afdb8..c631cf3df7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration1To2.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration1To2.kt @@ -13,7 +13,7 @@ import com.google.gson.JsonArray import com.google.gson.JsonObject import com.google.gson.JsonParser import io.github.sds100.keymapper.data.entities.ActionEntity -import io.github.sds100.keymapper.data.entities.TriggerKeyEntity +import io.github.sds100.keymapper.data.entities.KeyCodeTriggerKeyEntity import splitties.bitflags.hasFlag import timber.log.Timber @@ -107,7 +107,7 @@ object Migration1To2 { createTriggerKey2( it.asInt, - TriggerKeyEntity.DEVICE_ID_ANY_DEVICE, + KeyCodeTriggerKeyEntity.DEVICE_ID_ANY_DEVICE, clickType, ) } diff --git a/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration6To7.kt b/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration6To7.kt index e8e8693a1c..bf2378431a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration6To7.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/migration/Migration6To7.kt @@ -8,6 +8,7 @@ import com.github.salomonbrys.kotson.fromJson import com.github.salomonbrys.kotson.registerTypeAdapter import com.google.gson.Gson import com.google.gson.GsonBuilder +import io.github.sds100.keymapper.data.entities.KeyCodeTriggerKeyEntity import io.github.sds100.keymapper.data.entities.TriggerEntity import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import splitties.bitflags.hasFlag @@ -29,7 +30,10 @@ object Migration6To7 { .create() query(query).apply { - val gson = GsonBuilder().registerTypeAdapter(TriggerEntity.DESERIALIZER).create() + val gson = GsonBuilder() + .registerTypeAdapter(TriggerEntity.DESERIALIZER) + .registerTypeAdapter(TriggerKeyEntity.DESERIALIZER) + .create() while (moveToNext()) { val idColumnIndex = getColumnIndex("id") @@ -39,13 +43,15 @@ object Migration6To7 { val trigger = gson.fromJson(getString(triggerColumnIndex)) - val newTriggerKeys = trigger.keys.map { - if (trigger.flags.hasFlag(TRIGGER_FLAG_DONT_OVERRIDE_DEFAULT_ACTION)) { - it.copy(flags = it.flags.withFlag(TriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT)) - } else { - it + val newTriggerKeys = trigger.keys + .mapNotNull { it as? KeyCodeTriggerKeyEntity } + .map { key -> + if (trigger.flags.hasFlag(TRIGGER_FLAG_DONT_OVERRIDE_DEFAULT_ACTION)) { + key.copy(flags = key.flags.withFlag(KeyCodeTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT)) + } else { + key + } } - } val newTriggerFlags = trigger.flags.minusFlag( TRIGGER_FLAG_DONT_OVERRIDE_DEFAULT_ACTION, diff --git a/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt index a1545f9583..404302108a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt @@ -3,6 +3,7 @@ package io.github.sds100.keymapper.data.repositories import io.github.sds100.keymapper.data.db.dao.KeyMapDao import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.data.entities.Extra +import io.github.sds100.keymapper.data.entities.KeyCodeTriggerKeyEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import io.github.sds100.keymapper.mappings.keymaps.KeyMapRepository @@ -148,27 +149,30 @@ class RoomKeyMapRepository( for (keyMap in keyMapList) { var updateKeyMap = false - val newTriggerKeys = keyMap.trigger.keys.map { triggerKey -> - if (triggerKey.deviceId != TriggerKeyEntity.DEVICE_ID_THIS_DEVICE || - triggerKey.deviceId != TriggerKeyEntity.DEVICE_ID_ANY_DEVICE + val newTriggerKeys = mutableListOf() + + for (key in keyMap.trigger.keys) { + if (key !is KeyCodeTriggerKeyEntity) { + newTriggerKeys.add(key) + continue + } + + if (key.deviceId != KeyCodeTriggerKeyEntity.DEVICE_ID_THIS_DEVICE && + key.deviceId != KeyCodeTriggerKeyEntity.DEVICE_ID_ANY_DEVICE ) { - val deviceDescriptor = triggerKey.deviceId + val deviceDescriptor = key.deviceId - if (triggerKey.deviceName.isNullOrBlank()) { + if (key.deviceName.isNullOrBlank()) { val newDeviceName = connectedInputDevices.data.find { it.descriptor == deviceDescriptor }?.name if (newDeviceName != null) { updateKeyMap = true - return@map triggerKey.copy( - deviceName = newDeviceName, - ) + newTriggerKeys.add(key.copy(deviceName = newDeviceName)) } } } - - return@map triggerKey } val newActions = keyMap.actionList.map { action -> diff --git a/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt b/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt index ead9950f94..ec1f4ba102 100644 --- a/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/home/HomeFragment.kt @@ -16,14 +16,11 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.navigation.fragment.findNavController import androidx.viewpager2.widget.ViewPager2 -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkManager import com.google.android.material.bottomappbar.BottomAppBar.FAB_ALIGNMENT_MODE_CENTER import com.google.android.material.bottomappbar.BottomAppBar.FAB_ALIGNMENT_MODE_END import com.google.android.material.tabs.TabLayoutMediator import io.github.sds100.keymapper.R import io.github.sds100.keymapper.backup.BackupUtils -import io.github.sds100.keymapper.data.db.SeedDatabaseWorker import io.github.sds100.keymapper.databinding.FragmentHomeBinding import io.github.sds100.keymapper.fixError import io.github.sds100.keymapper.success @@ -147,12 +144,6 @@ class HomeFragment : Fragment() { true } - R.id.action_seed_database -> { - val request = OneTimeWorkRequestBuilder().build() - WorkManager.getInstance(requireContext()).enqueue(request) - true - } - R.id.action_select_all -> { homeViewModel.onSelectAllClick() true diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt index f97cf024f9..9c2089a8cf 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt @@ -9,8 +9,8 @@ import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.BaseConfigMappingUseCase import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.ConfigMappingUseCase +import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger -import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode import io.github.sds100.keymapper.system.devices.DevicesAdapter @@ -76,7 +76,7 @@ class ConfigKeyMapUseCaseImpl( consumeKeyEvent = false } - val triggerKey = TriggerKey( + val triggerKey = KeyCodeTriggerKey( keyCode = keyCode, device = device, clickType = clickType, @@ -443,7 +443,7 @@ class ConfigKeyMapUseCaseImpl( } } - private fun editTriggerKey(uid: String, block: (key: TriggerKey) -> TriggerKey) { + private fun editTriggerKey(uid: String, block: (key: KeyCodeTriggerKey) -> KeyCodeTriggerKey) { editTrigger { oldTrigger -> val newKeys = oldTrigger.keys.map { if (it.uid == uid) { diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt index 4c7ead0b7f..1823999456 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt @@ -15,8 +15,8 @@ import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.keymaps.KeyMap import io.github.sds100.keymapper.mappings.keymaps.KeyMapAction +import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger -import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode import io.github.sds100.keymapper.system.devices.InputDeviceInfo @@ -90,7 +90,7 @@ class KeyMapController( } else { detectKeyMaps = true - val longPressSequenceTriggerKeys = mutableListOf() + val longPressSequenceTriggerKeys = mutableListOf() val doublePressKeys = mutableListOf() @@ -128,9 +128,9 @@ class KeyMapController( } if (( - keyMap.trigger.mode == TriggerMode.Sequence || - keyMap.trigger.mode == TriggerMode.Undefined - ) && + keyMap.trigger.mode == TriggerMode.Sequence || + keyMap.trigger.mode == TriggerMode.Undefined + ) && key.clickType == ClickType.DOUBLE_PRESS ) { doublePressKeys.add(TriggerKeyLocation(triggerIndex, keyIndex)) @@ -156,9 +156,9 @@ class KeyMapController( if (keyMap.actionList.any { it.data is ActionData.InputKeyEvent && - isModifierKey( - it.data.keyCode, - ) + isModifierKey( + it.data.keyCode, + ) } ) { modifierKeyEventActions = true @@ -166,9 +166,9 @@ class KeyMapController( if (keyMap.actionList.any { it.data is ActionData.InputKeyEvent && - !isModifierKey( - it.data.keyCode, - ) + !isModifierKey( + it.data.keyCode, + ) } ) { notModifierKeyEventActions = true @@ -386,7 +386,7 @@ class KeyMapController( /** * All sequence events that have the long press click type. */ - private var longPressSequenceTriggerKeys: Array = arrayOf() + private var longPressSequenceTriggerKeys: Array = arrayOf() /** * All double press keys and the index of their corresponding trigger. first is the event and second is @@ -1478,34 +1478,34 @@ class KeyMapController( return key.matchesEvent(event) } - private fun TriggerKey.matchesEvent(event: Event): Boolean = when (this.device) { + private fun KeyCodeTriggerKey.matchesEvent(event: Event): Boolean = when (this.device) { TriggerKeyDevice.Any -> this.keyCode == event.keyCode && this.clickType == event.clickType is TriggerKeyDevice.External -> this.keyCode == event.keyCode && - event.descriptor != null && - event.descriptor == this.device.descriptor && - this.clickType == event.clickType + event.descriptor != null && + event.descriptor == this.device.descriptor && + this.clickType == event.clickType TriggerKeyDevice.Internal -> this.keyCode == event.keyCode && - event.descriptor == null && - this.clickType == event.clickType + event.descriptor == null && + this.clickType == event.clickType } - private fun TriggerKey.matchesWithOtherKey(otherKey: TriggerKey): Boolean = when (this.device) { + private fun KeyCodeTriggerKey.matchesWithOtherKey(otherKey: KeyCodeTriggerKey): Boolean = when (this.device) { TriggerKeyDevice.Any -> this.keyCode == otherKey.keyCode && - this.clickType == otherKey.clickType + this.clickType == otherKey.clickType is TriggerKeyDevice.External -> this.keyCode == otherKey.keyCode && - this.device == otherKey.device && - this.clickType == otherKey.clickType + this.device == otherKey.device && + this.clickType == otherKey.clickType TriggerKeyDevice.Internal -> this.keyCode == otherKey.keyCode && - otherKey.device == TriggerKeyDevice.Internal && - this.clickType == otherKey.clickType + otherKey.device == TriggerKeyDevice.Internal && + this.clickType == otherKey.clickType } private fun longPressDelay(trigger: Trigger): Long = diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt new file mode 100644 index 0000000000..b2effb48c0 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt @@ -0,0 +1,62 @@ +package io.github.sds100.keymapper.mappings.keymaps.trigger + +import io.github.sds100.keymapper.data.entities.AssistantTriggerKeyEntity +import io.github.sds100.keymapper.data.entities.TriggerKeyEntity +import io.github.sds100.keymapper.mappings.ClickType +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +data class AssistantTriggerKey( + override val uid: String = UUID.randomUUID().toString(), + val type: AssistantTriggerType, + override val clickType: ClickType, + + val consumeKeyEvent: Boolean = true, +) : TriggerKey() { + + companion object { + fun fromEntity( + entity: AssistantTriggerKeyEntity, + ): TriggerKey { + val type: AssistantTriggerType = when (entity.type) { + AssistantTriggerKeyEntity.ASSISTANT_TYPE_VOICE -> AssistantTriggerType.VOICE + AssistantTriggerKeyEntity.ASSISTANT_TYPE_DEVICE -> AssistantTriggerType.DEVICE + else -> AssistantTriggerType.ANY + } + + val clickType: ClickType = when (entity.clickType) { + TriggerKeyEntity.SHORT_PRESS -> ClickType.SHORT_PRESS + TriggerKeyEntity.LONG_PRESS -> ClickType.LONG_PRESS + TriggerKeyEntity.DOUBLE_PRESS -> ClickType.DOUBLE_PRESS + else -> ClickType.SHORT_PRESS + } + + return AssistantTriggerKey( + uid = entity.uid, + type = type, + clickType = clickType, + ) + } + + fun toEntity(key: AssistantTriggerKey): AssistantTriggerKeyEntity { + val type: String = when (key.type) { + AssistantTriggerType.VOICE -> AssistantTriggerKeyEntity.ASSISTANT_TYPE_VOICE + AssistantTriggerType.DEVICE -> AssistantTriggerKeyEntity.ASSISTANT_TYPE_DEVICE + AssistantTriggerType.ANY -> AssistantTriggerKeyEntity.ASSISTANT_TYPE_ANY + } + + val clickType: Int = when (key.clickType) { + ClickType.SHORT_PRESS -> TriggerKeyEntity.SHORT_PRESS + ClickType.LONG_PRESS -> TriggerKeyEntity.LONG_PRESS + ClickType.DOUBLE_PRESS -> TriggerKeyEntity.DOUBLE_PRESS + } + + return AssistantTriggerKeyEntity( + type = type, + clickType = clickType, + uid = key.uid, + ) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerType.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerType.kt new file mode 100644 index 0000000000..f82c6ad9b0 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerType.kt @@ -0,0 +1,16 @@ +package io.github.sds100.keymapper.mappings.keymaps.trigger + +/** + * The type of assistant that triggers an assistant trigger key. The voice assistant + * is the assistant that handles voice commands. If you press the voice command on a headset or + * keyboard then Android asks you which app it should use as default. + * + * The device assistant is the one selected in the settings as the default for reading on-screen + * content and only one app can have this permission at a time. This is the one used when + * long-pressing the power button on Pixels and other Android skins. + */ +enum class AssistantTriggerType { + ANY, + VOICE, + DEVICE, +} diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerKeyViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerKeyViewModel.kt index 3b3a8542f2..63e71fbe77 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerKeyViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerKeyViewModel.kt @@ -84,7 +84,7 @@ class ConfigTriggerKeyViewModel( triggerKeyUid.value = uid } - private fun createListItems(triggerMode: TriggerMode, key: TriggerKey): List = + private fun createListItems(triggerMode: TriggerMode, key: KeyCodeTriggerKey): List = sequence { yield( CheckBoxListItem( diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyCodeTriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyCodeTriggerKey.kt new file mode 100644 index 0000000000..5c011179be --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyCodeTriggerKey.kt @@ -0,0 +1,88 @@ +package io.github.sds100.keymapper.mappings.keymaps.trigger + +import io.github.sds100.keymapper.data.entities.KeyCodeTriggerKeyEntity +import io.github.sds100.keymapper.data.entities.TriggerKeyEntity +import io.github.sds100.keymapper.mappings.ClickType +import kotlinx.serialization.Serializable +import splitties.bitflags.hasFlag +import splitties.bitflags.withFlag +import java.util.UUID + +@Serializable +data class KeyCodeTriggerKey( + override val uid: String = UUID.randomUUID().toString(), + val keyCode: Int, + val device: TriggerKeyDevice, + override val clickType: ClickType, + + val consumeKeyEvent: Boolean = true, +) : TriggerKey() { + + override fun toString(): String { + val deviceString = when (device) { + TriggerKeyDevice.Any -> "any" + is TriggerKeyDevice.External -> "external" + TriggerKeyDevice.Internal -> "internal" + } + return "KeyCodeTriggerKey(uid=${uid.substring(0..5)}, keyCode=$keyCode, device=$deviceString, clickType=$clickType, consume=$consumeKeyEvent) " + } + + companion object { + fun fromEntity(entity: KeyCodeTriggerKeyEntity): TriggerKey { + return KeyCodeTriggerKey( + uid = entity.uid, + keyCode = entity.keyCode, + device = when (entity.deviceId) { + KeyCodeTriggerKeyEntity.DEVICE_ID_THIS_DEVICE -> TriggerKeyDevice.Internal + KeyCodeTriggerKeyEntity.DEVICE_ID_ANY_DEVICE -> TriggerKeyDevice.Any + else -> TriggerKeyDevice.External( + entity.deviceId, + entity.deviceName ?: "", + ) + }, + clickType = when (entity.clickType) { + TriggerKeyEntity.SHORT_PRESS -> ClickType.SHORT_PRESS + TriggerKeyEntity.LONG_PRESS -> ClickType.LONG_PRESS + TriggerKeyEntity.DOUBLE_PRESS -> ClickType.DOUBLE_PRESS + else -> ClickType.SHORT_PRESS + }, + consumeKeyEvent = !entity.flags.hasFlag(KeyCodeTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT), + ) + } + + fun toEntity(key: KeyCodeTriggerKey): KeyCodeTriggerKeyEntity { + val deviceId = when (key.device) { + TriggerKeyDevice.Any -> KeyCodeTriggerKeyEntity.DEVICE_ID_ANY_DEVICE + is TriggerKeyDevice.External -> key.device.descriptor + TriggerKeyDevice.Internal -> KeyCodeTriggerKeyEntity.DEVICE_ID_THIS_DEVICE + } + + val deviceName = if (key.device is TriggerKeyDevice.External) { + key.device.name + } else { + null + } + + val clickType = when (key.clickType) { + ClickType.SHORT_PRESS -> TriggerKeyEntity.SHORT_PRESS + ClickType.LONG_PRESS -> TriggerKeyEntity.LONG_PRESS + ClickType.DOUBLE_PRESS -> TriggerKeyEntity.DOUBLE_PRESS + } + + var flags = 0 + + if (!key.consumeKeyEvent) { + flags = flags.withFlag(KeyCodeTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT) + } + + return KeyCodeTriggerKeyEntity( + keyCode = key.keyCode, + deviceId = deviceId, + deviceName = deviceName, + clickType = clickType, + flags = flags, + uid = key.uid, + ) + } + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/Trigger.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/Trigger.kt index f93c7ef626..0bc2f1f99c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/Trigger.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/Trigger.kt @@ -42,20 +42,27 @@ data class Trigger( (keys.size == 1 || (mode is TriggerMode.Parallel)) && keys.getOrNull(0)?.clickType == ClickType.LONG_PRESS - fun isDetectingWhenScreenOffAllowed(): Boolean = keys.isNotEmpty() && - keys.all { - KeyEventUtils.canDetectKeyWhenScreenOff(it.keyCode) - } + /** + * Must check that it is not empty otherwise it would be true from the "all" check. + * It is not allowed if the key is an assistant button because it is assumed to be true + * anyway. + */ + fun isDetectingWhenScreenOffAllowed(): Boolean { + return keys.isNotEmpty() && + keys.all { + it is KeyCodeTriggerKey && KeyEventUtils.canDetectKeyWhenScreenOff(it.keyCode) + } + } fun isChangingSequenceTriggerTimeoutAllowed(): Boolean = - !keys.isNullOrEmpty() && keys.size > 1 && mode is TriggerMode.Sequence + keys.isNotEmpty() && keys.size > 1 && mode is TriggerMode.Sequence } object TriggerEntityMapper { fun fromEntity( entity: TriggerEntity, ): Trigger { - val keys = entity.keys.map { TriggerKeyEntityMapper.fromEntity(it) } + val keys = entity.keys.map { TriggerKey.fromEntity(it) } val mode = when { entity.mode == TriggerEntity.SEQUENCE && keys.size > 1 -> TriggerMode.Sequence @@ -158,7 +165,7 @@ object TriggerEntityMapper { } return TriggerEntity( - keys = trigger.keys.map { TriggerKeyEntityMapper.toEntity(it) }, + keys = trigger.keys.map { TriggerKey.toEntity(it) }, extras = extras, mode = mode, flags = flags, diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt index 8046500352..84c58954a2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt @@ -1,90 +1,27 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger +import io.github.sds100.keymapper.data.entities.AssistantTriggerKeyEntity +import io.github.sds100.keymapper.data.entities.KeyCodeTriggerKeyEntity import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import io.github.sds100.keymapper.mappings.ClickType -import kotlinx.serialization.Serializable -import splitties.bitflags.hasFlag -import splitties.bitflags.withFlag -import java.util.UUID -/** - * Created by sds100 on 21/02/2021. - */ -@Serializable -data class TriggerKey( - val uid: String = UUID.randomUUID().toString(), - val keyCode: Int, - val device: TriggerKeyDevice, - val clickType: ClickType, +sealed class TriggerKey { + abstract val clickType: ClickType + abstract val uid: String - val consumeKeyEvent: Boolean = true, -) { - - override fun toString(): String { - val deviceString = when (device) { - TriggerKeyDevice.Any -> "any" - is TriggerKeyDevice.External -> "external" - TriggerKeyDevice.Internal -> "internal" - } - return "TriggerKey(uid=${uid.substring(0..5)}, keyCode=$keyCode, device=$deviceString, clickType=$clickType, consume=$consumeKeyEvent) " - } -} - -object TriggerKeyEntityMapper { - fun fromEntity( - entity: TriggerKeyEntity, - ): TriggerKey = TriggerKey( - uid = entity.uid, - keyCode = entity.keyCode, - device = when (entity.deviceId) { - TriggerKeyEntity.DEVICE_ID_THIS_DEVICE -> TriggerKeyDevice.Internal - TriggerKeyEntity.DEVICE_ID_ANY_DEVICE -> TriggerKeyDevice.Any - else -> TriggerKeyDevice.External( - entity.deviceId, - entity.deviceName ?: "", - ) - }, - clickType = when (entity.clickType) { - TriggerKeyEntity.SHORT_PRESS -> ClickType.SHORT_PRESS - TriggerKeyEntity.LONG_PRESS -> ClickType.LONG_PRESS - TriggerKeyEntity.DOUBLE_PRESS -> ClickType.DOUBLE_PRESS - else -> ClickType.SHORT_PRESS - }, - consumeKeyEvent = !entity.flags.hasFlag(TriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT), - ) - - fun toEntity(key: TriggerKey): TriggerKeyEntity { - val deviceId = when (key.device) { - TriggerKeyDevice.Any -> TriggerKeyEntity.DEVICE_ID_ANY_DEVICE - is TriggerKeyDevice.External -> key.device.descriptor - TriggerKeyDevice.Internal -> TriggerKeyEntity.DEVICE_ID_THIS_DEVICE + companion object { + fun fromEntity(entity: TriggerKeyEntity): TriggerKey { + return when (entity) { + is AssistantTriggerKeyEntity -> AssistantTriggerKey.fromEntity(entity) + is KeyCodeTriggerKeyEntity -> KeyCodeTriggerKey.fromEntity(entity) + } } - val deviceName = if (key.device is TriggerKeyDevice.External) { - key.device.name - } else { - null + fun toEntity(key: TriggerKey): TriggerKeyEntity { + return when (key) { + is AssistantTriggerKey -> AssistantTriggerKey.toEntity(key) + is KeyCodeTriggerKey -> KeyCodeTriggerKey.toEntity(key) + } } - - val clickType = when (key.clickType) { - ClickType.SHORT_PRESS -> TriggerKeyEntity.SHORT_PRESS - ClickType.LONG_PRESS -> TriggerKeyEntity.LONG_PRESS - ClickType.DOUBLE_PRESS -> TriggerKeyEntity.DOUBLE_PRESS - } - - var flags = 0 - - if (!key.consumeKeyEvent) { - flags = flags.withFlag(TriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT) - } - - return TriggerKeyEntity( - keyCode = key.keyCode, - deviceId = deviceId, - deviceName = deviceName, - clickType = clickType, - flags = flags, - uid = key.uid, - ) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceAdapter.kt index 2b39e3cade..b80d8c1343 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/AccessibilityServiceAdapter.kt @@ -181,8 +181,8 @@ class AccessibilityServiceAdapter( settingsIntent.addFlags( Intent.FLAG_ACTIVITY_NEW_TASK - or Intent.FLAG_ACTIVITY_CLEAR_TASK - or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS, + or Intent.FLAG_ACTIVITY_CLEAR_TASK + or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS, ) ctx.startActivity(settingsIntent) diff --git a/app/src/main/res/menu/menu_home.xml b/app/src/main/res/menu/menu_home.xml index 72e6f16a2c..e084e17d38 100644 --- a/app/src/main/res/menu/menu_home.xml +++ b/app/src/main/res/menu/menu_home.xml @@ -7,10 +7,4 @@ android:icon="@drawable/ic_baseline_help_outline_24" android:title="@string/action_help_home" app:showAsAction="ifRoom" /> - - \ No newline at end of file diff --git a/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt b/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt index 1ec372147b..4a823e6001 100644 --- a/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt @@ -4,9 +4,9 @@ import io.github.sds100.keymapper.TestDispatcherProvider import io.github.sds100.keymapper.data.db.dao.KeyMapDao import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.data.entities.Extra +import io.github.sds100.keymapper.data.entities.KeyCodeTriggerKeyEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.data.entities.TriggerEntity -import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import io.github.sds100.keymapper.system.devices.FakeDevicesAdapter import io.github.sds100.keymapper.system.devices.InputDeviceInfo import io.github.sds100.keymapper.util.State @@ -255,7 +255,7 @@ class KeyMapRepositoryTest { fun `key map with device name for trigger key, if device for trigger key is connected, do not update trigger key device name`() = runTest(testDispatcher) { // GIVEN - val triggerKey = TriggerKeyEntity( + val triggerKey = KeyCodeTriggerKeyEntity( keyCode = 1, deviceId = FAKE_KEYBOARD.descriptor, deviceName = FAKE_KEYBOARD.name, @@ -278,7 +278,7 @@ class KeyMapRepositoryTest { fun `key map with device name for trigger key, if device for trigger key is disconnected, do not update trigger key device name`() = runTest(testDispatcher) { // GIVEN - val triggerKey = TriggerKeyEntity( + val triggerKey = KeyCodeTriggerKeyEntity( keyCode = 1, deviceId = FAKE_KEYBOARD.descriptor, deviceName = FAKE_KEYBOARD.name, @@ -299,7 +299,7 @@ class KeyMapRepositoryTest { fun `key map with no device name for trigger key, if device for trigger key is connected, update trigger key device name`() = runTest(testDispatcher) { // GIVEN - val triggerKey = TriggerKeyEntity( + val triggerKey = KeyCodeTriggerKeyEntity( keyCode = 1, deviceId = FAKE_KEYBOARD.descriptor, deviceName = "", diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyCodeTriggerKeyMapFromOtherAppsControllerTest.kt similarity index 98% rename from app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt rename to app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyCodeTriggerKeyMapFromOtherAppsControllerTest.kt index 0ce85607dc..9f78768655 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyCodeTriggerKeyMapFromOtherAppsControllerTest.kt @@ -30,7 +30,7 @@ import org.mockito.kotlin.verify @ExperimentalCoroutinesApi @RunWith(JUnitParamsRunner::class) -class TriggerKeyMapFromOtherAppsControllerTest { +class KeyCodeTriggerKeyMapFromOtherAppsControllerTest { companion object { private const val LONG_PRESS_DELAY = 500L diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt index 21f03e2961..6121563c7b 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt @@ -12,8 +12,8 @@ import io.github.sds100.keymapper.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCase import io.github.sds100.keymapper.mappings.keymaps.detection.KeyMapController +import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger -import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode import io.github.sds100.keymapper.system.camera.CameraLens @@ -3212,7 +3212,7 @@ class KeyMapControllerTest { verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) } - private suspend fun mockTriggerKeyInput(key: TriggerKey, delay: Long? = null) { + private suspend fun mockTriggerKeyInput(key: KeyCodeTriggerKey, delay: Long? = null) { val deviceDescriptor = triggerKeyDeviceToInputDevice(key.device) val pressDuration: Long = delay ?: when (key.clickType) { ClickType.LONG_PRESS -> LONG_PRESS_DELAY + 100L diff --git a/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt b/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt index 9d5508a13e..8fbd2c7a8e 100644 --- a/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt +++ b/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt @@ -1,8 +1,8 @@ package io.github.sds100.keymapper.util import io.github.sds100.keymapper.mappings.ClickType +import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger -import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode @@ -10,17 +10,17 @@ import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode * Created by sds100 on 19/04/2021. */ -fun singleKeyTrigger(key: TriggerKey): Trigger = Trigger( +fun singleKeyTrigger(key: KeyCodeTriggerKey): Trigger = Trigger( keys = listOf(key), mode = TriggerMode.Undefined, ) -fun parallelTrigger(vararg keys: TriggerKey): Trigger = Trigger( +fun parallelTrigger(vararg keys: KeyCodeTriggerKey): Trigger = Trigger( keys = keys.toList(), mode = TriggerMode.Parallel(keys[0].clickType), ) -fun sequenceTrigger(vararg keys: TriggerKey): Trigger = Trigger( +fun sequenceTrigger(vararg keys: KeyCodeTriggerKey): Trigger = Trigger( keys = keys.toList(), mode = TriggerMode.Sequence, ) @@ -30,7 +30,7 @@ fun triggerKey( device: TriggerKeyDevice = TriggerKeyDevice.Internal, clickType: ClickType = ClickType.SHORT_PRESS, consume: Boolean = true, -): TriggerKey = TriggerKey( +): KeyCodeTriggerKey = KeyCodeTriggerKey( keyCode = keyCode, device = device, clickType = clickType, From 4ac83647375038b0f0efbe4045e2613af699ea42 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 5 Oct 2024 22:39:23 +0200 Subject: [PATCH 050/118] delete code that migrates device names for triggers from before version 2.2 because it has been many years and this needs maintaining for new trigger keys unnecessarily See issue #612 for why this was implemented. --- .../github/sds100/keymapper/ServiceLocator.kt | 1 - .../data/repositories/RoomKeyMapRepository.kt | 111 ------------------ 2 files changed, 112 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt index e2df1d2baa..d56e85ba97 100755 --- a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt @@ -70,7 +70,6 @@ object ServiceLocator { synchronized(this) { return roomKeymapRepository ?: RoomKeyMapRepository( database(context).keymapDao(), - devicesAdapter(context), (context.applicationContext as KeyMapperApp).appCoroutineScope, ).also { this.roomKeymapRepository = it diff --git a/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt index 8ab8745a38..697a5c2594 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/repositories/RoomKeyMapRepository.kt @@ -1,25 +1,17 @@ package io.github.sds100.keymapper.data.repositories import io.github.sds100.keymapper.data.db.dao.KeyMapDao -import io.github.sds100.keymapper.data.entities.ActionEntity -import io.github.sds100.keymapper.data.entities.Extra import io.github.sds100.keymapper.data.entities.KeyMapEntity -import io.github.sds100.keymapper.data.entities.TriggerEntity import io.github.sds100.keymapper.mappings.keymaps.KeyMapRepository -import io.github.sds100.keymapper.system.devices.DevicesAdapter import io.github.sds100.keymapper.util.DefaultDispatcherProvider import io.github.sds100.keymapper.util.DispatcherProvider import io.github.sds100.keymapper.util.State -import io.github.sds100.keymapper.util.ifIsData import io.github.sds100.keymapper.util.splitIntoBatches import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.util.UUID @@ -29,7 +21,6 @@ import java.util.UUID */ class RoomKeyMapRepository( private val dao: KeyMapDao, - private val devicesAdapter: DevicesAdapter, private val coroutineScope: CoroutineScope, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), ) : KeyMapRepository { @@ -40,25 +31,10 @@ class RoomKeyMapRepository( override val keyMapList = dao.getAll() .map { State.Data(it) } - .map { state -> - if (fixUnknownDeviceNamesInKeyMaps(state.data)) { - State.Loading - } else { - state - } - } .stateIn(coroutineScope, SharingStarted.Eagerly, State.Loading) override val requestBackup = MutableSharedFlow>() - init { - keyMapList.onEach { keyMapListState -> - keyMapListState.ifIsData { - fixUnknownDeviceNamesInKeyMaps(it) - } - }.flowOn(dispatchers.default()).launchIn(coroutineScope) - } - override fun insert(vararg keyMap: KeyMapEntity) { coroutineScope.launch(dispatchers.default()) { keyMap.splitIntoBatches(MAX_KEY_MAP_BATCH_SIZE).forEach { @@ -128,93 +104,6 @@ class RoomKeyMapRepository( } } - /** - * See issue #612. - * This will check if any triggers or actions have unknown device names and if the device is connected - * then it will update the device name with the correct one. - * This only has to check for uses of devices in Key Mapper 2.2 and older. - * - * @return whether any key maps were updated - */ - private suspend fun fixUnknownDeviceNamesInKeyMaps(keyMapList: List): Boolean { - val keyMapsToUpdate = mutableListOf() - val connectedInputDevices = - devicesAdapter.connectedInputDevices.first { it is State.Data } as State.Data - - if (connectedInputDevices.data.isEmpty()) { - return false - } - - for (keyMap in keyMapList) { - var updateKeyMap = false - - val newTriggerKeys = keyMap.trigger.keys.map { triggerKey -> - if (triggerKey.deviceId != TriggerEntity.KeyEntity.DEVICE_ID_THIS_DEVICE || - triggerKey.deviceId != TriggerEntity.KeyEntity.DEVICE_ID_ANY_DEVICE - ) { - val deviceDescriptor = triggerKey.deviceId - - if (triggerKey.deviceName.isNullOrBlank()) { - val newDeviceName = - connectedInputDevices.data.find { it.descriptor == deviceDescriptor }?.name - - if (newDeviceName != null) { - updateKeyMap = true - - return@map triggerKey.copy( - deviceName = newDeviceName, - ) - } - } - } - - return@map triggerKey - } - - val newActions = keyMap.actionList.map { action -> - if (action.type == ActionEntity.Type.KEY_EVENT) { - val deviceDescriptor = - action.extras.find { it.id == ActionEntity.EXTRA_KEY_EVENT_DEVICE_DESCRIPTOR }?.data - val oldDeviceName = - action.extras.find { it.id == ActionEntity.EXTRA_KEY_EVENT_DEVICE_NAME }?.data - - if (deviceDescriptor != null && oldDeviceName.isNullOrBlank()) { - val newDeviceName = - connectedInputDevices.data.find { it.descriptor == deviceDescriptor }?.name - - if (newDeviceName != null) { - updateKeyMap = true - - val newExtras = action.extras.toMutableList().apply { - removeAll { it.id == ActionEntity.EXTRA_KEY_EVENT_DEVICE_NAME } - add(Extra(ActionEntity.EXTRA_KEY_EVENT_DEVICE_NAME, newDeviceName)) - } - - return@map action.copy(extras = newExtras) - } - } - } - - return@map action - } - - if (updateKeyMap) { - val newKeyMap = keyMap.copy( - trigger = keyMap.trigger.copy(keys = newTriggerKeys), - actionList = newActions, - ) - - keyMapsToUpdate.add(newKeyMap) - } - } - - if (keyMapsToUpdate.isNotEmpty()) { - dao.update(*keyMapsToUpdate.toTypedArray()) - } - - return keyMapsToUpdate.isNotEmpty() - } - private fun requestBackup() { coroutineScope.launch { val keyMapList = keyMapList.first { it is State.Data } as State.Data From 0fe594fc777addb48c3f88337b29847e82e43cf5 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sat, 5 Oct 2024 20:39:58 +0000 Subject: [PATCH 051/118] New Crowdin translations by GitHub Action --- app/src/main/res/values-vi/strings.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 094f2853d8..ca50f4eb77 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -170,7 +170,7 @@ Tùy chọn Hạn chế Hành động - Trigger + Kích hoạt Dấu vân tay @string/tab_keyevents @@ -281,7 +281,7 @@ Màn hình xoay (270°) - Bấm nhanh + Nhấn Nhấn giữ Nhấn đúp @@ -309,7 +309,7 @@ Cài đặt Xong Chọn tất cả - Về + Thông tin Tìm kiếm Hướng dẫn bắt đầu nhanh Help @@ -659,7 +659,7 @@ Shizuku đã được phép! Bạn đã cấp quyền thành công cho Key Mapper Shizuku. Thông tin thêm - Khoản trợ cấp + Cấp quyền Shizuku chưa bắt đầu Shizuku phải được khởi động trước khi bạn cấp quyền cho Key Mapper sử dụng. Nhấn vào \'Khởi chạy Shizuku\' để mở ứng dụng Shizuku để bạn có thể khởi động ứng dụng. Cài đặt Shizuku @@ -667,7 +667,7 @@ Một số tính năng của Key Mapper yêu cầu quyền đăng thông báo, ví dụ: có thông báo tạm dừng/tiếp tục các bản đồ chính của bạn. Nhấn \'cấp\' để cấp quyền. Thông báo có thể được hiển thị! Bạn đã cấp thành công quyền Key Mapper để hiển thị thông báo. - Khoản trợ cấp + Cấp quyền Khả năng tiếp cận @@ -678,7 +678,7 @@ Nhẫn Hệ thống Cuộc gọi thoại - Nhẫn + Chuông Rung Im lặng Đằng trước From 5403af4455c47aba3db1bdda994937355cf96791 Mon Sep 17 00:00:00 2001 From: Seth Schroeder Date: Sun, 6 Oct 2024 10:46:47 +0200 Subject: [PATCH 052/118] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 4 +--- .github/ISSUE_TEMPLATE/new-feature.md | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 6b425409b8..b76001ca56 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,16 +2,14 @@ name: Bug report about: Create a report to help us improve title: '' -labels: bug +labels: bug, needs triage assignees: sds100 --- **Developer TODO (don't remove)** -- [ ] create new branch. put issue number at start of name if not a very quick fix. - [ ] write tests. put issue number in comment - [ ] update documentation -- [ ] merge and delete branch (don't squash because want commit history to see why I made changes) **Discord message link/email recipient** diff --git a/.github/ISSUE_TEMPLATE/new-feature.md b/.github/ISSUE_TEMPLATE/new-feature.md index e794ff0142..677a2e1159 100644 --- a/.github/ISSUE_TEMPLATE/new-feature.md +++ b/.github/ISSUE_TEMPLATE/new-feature.md @@ -2,12 +2,10 @@ name: New Feature about: Add a new feature or enhancement to the app. title: '' -labels: enhancement +labels: enhancement, needs triage assignees: sds100 --- **Developer TODO (don't remove)** -- [ ] create new branch. put issue number at start of name - [ ] update documentation -- [ ] merge and delete branch (don't squash because want commit history to see why I made changes) From b3125be8ddba7ea3382d6f7c413e7b9bcc7a2763 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 6 Oct 2024 10:54:46 +0200 Subject: [PATCH 053/118] #1274 wip: support assistant trigger in keymapcontroller --- .../keymaps/detection/KeyMapController.kt | 31 ++-- .../mappings/keymaps/ConfigKeyMapUseCase.kt | 138 ++++++++++------- .../mappings/keymaps/DisplayKeyMapUseCase.kt | 7 +- .../keymapper/mappings/keymaps/KeyMap.kt | 21 ++- .../mappings/keymaps/KeyMapListItemCreator.kt | 144 +++++++++++------- .../keymaps/trigger/AssistantTriggerKey.kt | 6 +- .../trigger/BaseConfigTriggerViewModel.kt | 41 +++-- .../trigger/ConfigTriggerKeyViewModel.kt | 11 +- .../keymaps/trigger/KeyCodeTriggerKey.kt | 47 +++--- .../mappings/keymaps/trigger/TriggerKey.kt | 30 ++-- .../keymaps/trigger/TriggerKeyDevice.kt | 12 +- .../keymaps/trigger/TriggerKeyListItem.kt | 1 - .../BaseAccessibilityServiceController.kt | 16 +- app/src/main/res/values/strings.xml | 5 + .../keymapper/ConfigKeyMapUseCaseTest.kt | 8 +- .../data/repositories/KeyMapRepositoryTest.kt | 1 - .../mappings/keymaps/KeyMapControllerTest.kt | 72 +++++---- .../sds100/keymapper/util/KeyMapUtils.kt | 9 +- 18 files changed, 367 insertions(+), 233 deletions(-) rename app/src/{main => free}/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt (98%) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt similarity index 98% rename from app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt rename to app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt index 1823999456..36e5ff4df4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt @@ -1492,21 +1492,22 @@ class KeyMapController( this.clickType == event.clickType } - private fun KeyCodeTriggerKey.matchesWithOtherKey(otherKey: KeyCodeTriggerKey): Boolean = when (this.device) { - TriggerKeyDevice.Any -> - this.keyCode == otherKey.keyCode && - this.clickType == otherKey.clickType - - is TriggerKeyDevice.External -> - this.keyCode == otherKey.keyCode && - this.device == otherKey.device && - this.clickType == otherKey.clickType - - TriggerKeyDevice.Internal -> - this.keyCode == otherKey.keyCode && - otherKey.device == TriggerKeyDevice.Internal && - this.clickType == otherKey.clickType - } + private fun KeyCodeTriggerKey.matchesWithOtherKey(otherKey: KeyCodeTriggerKey): Boolean = + when (this.device) { + TriggerKeyDevice.Any -> + this.keyCode == otherKey.keyCode && + this.clickType == otherKey.clickType + + is TriggerKeyDevice.External -> + this.keyCode == otherKey.keyCode && + this.device == otherKey.device && + this.clickType == otherKey.clickType + + TriggerKeyDevice.Internal -> + this.keyCode == otherKey.keyCode && + otherKey.device == TriggerKeyDevice.Internal && + this.clickType == otherKey.clickType + } private fun longPressDelay(trigger: Trigger): Long = trigger.longPressDelay?.toLong() ?: defaultLongPressDelay.value diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt index 9c2089a8cf..bfeaae44ec 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt @@ -9,8 +9,11 @@ import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.BaseConfigMappingUseCase import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.ConfigMappingUseCase +import io.github.sds100.keymapper.mappings.keymaps.trigger.AssistantTriggerKey +import io.github.sds100.keymapper.mappings.keymaps.trigger.AssistantTriggerType import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode import io.github.sds100.keymapper.system.devices.DevicesAdapter @@ -38,36 +41,51 @@ class ConfigKeyMapUseCaseImpl( private val showDeviceDescriptors: Flow = preferenceRepository.get(Keys.showDeviceDescriptors).map { it ?: false } - override fun addTriggerKey( - keyCode: Int, - device: TriggerKeyDevice, - ) = editTrigger { trigger -> + override fun addAssistantTriggerKey(type: AssistantTriggerType) = editTrigger { trigger -> val clickType = when (trigger.mode) { is TriggerMode.Parallel -> trigger.mode.clickType TriggerMode.Sequence -> ClickType.SHORT_PRESS TriggerMode.Undefined -> ClickType.SHORT_PRESS } - val containsKey = trigger.keys.any { keyToCompare -> - if (trigger.mode != TriggerMode.Sequence) { - val sameKeyCode = keyCode == keyToCompare.keyCode + // Check whether the trigger already contains the key because if so + // then it must be converted to a sequence trigger. + val containsKey = trigger.keys.any { it is AssistantTriggerKey } - // if the new key is not external, check whether a trigger key already exists for this device - val sameDevice = when { - keyToCompare.device is TriggerKeyDevice.External && - device is TriggerKeyDevice.External -> - keyToCompare.device.descriptor == device.descriptor + val triggerKey = AssistantTriggerKey(type = type, clickType = clickType) - else -> true - } + val newKeys = trigger.keys.plus(triggerKey) - sameKeyCode && sameDevice - } else { - false - } + val newMode = when { + trigger.mode != TriggerMode.Sequence && containsKey -> TriggerMode.Sequence + newKeys.size <= 1 -> TriggerMode.Undefined + + /* Automatically make it a parallel trigger when the user makes a trigger with more than one key + because this is what most users are expecting when they make a trigger with multiple keys */ + newKeys.size == 2 && !containsKey -> TriggerMode.Parallel(newKeys[0].clickType) + else -> trigger.mode + } + + trigger.copy(keys = newKeys, mode = newMode) + } + + override fun addKeyCodeTriggerKey( + keyCode: Int, + device: TriggerKeyDevice, + ) = editTrigger { trigger -> + val clickType = when (trigger.mode) { + is TriggerMode.Parallel -> trigger.mode.clickType + TriggerMode.Sequence -> ClickType.SHORT_PRESS + TriggerMode.Undefined -> ClickType.SHORT_PRESS } - val newKeys = trigger.keys.toMutableList() + // Check whether the trigger already contains the key because if so + // then it must be converted to a sequence trigger. + val containsKey = trigger.keys + .mapNotNull { it as? KeyCodeTriggerKey } + .any { keyToCompare -> + keyToCompare.keyCode == keyCode && keyToCompare.device.isSameDevice(device) + } var consumeKeyEvent = true @@ -80,18 +98,18 @@ class ConfigKeyMapUseCaseImpl( keyCode = keyCode, device = device, clickType = clickType, - consumeKeyEvent = consumeKeyEvent, + consumeEvent = consumeKeyEvent, ) - newKeys.add(triggerKey) + val newKeys = trigger.keys.plus(triggerKey) val newMode = when { - containsKey -> TriggerMode.Sequence + trigger.mode != TriggerMode.Sequence && containsKey -> TriggerMode.Sequence newKeys.size <= 1 -> TriggerMode.Undefined /* Automatically make it a parallel trigger when the user makes a trigger with more than one key because this is what most users are expecting when they make a trigger with multiple keys */ - newKeys.size == 2 && !containsKey -> TriggerMode.Parallel(clickType) + newKeys.size == 2 && !containsKey -> TriggerMode.Parallel(triggerKey.clickType) else -> trigger.mode } @@ -120,7 +138,9 @@ class ConfigKeyMapUseCaseImpl( } override fun setParallelTriggerMode() = editTrigger { trigger -> - if (trigger.mode is TriggerMode.Parallel) return@editTrigger trigger + if (trigger.mode is TriggerMode.Parallel) { + return@editTrigger trigger + } // undefined mode only allowed if one or no keys if (trigger.keys.size <= 1) { @@ -128,19 +148,19 @@ class ConfigKeyMapUseCaseImpl( } val oldKeys = trigger.keys - var newKeys = oldKeys.toMutableList() - - if (trigger.mode !is TriggerMode.Parallel) { - // set all the keys to a short press if coming from a non-parallel trigger - // because they must all be the same click type and can't all be double pressed - newKeys = newKeys.map { key -> - key.copy(clickType = ClickType.SHORT_PRESS) - }.toMutableList() + var newKeys = oldKeys + // set all the keys to a short press if coming from a non-parallel trigger + // because they must all be the same click type and can't all be double pressed + newKeys = newKeys + .map { key -> key.setClickType(clickType = ClickType.SHORT_PRESS) } // remove duplicates of keys that have the same keycode and device id - newKeys = - newKeys.distinctBy { Pair(it.keyCode, it.device) }.toMutableList() - } + .distinctBy { key -> + when (key) { + is AssistantTriggerKey -> key.type + is KeyCodeTriggerKey -> Pair(key.keyCode, key.device) + } + } val newMode = if (newKeys.size <= 1) { TriggerMode.Undefined @@ -178,7 +198,7 @@ class ConfigKeyMapUseCaseImpl( return@editTrigger oldTrigger } - val newKeys = oldTrigger.keys.map { it.copy(clickType = ClickType.SHORT_PRESS) } + val newKeys = oldTrigger.keys.map { it.setClickType(clickType = ClickType.SHORT_PRESS) } val newMode = if (newKeys.size <= 1) { TriggerMode.Undefined } else { @@ -189,50 +209,65 @@ class ConfigKeyMapUseCaseImpl( } override fun setTriggerLongPress() { - editTrigger { oldTrigger -> - if (oldTrigger.mode == TriggerMode.Sequence) { - return@editTrigger oldTrigger + editTrigger { trigger -> + if (trigger.mode == TriggerMode.Sequence) { + return@editTrigger trigger + } + + // You can't set the trigger to a long press if it contains a key + // that isn't detected with key codes. This is because there aren't + // separate key events for the up and down press that can be timed. + if (trigger.keys.any { it !is KeyCodeTriggerKey }) { + return@editTrigger trigger } - val newKeys = oldTrigger.keys.map { it.copy(clickType = ClickType.LONG_PRESS) } + val newKeys = trigger.keys.map { it.setClickType(clickType = ClickType.LONG_PRESS) } val newMode = if (newKeys.size <= 1) { TriggerMode.Undefined } else { TriggerMode.Parallel(ClickType.LONG_PRESS) } - oldTrigger.copy(keys = newKeys, mode = newMode) + trigger.copy(keys = newKeys, mode = newMode) } } override fun setTriggerDoublePress() { - editTrigger { oldTrigger -> - if (oldTrigger.mode != TriggerMode.Undefined) { - return@editTrigger oldTrigger + editTrigger { trigger -> + if (trigger.mode != TriggerMode.Undefined) { + return@editTrigger trigger } - val newKeys = oldTrigger.keys.map { it.copy(clickType = ClickType.DOUBLE_PRESS) } + val newKeys = trigger.keys.map { it.setClickType(clickType = ClickType.DOUBLE_PRESS) } val newMode = TriggerMode.Undefined - oldTrigger.copy(keys = newKeys, mode = newMode) + trigger.copy(keys = newKeys, mode = newMode) } } override fun setTriggerKeyClickType(keyUid: String, clickType: ClickType) { editTriggerKey(keyUid) { - it.copy(clickType = clickType) + it.setClickType(clickType = clickType) } } override fun setTriggerKeyDevice(keyUid: String, device: TriggerKeyDevice) { editTriggerKey(keyUid) { - it.copy(device = device) + if (it is KeyCodeTriggerKey) { + it.copy(device = device) + } else { + it + } } } override fun setTriggerKeyConsumeKeyEvent(keyUid: String, consumeKeyEvent: Boolean) { editTriggerKey(keyUid) { - it.copy(consumeKeyEvent = consumeKeyEvent) + if (it is KeyCodeTriggerKey) { + it.copy(consumeEvent = consumeKeyEvent) + } else { + it + } } } @@ -443,7 +478,7 @@ class ConfigKeyMapUseCaseImpl( } } - private fun editTriggerKey(uid: String, block: (key: KeyCodeTriggerKey) -> KeyCodeTriggerKey) { + private fun editTriggerKey(uid: String, block: (key: TriggerKey) -> TriggerKey) { editTrigger { oldTrigger -> val newKeys = oldTrigger.keys.map { if (it.uid == uid) { @@ -464,7 +499,8 @@ class ConfigKeyMapUseCaseImpl( interface ConfigKeyMapUseCase : ConfigMappingUseCase { // trigger - fun addTriggerKey(keyCode: Int, device: TriggerKeyDevice) + fun addKeyCodeTriggerKey(keyCode: Int, device: TriggerKeyDevice) + fun addAssistantTriggerKey(type: AssistantTriggerType) fun removeTriggerKey(uid: String) fun moveTriggerKey(fromIndex: Int, toIndex: Int) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt index 849db6ef5b..e97ed6f0f1 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt @@ -5,6 +5,7 @@ import android.view.KeyEvent import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.DisplaySimpleMappingUseCase +import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerError import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.inputmethod.KeyMapperImeHelper @@ -49,7 +50,11 @@ class DisplayKeyMapUseCaseImpl( errors.add(TriggerError.CANT_DETECT_IN_PHONE_CALL) } - if (trigger.keys.any { it.keyCode in keysThatRequireDndAccess }) { + val requiresDndAccess = trigger.keys + .mapNotNull { it as? KeyCodeTriggerKey } + .any { it.keyCode in keysThatRequireDndAccess } + + if (requiresDndAccess) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !permissionAdapter.isGranted(Permission.ACCESS_NOTIFICATION_POLICY) ) { diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt index 65284bd306..edf866e9d6 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMap.kt @@ -9,6 +9,7 @@ import io.github.sds100.keymapper.constraints.ConstraintState import io.github.sds100.keymapper.data.entities.KeyMapEntity import io.github.sds100.keymapper.mappings.Mapping import io.github.sds100.keymapper.mappings.keymaps.detection.KeyMapController +import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerEntityMapper import kotlinx.serialization.Serializable @@ -62,13 +63,25 @@ data class KeyMap( } /** - * @return whether this key map requires an input method to send the key events - * because otherwise it won't be detected. + * Whether this key map requires an input method to detect the key events. + * If the key map needs to answer or end a call then it must use an input method to detect + * the key events because volume key events are not sent to accessibility services when a call + * is incoming. */ -fun KeyMap.requiresImeKeyEventForwarding(): Boolean = - trigger.keys.any { it.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || it.keyCode == KeyEvent.KEYCODE_VOLUME_UP } && +fun KeyMap.requiresImeKeyEventForwarding(): Boolean { + val hasPhoneCallAction = actionList.any { it.data is ActionData.AnswerCall || it.data is ActionData.EndCall } + val hasVolumeKeys = trigger.keys + .mapNotNull { it as? KeyCodeTriggerKey } + .any { + it.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + it.keyCode == KeyEvent.KEYCODE_VOLUME_UP + } + + return hasVolumeKeys && hasPhoneCallAction +} + object KeyMapEntityMapper { fun fromEntity(entity: KeyMapEntity): KeyMap { val actionList = entity.actionList.mapNotNull { KeymapActionEntityMapper.fromEntity(it) } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt index 0e4ece49bb..3491f35ace 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt @@ -3,6 +3,9 @@ package io.github.sds100.keymapper.mappings.keymaps import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.BaseMappingListItemCreator import io.github.sds100.keymapper.mappings.ClickType +import io.github.sds100.keymapper.mappings.keymaps.trigger.AssistantTriggerKey +import io.github.sds100.keymapper.mappings.keymaps.trigger.AssistantTriggerType +import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerError import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice @@ -25,13 +28,17 @@ class KeyMapListItemCreator( KeyMapActionUiHelper(displayMapping, resourceProvider), resourceProvider, ) { + private val midDot by lazy { getString(R.string.middot) } + private val longPressString by lazy { getString(R.string.clicktype_long_press) } + private val doublePressString by lazy { getString(R.string.clicktype_double_press) } + private val anyAssistantString by lazy { getString(R.string.assistant_any_trigger_name) } + private val voiceAssistantString by lazy { getString(R.string.assistant_voice_trigger_name) } + private val deviceAssistantString by lazy { getString(R.string.assistant_device_trigger_name) } suspend fun create( keyMap: KeyMap, showDeviceDescriptors: Boolean, ): KeyMapListItem.KeyMapUiState { - val midDot = getString(R.string.middot) - val triggerDescription = buildString { val separator = when (keyMap.trigger.mode) { is TriggerMode.Parallel -> getString(R.string.plus) @@ -39,46 +46,15 @@ class KeyMapListItemCreator( is TriggerMode.Undefined -> null } - val longPressString = getString(R.string.clicktype_long_press) - val doublePressString = getString(R.string.clicktype_double_press) - keyMap.trigger.keys.forEachIndexed { index, key -> if (index > 0) { append(" $separator ") } - when (key.clickType) { - ClickType.LONG_PRESS -> append(longPressString).append(" ") - ClickType.DOUBLE_PRESS -> append(doublePressString).append(" ") - else -> Unit - } - - append(KeyEventUtils.keyCodeToString(key.keyCode)) - - val deviceName = when (key.device) { - is TriggerKeyDevice.Internal -> getString(R.string.this_device) - is TriggerKeyDevice.Any -> getString(R.string.any_device) - is TriggerKeyDevice.External -> { - if (showDeviceDescriptors) { - InputDeviceUtils.appendDeviceDescriptorToName( - key.device.descriptor, - key.device.name, - ) - } else { - key.device.name - } - } - } - - append(" (") - - append(deviceName) - - if (!key.consumeKeyEvent) { - append(" $midDot ${getString(R.string.flag_dont_override_default_action)}") + when (key) { + is AssistantTriggerKey -> appendAssistantTriggerKeyName(key) + is KeyCodeTriggerKey -> appendKeyCodeTriggerKeyName(key, showDeviceDescriptors) } - - append(")") } } @@ -109,28 +85,7 @@ class KeyMapListItemCreator( val triggerErrors = displayMapping.getTriggerErrors(keyMap) - val triggerErrorChips = triggerErrors.map { - when (it) { - TriggerError.DND_ACCESS_DENIED -> - ChipUi.Error( - id = TriggerError.DND_ACCESS_DENIED.toString(), - text = getString(R.string.trigger_error_dnd_access_denied_short), - error = Error.PermissionDenied(Permission.ACCESS_NOTIFICATION_POLICY), - ) - - TriggerError.SCREEN_OFF_ROOT_DENIED -> ChipUi.Error( - id = TriggerError.SCREEN_OFF_ROOT_DENIED.toString(), - text = getString(R.string.trigger_error_screen_off_root_permission_denied_short), - error = Error.PermissionDenied(Permission.ROOT), - ) - - TriggerError.CANT_DETECT_IN_PHONE_CALL -> ChipUi.Error( - id = TriggerError.SCREEN_OFF_ROOT_DENIED.toString(), - text = getString(R.string.trigger_error_cant_detect_in_phone_call), - error = Error.CantDetectKeyEventsInPhoneCall, - ) - } - } + val triggerErrorChips = triggerErrors.map(this::getTriggerChipError) return KeyMapListItem.KeyMapUiState( uid = keyMap.uid, @@ -143,6 +98,79 @@ class KeyMapListItemCreator( ) } + private fun getTriggerChipError(error: TriggerError): ChipUi.Error = + when (error) { + TriggerError.DND_ACCESS_DENIED -> + ChipUi.Error( + id = TriggerError.DND_ACCESS_DENIED.toString(), + text = getString(R.string.trigger_error_dnd_access_denied_short), + error = Error.PermissionDenied(Permission.ACCESS_NOTIFICATION_POLICY), + ) + + TriggerError.SCREEN_OFF_ROOT_DENIED -> ChipUi.Error( + id = TriggerError.SCREEN_OFF_ROOT_DENIED.toString(), + text = getString(R.string.trigger_error_screen_off_root_permission_denied_short), + error = Error.PermissionDenied(Permission.ROOT), + ) + + TriggerError.CANT_DETECT_IN_PHONE_CALL -> ChipUi.Error( + id = TriggerError.SCREEN_OFF_ROOT_DENIED.toString(), + text = getString(R.string.trigger_error_cant_detect_in_phone_call), + error = Error.CantDetectKeyEventsInPhoneCall, + ) + } + + private fun StringBuilder.appendKeyCodeTriggerKeyName( + key: KeyCodeTriggerKey, + showDeviceDescriptors: Boolean, + ) { + when (key.clickType) { + ClickType.LONG_PRESS -> append(longPressString).append(" ") + ClickType.DOUBLE_PRESS -> append(doublePressString).append(" ") + else -> Unit + } + + append(KeyEventUtils.keyCodeToString(key.keyCode)) + + val deviceName = when (key.device) { + is TriggerKeyDevice.Internal -> getString(R.string.this_device) + is TriggerKeyDevice.Any -> getString(R.string.any_device) + is TriggerKeyDevice.External -> { + if (showDeviceDescriptors) { + InputDeviceUtils.appendDeviceDescriptorToName( + key.device.descriptor, + key.device.name, + ) + } else { + key.device.name + } + } + } + + append(" (") + + append(deviceName) + + if (!key.consumeEvent) { + append(" $midDot ${getString(R.string.flag_dont_override_default_action)}") + } + + append(")") + } + + private fun StringBuilder.appendAssistantTriggerKeyName(key: AssistantTriggerKey) { + when (key.clickType) { + ClickType.DOUBLE_PRESS -> append(doublePressString).append(" ") + else -> Unit + } + + when (key.type) { + AssistantTriggerType.ANY -> append(anyAssistantString) + AssistantTriggerType.VOICE -> append(voiceAssistantString) + AssistantTriggerType.DEVICE -> append(deviceAssistantString) + } + } + private fun getTriggerOptionLabels(trigger: Trigger): List { val labels = mutableListOf() diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt index b2effb48c0..a5fe9dce05 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt @@ -11,10 +11,12 @@ data class AssistantTriggerKey( override val uid: String = UUID.randomUUID().toString(), val type: AssistantTriggerType, override val clickType: ClickType, - - val consumeKeyEvent: Boolean = true, ) : TriggerKey() { + // This is always true for an assistant key event because Key Mapper can't forward the + // assistant event to another app (or can it??). + override val consumeEvent: Boolean = true + companion object { fun fromEntity( entity: AssistantTriggerKeyEntity, diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt index f1723376c7..048e5b09ae 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt @@ -227,7 +227,7 @@ abstract class BaseConfigTriggerViewModel( showPopup("screen_pinning_message", dialog) } - config.addTriggerKey(it.keyCode, it.device) + config.addKeyCodeTriggerKey(it.keyCode, it.device) }.launchIn(coroutineScope) coroutineScope.launch { @@ -415,15 +415,6 @@ abstract class BaseConfigTriggerViewModel( showDeviceDescriptors: Boolean, ): List = trigger.keys.mapIndexed { index, key -> - val extraInfo = buildString { - append(getTriggerKeyDeviceName(key.device, showDeviceDescriptors)) - - if (!key.consumeKeyEvent) { - val midDot = getString(R.string.middot) - append(" $midDot ${getString(R.string.flag_dont_override_default_action)}") - } - } - val clickTypeString = when (key.clickType) { ClickType.SHORT_PRESS -> null ClickType.LONG_PRESS -> getString(R.string.clicktype_long_press) @@ -438,15 +429,39 @@ abstract class BaseConfigTriggerViewModel( TriggerKeyListItem( id = key.uid, - keyCode = key.keyCode, - name = KeyEventUtils.keyCodeToString(key.keyCode), + name = getTriggerKeyName(key), clickTypeString = clickTypeString, - extraInfo = extraInfo, + extraInfo = getTriggerKeyExtraInfo(key, showDeviceDescriptors), linkType = linkDrawable, isDragDropEnabled = trigger.keys.size > 1, ) } + private fun getTriggerKeyExtraInfo(key: TriggerKey, showDeviceDescriptors: Boolean): String? { + if (key !is KeyCodeTriggerKey) { + return null + } + + return buildString { + append(getTriggerKeyDeviceName(key.device, showDeviceDescriptors)) + + if (!key.consumeEvent) { + val midDot = getString(R.string.middot) + append(" $midDot ${getString(R.string.flag_dont_override_default_action)}") + } + } + } + + private fun getTriggerKeyName(key: TriggerKey): String = when (key) { + is AssistantTriggerKey -> when (key.type) { + AssistantTriggerType.ANY -> getString(R.string.assistant_any_trigger_name) + AssistantTriggerType.VOICE -> getString(R.string.assistant_voice_trigger_name) + AssistantTriggerType.DEVICE -> getString(R.string.assistant_device_trigger_name) + } + + is KeyCodeTriggerKey -> KeyEventUtils.keyCodeToString(key.keyCode) + } + private fun getTriggerKeyDeviceName( device: TriggerKeyDevice, showDeviceDescriptors: Boolean, diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerKeyViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerKeyViewModel.kt index 63e71fbe77..c25a3bff47 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerKeyViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerKeyViewModel.kt @@ -84,12 +84,16 @@ class ConfigTriggerKeyViewModel( triggerKeyUid.value = uid } - private fun createListItems(triggerMode: TriggerMode, key: KeyCodeTriggerKey): List = - sequence { + private fun createListItems(triggerMode: TriggerMode, key: TriggerKey): List { + if (key !is KeyCodeTriggerKey) { + return emptyList() + } + + return sequence { yield( CheckBoxListItem( id = ID_DONT_CONSUME_KEY_EVENT, - isChecked = !key.consumeKeyEvent, + isChecked = !key.consumeEvent, label = getString(R.string.flag_dont_override_default_action), ), ) @@ -115,4 +119,5 @@ class ConfigTriggerKeyViewModel( ) } }.toList() + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyCodeTriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyCodeTriggerKey.kt index 5c011179be..5c5bdcd926 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyCodeTriggerKey.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/KeyCodeTriggerKey.kt @@ -14,8 +14,7 @@ data class KeyCodeTriggerKey( val keyCode: Int, val device: TriggerKeyDevice, override val clickType: ClickType, - - val consumeKeyEvent: Boolean = true, + override val consumeEvent: Boolean = true, ) : TriggerKey() { override fun toString(): String { @@ -24,31 +23,29 @@ data class KeyCodeTriggerKey( is TriggerKeyDevice.External -> "external" TriggerKeyDevice.Internal -> "internal" } - return "KeyCodeTriggerKey(uid=${uid.substring(0..5)}, keyCode=$keyCode, device=$deviceString, clickType=$clickType, consume=$consumeKeyEvent) " + return "KeyCodeTriggerKey(uid=${uid.substring(0..5)}, keyCode=$keyCode, device=$deviceString, clickType=$clickType, consume=$consumeEvent) " } companion object { - fun fromEntity(entity: KeyCodeTriggerKeyEntity): TriggerKey { - return KeyCodeTriggerKey( - uid = entity.uid, - keyCode = entity.keyCode, - device = when (entity.deviceId) { - KeyCodeTriggerKeyEntity.DEVICE_ID_THIS_DEVICE -> TriggerKeyDevice.Internal - KeyCodeTriggerKeyEntity.DEVICE_ID_ANY_DEVICE -> TriggerKeyDevice.Any - else -> TriggerKeyDevice.External( - entity.deviceId, - entity.deviceName ?: "", - ) - }, - clickType = when (entity.clickType) { - TriggerKeyEntity.SHORT_PRESS -> ClickType.SHORT_PRESS - TriggerKeyEntity.LONG_PRESS -> ClickType.LONG_PRESS - TriggerKeyEntity.DOUBLE_PRESS -> ClickType.DOUBLE_PRESS - else -> ClickType.SHORT_PRESS - }, - consumeKeyEvent = !entity.flags.hasFlag(KeyCodeTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT), - ) - } + fun fromEntity(entity: KeyCodeTriggerKeyEntity): TriggerKey = KeyCodeTriggerKey( + uid = entity.uid, + keyCode = entity.keyCode, + device = when (entity.deviceId) { + KeyCodeTriggerKeyEntity.DEVICE_ID_THIS_DEVICE -> TriggerKeyDevice.Internal + KeyCodeTriggerKeyEntity.DEVICE_ID_ANY_DEVICE -> TriggerKeyDevice.Any + else -> TriggerKeyDevice.External( + entity.deviceId, + entity.deviceName ?: "", + ) + }, + clickType = when (entity.clickType) { + TriggerKeyEntity.SHORT_PRESS -> ClickType.SHORT_PRESS + TriggerKeyEntity.LONG_PRESS -> ClickType.LONG_PRESS + TriggerKeyEntity.DOUBLE_PRESS -> ClickType.DOUBLE_PRESS + else -> ClickType.SHORT_PRESS + }, + consumeEvent = !entity.flags.hasFlag(KeyCodeTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT), + ) fun toEntity(key: KeyCodeTriggerKey): KeyCodeTriggerKeyEntity { val deviceId = when (key.device) { @@ -71,7 +68,7 @@ data class KeyCodeTriggerKey( var flags = 0 - if (!key.consumeKeyEvent) { + if (!key.consumeEvent) { flags = flags.withFlag(KeyCodeTriggerKeyEntity.FLAG_DO_NOT_CONSUME_KEY_EVENT) } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt index 84c58954a2..27a5d49b51 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKey.kt @@ -4,24 +4,34 @@ import io.github.sds100.keymapper.data.entities.AssistantTriggerKeyEntity import io.github.sds100.keymapper.data.entities.KeyCodeTriggerKeyEntity import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import io.github.sds100.keymapper.mappings.ClickType +import kotlinx.serialization.Serializable +@Serializable sealed class TriggerKey { abstract val clickType: ClickType + + /** + * Whether the event that triggers this key will be consumed and not passed + * onto subsequent apps. E.g consuming the volume down key event will mean the volume + * doesn't change. + */ + abstract val consumeEvent: Boolean abstract val uid: String companion object { - fun fromEntity(entity: TriggerKeyEntity): TriggerKey { - return when (entity) { - is AssistantTriggerKeyEntity -> AssistantTriggerKey.fromEntity(entity) - is KeyCodeTriggerKeyEntity -> KeyCodeTriggerKey.fromEntity(entity) - } + fun fromEntity(entity: TriggerKeyEntity): TriggerKey = when (entity) { + is AssistantTriggerKeyEntity -> AssistantTriggerKey.fromEntity(entity) + is KeyCodeTriggerKeyEntity -> KeyCodeTriggerKey.fromEntity(entity) } - fun toEntity(key: TriggerKey): TriggerKeyEntity { - return when (key) { - is AssistantTriggerKey -> AssistantTriggerKey.toEntity(key) - is KeyCodeTriggerKey -> KeyCodeTriggerKey.toEntity(key) - } + fun toEntity(key: TriggerKey): TriggerKeyEntity = when (key) { + is AssistantTriggerKey -> AssistantTriggerKey.toEntity(key) + is KeyCodeTriggerKey -> KeyCodeTriggerKey.toEntity(key) } } + + fun setClickType(clickType: ClickType): TriggerKey = when (this) { + is AssistantTriggerKey -> copy(clickType = clickType) + is KeyCodeTriggerKey -> copy(clickType = clickType) + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyDevice.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyDevice.kt index cb53febc84..0c303265c7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyDevice.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyDevice.kt @@ -2,10 +2,6 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger import kotlinx.serialization.Serializable -/** - * Created by sds100 on 21/02/2021. - */ - @Serializable sealed class TriggerKeyDevice { @Serializable @@ -16,4 +12,12 @@ sealed class TriggerKeyDevice { @Serializable data class External(val descriptor: String, val name: String) : TriggerKeyDevice() + + fun isSameDevice(other: TriggerKeyDevice): Boolean { + if (other is External && this is External) { + return other.descriptor == this.descriptor + } else { + return true + } + } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyListItem.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyListItem.kt index b741580786..27f5c554b8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyListItem.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyListItem.kt @@ -5,7 +5,6 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger */ data class TriggerKeyListItem( val id: String, - val keyCode: Int, val name: String, /** diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt index 9f8d85ab00..fb4267bae4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/BaseAccessibilityServiceController.kt @@ -92,7 +92,7 @@ abstract class BaseAccessibilityServiceController( detectConstraintsUseCase, ) - private val keyMapController = KeyMapController( + val keyMapController = KeyMapController( coroutineScope, detectKeyMapsUseCase, performActionsUseCase, @@ -108,7 +108,7 @@ abstract class BaseAccessibilityServiceController( private val recordingTrigger: Boolean get() = recordingTriggerJob != null && recordingTriggerJob?.isActive == true - private val isPaused: StateFlow = pauseMappingsUseCase.isPaused + val isPaused: StateFlow = pauseMappingsUseCase.isPaused .stateIn(coroutineScope, SharingStarted.Eagerly, false) private val screenOffTriggersEnabled: StateFlow = @@ -320,7 +320,12 @@ abstract class BaseAccessibilityServiceController( return true } - if (!isPaused.value) { + if (isPaused.value) { + when (action) { + KeyEvent.ACTION_DOWN -> Timber.d("Down ${KeyEvent.keyCodeToString(keyCode)} - not filtering because paused, $detailedLogInfo") + KeyEvent.ACTION_UP -> Timber.d("Up ${KeyEvent.keyCodeToString(keyCode)} - not filtering because paused, $detailedLogInfo") + } + } else { try { var consume: Boolean @@ -351,11 +356,6 @@ abstract class BaseAccessibilityServiceController( } catch (e: Exception) { Timber.e(e) } - } else { - when (action) { - KeyEvent.ACTION_DOWN -> Timber.d("Down ${KeyEvent.keyCodeToString(keyCode)} - not filtering because paused, $detailedLogInfo") - KeyEvent.ACTION_UP -> Timber.d("Up ${KeyEvent.keyCodeToString(keyCode)} - not filtering because paused, $detailedLogInfo") - } } return false diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 20b83ce663..78b4ac2a1e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1373,4 +1373,9 @@ https://github.com/bydariogamer Translator (Spanish) + + + Any assistant + Device assistant + Voice assistant diff --git a/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt b/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt index f412bc7d8d..4a3e07767d 100644 --- a/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt @@ -69,12 +69,12 @@ class ConfigKeyMapUseCaseTest { useCase.mapping.value = State.Data(KeyMap()) // WHEN - useCase.addTriggerKey(modifierKeyCode, TriggerKeyDevice.Internal) + useCase.addKeyCodeTriggerKey(modifierKeyCode, TriggerKeyDevice.Internal) // THEN val trigger = useCase.mapping.value.dataOrNull()!!.trigger - assertThat(trigger.keys[0].consumeKeyEvent, `is`(false)) + assertThat(trigger.keys[0].consumeEvent, `is`(false)) } } @@ -88,12 +88,12 @@ class ConfigKeyMapUseCaseTest { useCase.mapping.value = State.Data(KeyMap()) // WHEN - useCase.addTriggerKey(KeyEvent.KEYCODE_A, TriggerKeyDevice.Internal) + useCase.addKeyCodeTriggerKey(KeyEvent.KEYCODE_A, TriggerKeyDevice.Internal) // THEN val trigger = useCase.mapping.value.dataOrNull()!!.trigger - assertThat(trigger.keys[0].consumeKeyEvent, `is`(true)) + assertThat(trigger.keys[0].consumeEvent, `is`(true)) } /** diff --git a/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt b/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt index 4a823e6001..e9e2cd0624 100644 --- a/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt @@ -65,7 +65,6 @@ class KeyMapRepositoryTest { repository = RoomKeyMapRepository( mockDao, - devicesAdapter, testScope, dispatchers = TestDispatcherProvider(testDispatcher), ) diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt index 6121563c7b..2f4b687aef 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt @@ -14,6 +14,7 @@ import io.github.sds100.keymapper.mappings.keymaps.detection.DetectKeyMapsUseCas import io.github.sds100.keymapper.mappings.keymaps.detection.KeyMapController import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode import io.github.sds100.keymapper.system.camera.CameraLens @@ -2040,7 +2041,7 @@ class KeyMapControllerTest { listOf(KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION))) // when - trigger.keys[1].let { + (trigger.keys[1] as KeyCodeTriggerKey).let { inputKeyEvent( it.keyCode, KeyEvent.ACTION_DOWN, @@ -2048,7 +2049,7 @@ class KeyMapControllerTest { ) } - trigger.keys[1].let { + (trigger.keys[1] as KeyCodeTriggerKey).let { val consumed = inputKeyEvent( it.keyCode, KeyEvent.ACTION_UP, @@ -2090,22 +2091,22 @@ class KeyMapControllerTest { listOf(KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION))) // when - trigger.keys.forEach { + for (key in trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { inputKeyEvent( - it.keyCode, + key.keyCode, KeyEvent.ACTION_DOWN, - triggerKeyDeviceToInputDevice(it.device), + triggerKeyDeviceToInputDevice(key.device), ) } var consumedUpCount = 0 - trigger.keys.forEach { + for (key in trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { val consumed = inputKeyEvent( - it.keyCode, + key.keyCode, KeyEvent.ACTION_UP, - triggerKeyDeviceToInputDevice(it.device), + triggerKeyDeviceToInputDevice(key.device), ) if (consumed) { @@ -2129,11 +2130,11 @@ class KeyMapControllerTest { listOf(KeyMap(0, trigger = trigger, actionList = listOf(TEST_ACTION))) // when - trigger.keys.forEach { + for (key in trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { inputKeyEvent( - it.keyCode, + key.keyCode, KeyEvent.ACTION_DOWN, - triggerKeyDeviceToInputDevice(it.device), + triggerKeyDeviceToInputDevice(key.device), ) } @@ -2141,12 +2142,12 @@ class KeyMapControllerTest { var consumedUpCount = 0 - trigger.keys.forEach { + for (key in trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { val consumed = inputKeyEvent( - it.keyCode, + key.keyCode, KeyEvent.ACTION_UP, - triggerKeyDeviceToInputDevice(it.device), + triggerKeyDeviceToInputDevice(key.device), ) if (consumed) { @@ -2366,12 +2367,12 @@ class KeyMapControllerTest { // WHEN var consumedCount = 0 - keyMap.trigger.keys.forEach { + for (key in keyMap.trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { val consumed = inputKeyEvent( 999, KeyEvent.ACTION_DOWN, - triggerKeyDeviceToInputDevice(it.device), + triggerKeyDeviceToInputDevice(key.device), ) if (consumed) { @@ -2393,12 +2394,12 @@ class KeyMapControllerTest { var consumedCount = 0 - keyMap.trigger.keys.forEach { + for (key in keyMap.trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { val consumed = inputKeyEvent( - it.keyCode, + key.keyCode, KeyEvent.ACTION_DOWN, - triggerKeyDeviceToInputDevice(it.device), + triggerKeyDeviceToInputDevice(key.device), ) if (consumed) { @@ -2418,12 +2419,12 @@ class KeyMapControllerTest { var consumedCount = 0 - keyMap.trigger.keys.forEach { + for (key in keyMap.trigger.keys.mapNotNull { it as? KeyCodeTriggerKey }) { val consumed = inputKeyEvent( - it.keyCode, + key.keyCode, KeyEvent.ACTION_DOWN, - triggerKeyDeviceToInputDevice(it.device), + triggerKeyDeviceToInputDevice(key.device), ) if (consumed) { @@ -3212,7 +3213,11 @@ class KeyMapControllerTest { verify(performActionsUseCase, times(1)).perform(TEST_ACTION.data) } - private suspend fun mockTriggerKeyInput(key: KeyCodeTriggerKey, delay: Long? = null) { + private suspend fun mockTriggerKeyInput(key: TriggerKey, delay: Long? = null) { + if (key !is KeyCodeTriggerKey) { + return + } + val deviceDescriptor = triggerKeyDeviceToInputDevice(key.device) val pressDuration: Long = delay ?: when (key.clickType) { ClickType.LONG_PRESS -> LONG_PRESS_DELAY + 100L @@ -3263,11 +3268,16 @@ class KeyMapControllerTest { delay: Long? = null, ) { require(trigger.mode is TriggerMode.Parallel) + require(trigger.keys.all { it is KeyCodeTriggerKey }) + + for (key in trigger.keys) { + if (key !is KeyCodeTriggerKey) { + continue + } - trigger.keys.forEach { - val deviceDescriptor = triggerKeyDeviceToInputDevice(it.device) + val deviceDescriptor = triggerKeyDeviceToInputDevice(key.device) - inputKeyEvent(it.keyCode, KeyEvent.ACTION_DOWN, deviceDescriptor) + inputKeyEvent(key.keyCode, KeyEvent.ACTION_DOWN, deviceDescriptor) } if (delay != null) { @@ -3280,10 +3290,14 @@ class KeyMapControllerTest { } } - trigger.keys.forEach { - val deviceDescriptor = triggerKeyDeviceToInputDevice(it.device) + for (key in trigger.keys) { + if (key !is KeyCodeTriggerKey) { + continue + } + + val deviceDescriptor = triggerKeyDeviceToInputDevice(key.device) - inputKeyEvent(it.keyCode, KeyEvent.ACTION_UP, deviceDescriptor) + inputKeyEvent(key.keyCode, KeyEvent.ACTION_UP, deviceDescriptor) } } diff --git a/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt b/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt index 8fbd2c7a8e..fdfaf78f6e 100644 --- a/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt +++ b/app/src/test/java/io/github/sds100/keymapper/util/KeyMapUtils.kt @@ -3,6 +3,7 @@ package io.github.sds100.keymapper.util import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode @@ -10,17 +11,17 @@ import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode * Created by sds100 on 19/04/2021. */ -fun singleKeyTrigger(key: KeyCodeTriggerKey): Trigger = Trigger( +fun singleKeyTrigger(key: TriggerKey): Trigger = Trigger( keys = listOf(key), mode = TriggerMode.Undefined, ) -fun parallelTrigger(vararg keys: KeyCodeTriggerKey): Trigger = Trigger( +fun parallelTrigger(vararg keys: TriggerKey): Trigger = Trigger( keys = keys.toList(), mode = TriggerMode.Parallel(keys[0].clickType), ) -fun sequenceTrigger(vararg keys: KeyCodeTriggerKey): Trigger = Trigger( +fun sequenceTrigger(vararg keys: TriggerKey): Trigger = Trigger( keys = keys.toList(), mode = TriggerMode.Sequence, ) @@ -34,5 +35,5 @@ fun triggerKey( keyCode = keyCode, device = device, clickType = clickType, - consumeKeyEvent = consume, + consumeEvent = consume, ) From 11752b10174db48df1af6338730841efe2185b4c Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 6 Oct 2024 16:38:31 +0200 Subject: [PATCH 054/118] #1274 move all strings for advanced triggers into the public repo --- app/src/main/res/values/strings.xml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 78b4ac2a1e..da02a7cb67 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1374,8 +1374,13 @@ Translator (Spanish) - + Any assistant Device assistant Voice assistant + Advanced triggers + The developer doesn\'t believe ads are a sustainable or user-friendly form of monetization so these paid triggers help support development ❤️. You will be given priority support as well. + Assistant trigger + Did you know you can remap your device assistant? Instead of launching Google Assistant or Bixby, your device can perform an action of your choice. It works even when the screen is off! + Unlock (%s) From 4cf532fffa1bc3897b047661bb03895df791d158 Mon Sep 17 00:00:00 2001 From: Seth Schroeder Date: Tue, 8 Oct 2024 17:47:35 +0200 Subject: [PATCH 055/118] readme: Hide translation badges until the shield link is fixed --- README.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e03504b293..1833d7447e 100644 --- a/README.md +++ b/README.md @@ -27,16 +27,22 @@ Key Mapper is a free and open source Android app that can remap your buttons and 🎉 Check out the [website](https://docs.keymapper.club) for more information and help! 🎉 -## Translations +[//]: # (## Translations) -![cs translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Czech&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27cs%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) -![es-ES translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Spanish&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27es-ES%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) -![pl translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Polish&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27pl%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) -![ru translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Russian&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27ru%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) -![sk translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Slovak&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27sk%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) -![vi translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Vietnamese&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27vi%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) -![zh-CN translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Chinese%20(Simplified)&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27zh-CN%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) +[//]: # () +[//]: # (![cs translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Czech&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27cs%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) +[//]: # (![es-ES translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Spanish&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27es-ES%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) + +[//]: # (![pl translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Polish&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27pl%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) + +[//]: # (![ru translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Russian&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27ru%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) + +[//]: # (![sk translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Slovak&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27sk%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) + +[//]: # (![vi translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Vietnamese&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27vi%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) + +[//]: # (![zh-CN translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Chinese%20(Simplified)&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27zh-CN%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=keymapperorg/KeyMapper&type=Date)](https://star-history.com/#keymapperorg/KeyMapper&Date) From 3521dac5b03315f594a6be3d66ba4cbf4b4e9919 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 8 Oct 2024 18:04:43 +0200 Subject: [PATCH 056/118] tests: remove tests for issue #612. See commit 4ac83647375038b0f0efbe4045e2613af699ea42 --- .../data/repositories/KeyMapRepositoryTest.kt | 220 ------------------ 1 file changed, 220 deletions(-) diff --git a/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt b/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt index 9a390733d9..08c4660ad2 100644 --- a/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/data/repositories/KeyMapRepositoryTest.kt @@ -2,13 +2,9 @@ package io.github.sds100.keymapper.data.repositories import io.github.sds100.keymapper.TestDispatcherProvider import io.github.sds100.keymapper.data.db.dao.KeyMapDao -import io.github.sds100.keymapper.data.entities.ActionEntity -import io.github.sds100.keymapper.data.entities.Extra import io.github.sds100.keymapper.data.entities.KeyMapEntity -import io.github.sds100.keymapper.data.entities.TriggerEntity import io.github.sds100.keymapper.system.devices.FakeDevicesAdapter import io.github.sds100.keymapper.system.devices.InputDeviceInfo -import io.github.sds100.keymapper.util.State import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.TestScope @@ -18,13 +14,10 @@ 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.anyVararg 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 /** * Created by sds100 on 01/05/2021. @@ -64,7 +57,6 @@ class KeyMapRepositoryTest { repository = RoomKeyMapRepository( mockDao, - devicesAdapter, testScope, dispatchers = TestDispatcherProvider(testDispatcher), ) @@ -107,216 +99,4 @@ class KeyMapRepositoryTest { verify(mockDao, times(5)).update(anyVararg()) } } - - @Test - fun `key map with key event action from device and proper device name extra, do not update action device name`() = - runTest(testDispatcher) { - // GIVEN - val action = ActionEntity( - type = ActionEntity.Type.KEY_EVENT, - data = "1", - extras = listOf( - Extra(ActionEntity.EXTRA_KEY_EVENT_DEVICE_DESCRIPTOR, FAKE_KEYBOARD.descriptor), - Extra(ActionEntity.EXTRA_KEY_EVENT_DEVICE_NAME, FAKE_KEYBOARD.name), - ), - ) - - val keyMap = KeyMapEntity(id = 0, actionList = listOf(action)) - - devicesAdapter.connectedInputDevices.value = State.Data(listOf(FAKE_KEYBOARD)) - - // WHEN - keyMaps.emit(listOf(keyMap)) - - // THEN - verify(mockDao, never()).update(any()) - } - - @Test - fun `key map with key event action from device and blank device name extra, if device for action is disconnected, do not update action device name`() = - runTest(testDispatcher) { - // GIVEN - val action = ActionEntity( - type = ActionEntity.Type.KEY_EVENT, - data = "1", - extras = listOf( - Extra(ActionEntity.EXTRA_KEY_EVENT_DEVICE_DESCRIPTOR, FAKE_KEYBOARD.descriptor), - Extra(ActionEntity.EXTRA_KEY_EVENT_DEVICE_NAME, ""), - ), - ) - - val keyMap = KeyMapEntity(id = 0, actionList = listOf(action)) - - devicesAdapter.connectedInputDevices.value = State.Data(emptyList()) - - // WHEN - keyMaps.emit(listOf(keyMap)) - - // THEN - verify(mockDao, never()).update(any()) - } - - @Test - fun `key map with key event action from device and blank device name extra, if device for action is connected, update action device name`() = - runTest(testDispatcher) { - // GIVEN - val action = ActionEntity( - type = ActionEntity.Type.KEY_EVENT, - data = "1", - extras = listOf( - Extra(ActionEntity.EXTRA_KEY_EVENT_DEVICE_DESCRIPTOR, FAKE_KEYBOARD.descriptor), - Extra(ActionEntity.EXTRA_KEY_EVENT_DEVICE_NAME, ""), - ), - ) - - val keyMap = KeyMapEntity(id = 0, actionList = listOf(action)) - - devicesAdapter.connectedInputDevices.value = State.Data( - listOf(FAKE_KEYBOARD), - ) - - // WHEN - keyMaps.emit(listOf(keyMap)) - - val expectedAction = action.copy( - extras = listOf( - Extra(ActionEntity.EXTRA_KEY_EVENT_DEVICE_DESCRIPTOR, FAKE_KEYBOARD.descriptor), - Extra(ActionEntity.EXTRA_KEY_EVENT_DEVICE_NAME, FAKE_KEYBOARD.name), - ), - ) - - // THEN - verify(mockDao, times(1)).update( - keyMap.copy(actionList = listOf(expectedAction)), - ) - } - - @Test - fun `key map with key event action from device and no device name extra, if device for action is connected, update action device name`() = - runTest(testDispatcher) { - // GIVEN - val action = ActionEntity( - type = ActionEntity.Type.KEY_EVENT, - data = "1", - extra = Extra( - ActionEntity.EXTRA_KEY_EVENT_DEVICE_DESCRIPTOR, - FAKE_KEYBOARD.descriptor, - ), - ) - - val keyMap = KeyMapEntity(id = 0, actionList = listOf(action)) - - devicesAdapter.connectedInputDevices.value = State.Data( - listOf(FAKE_KEYBOARD), - ) - - // WHEN - keyMaps.emit(listOf(keyMap)) - - val expectedAction = action.copy( - extras = listOf( - Extra(ActionEntity.EXTRA_KEY_EVENT_DEVICE_DESCRIPTOR, FAKE_KEYBOARD.descriptor), - Extra(ActionEntity.EXTRA_KEY_EVENT_DEVICE_NAME, FAKE_KEYBOARD.name), - ), - ) - - // THEN - verify(mockDao, times(1)).update( - keyMap.copy(actionList = listOf(expectedAction)), - ) - } - - @Test - fun `key map with key event action from device and no device name extra, if device for action is disconnected, update action device name`() = - runTest(testDispatcher) { - // GIVEN - val action = ActionEntity( - type = ActionEntity.Type.KEY_EVENT, - data = "1", - extra = Extra( - ActionEntity.EXTRA_KEY_EVENT_DEVICE_DESCRIPTOR, - FAKE_KEYBOARD.descriptor, - ), - ) - - val keyMap = KeyMapEntity(id = 0, actionList = listOf(action)) - - devicesAdapter.connectedInputDevices.value = State.Data(emptyList()) - - // WHEN - keyMaps.emit(listOf(keyMap)) - - // THEN - verify(mockDao, never()).update(any()) - } - - @Test - fun `key map with device name for trigger key, if device for trigger key is connected, do not update trigger key device name`() = - runTest(testDispatcher) { - // GIVEN - val triggerKey = TriggerEntity.KeyEntity( - keyCode = 1, - deviceId = FAKE_KEYBOARD.descriptor, - deviceName = FAKE_KEYBOARD.name, - ) - - val keyMap = KeyMapEntity(id = 0, trigger = TriggerEntity(keys = listOf(triggerKey))) - - devicesAdapter.connectedInputDevices.value = State.Data( - listOf(FAKE_KEYBOARD), - ) - - // WHEN - keyMaps.emit(listOf(keyMap)) - - // THEN - verify(mockDao, never()).update(any()) - } - - @Test - fun `key map with device name for trigger key, if device for trigger key is disconnected, do not update trigger key device name`() = - runTest(testDispatcher) { - // GIVEN - val triggerKey = TriggerEntity.KeyEntity( - keyCode = 1, - deviceId = FAKE_KEYBOARD.descriptor, - deviceName = FAKE_KEYBOARD.name, - ) - - val keyMap = KeyMapEntity(id = 0, trigger = TriggerEntity(keys = listOf(triggerKey))) - - devicesAdapter.connectedInputDevices.value = State.Data(emptyList()) - - // WHEN - keyMaps.emit(listOf(keyMap)) - - // THEN - verify(mockDao, never()).update(any()) - } - - @Test - fun `key map with no device name for trigger key, if device for trigger key is connected, update trigger key device name`() = - runTest(testDispatcher) { - // GIVEN - val triggerKey = TriggerEntity.KeyEntity( - keyCode = 1, - deviceId = FAKE_KEYBOARD.descriptor, - deviceName = "", - ) - - val keyMap = KeyMapEntity(id = 0, trigger = TriggerEntity(keys = listOf(triggerKey))) - - devicesAdapter.connectedInputDevices.value = State.Data( - listOf(FAKE_KEYBOARD), - ) - - // WHEN - keyMaps.emit(listOf(keyMap)) - - // THEN - val expectedTriggerKey = triggerKey.copy(deviceName = FAKE_KEYBOARD.name) - - verify(mockDao, times(1)) - .update(keyMap.copy(trigger = TriggerEntity(listOf(expectedTriggerKey)))) - } } From 995176a8a6af665b3db998f007d9fc7c6dc4a44c Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 8 Oct 2024 16:06:44 +0000 Subject: [PATCH 057/118] New Crowdin translations by GitHub Action --- app/src/main/res/values-vi/strings.xml | 108 ++++++++++++------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index ca50f4eb77..877ac4d59b 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -24,7 +24,7 @@ Tên thiết bị không xác định Bật Tắt - Theo dõi hệ thống + Theo hệ thống Thiết bị này Bất kỳ thiết bị nào Không biết tên của thiết bị này @@ -66,7 +66,7 @@ Không thể tìm thấy bất kỳ thiết bị được ghép nối nào. Bluetooth đã được bật chưa? Tùy chọn \"Cho phép các ứng dụng khác kích hoạt sơ đồ bàn phím này\" sẽ được bật cho sơ đồ bàn phím mà bạn chọn nếu chưa bật. Nếu sau này bạn tắt tùy chọn này thì mọi phím tắt hoặc Ý định kích hoạt sơ đồ phím này sẽ không hoạt động. Đã bật - Tàn tật + Đã tắt Khôi phục Sau khi đã bật quản trị viên thiết bị, bạn phải TẮT KÍCH HOẠT nó nếu muốn gỡ cài đặt Key Mapper. Thêm một hạn chế! @@ -92,7 +92,7 @@ Dịch vụ trợ năng đã được kích hoạt! Hành động của bạn sẽ hoạt động. Ghi nhật ký bổ sung được bật! Hãy tắt tính năng này nếu bạn không cố gắng khắc phục sự cố. Tắt - Về + Thông tin Mở %s Nhấn phím \'%s\' @@ -113,7 +113,7 @@ Tùy chọn Hành động - Trigger + Kích hoạt Hạn chế Vuốt lên Vuốt xuống @@ -155,7 +155,7 @@ Theo thứ tự HOẶC - Bấm nhanh + Nhấn Nhấn giữ Nhấn đúp Đúng @@ -188,7 +188,7 @@ Tự động sao lưu thành công! Tự động sao lưu thất bại! Đã chụp ảnh màn hình - IO exception ¯\\_(ツ)_/¯ + IO ngoại lệ ¯\\_(ツ)_/¯ Độ phân giải ảnh chụp màn hình không khớp với độ phân giải của thiết bị này! Đã sao chép sơ đồ khóa UUID vào bảng nhớ tạm Bạn đã kích hoạt hành động @@ -200,24 +200,24 @@ Key Mapper đã sử dụng Root để tự cấp quyền WRITE_SECURE_SETTINGS - Sequence trigger timeout (ms) + Thời gian chờ kích hoạt phím tiếp theo (ms) Độ trễ nhấn giữ (ms) Thời gian chờ nhấn đúp (ms) Trì hoãn cho đến khi lặp lại (ms) Giới hạn lặp lại Lặp lại mỗi… (ms) Thời gian rung (ms) - Đã bao nhiêu lần + Số lần lặp lại Mỗi lần lặp lại bao nhiêu lần - + Độ trễ trước hành động tiếp theo (ms) Thời gian giữ phím (ms) Thời lượng vuốt (ms) - Đếm ngón tay + Số ngón tay Tọa độ để thiết lập với ảnh chụp màn hình Bắt đầu Kết thúc Thời lượng chụm (ms) - Đếm ngón tay + Số ngón tay %s ở phía trước @@ -246,8 +246,8 @@ Màn hình xoay (90°) Màn hình xoay (180°) Màn hình xoay (270°) - Màn hình xoay (bất kỳ) - Phong cảnh (bất kỳ) + Màn hình dọc (bất kỳ) + Màn hình ngang (bất kỳ) Ứng dụng phát âm thanh Ứng dụng không phát âm thanh Âm thanh đang phát @@ -322,7 +322,7 @@ Cơ sở dữ liệu hạt giống Lưu Nhân bản - Lên + Lưu Khôi phục Sao lưu mọi thứ Sao lưu tất cả @@ -348,7 +348,7 @@ Thêm bổ sung Tạo lối tắt trình khởi chạy Tạo lối tắt thủ công - Hướng dẫn ý định + Hướng dẫn Help Chọn ảnh chụp màn hình (tùy chọn) Chọn hoạt động @@ -437,7 +437,7 @@ Hướng dẫn Hướng dẫn Kích hoạt tính năng root - Khoản trợ cấp + Cấp quyền Tham gia Thay đổi Sửa một phần @@ -474,13 +474,13 @@ Tính năng mới Nhấn để thay đổi bàn phím của bạn. Hộp chọn bàn phím - Đang tải xuống… + Đang chạy Nhấn để mở Key Mapper. Tạm dừng Đã tạm dừng Nhấn để mở Key Mapper. - Bản tóm tắt - Miễn nhiệm + Khởi chạy + Loại bỏ Khởi động lại Dịch vụ trợ năng bị vô hiệu hóa Nhấn để bắt đầu dịch vụ trợ năng. @@ -564,7 +564,7 @@ 2. Shizuku bắt đầu. 3. Key Mapper không được phép sử dụng Shizuku. Nhấn để cấp quyền này. 3. Key Mapper sẽ tự động sử dụng Shizuku. Nhấn để đọc tính năng Key Mapper nào sử dụng Shizuku. - Tùy chọn ánh xạ mặc định + Tùy chọn cài đặt mặc định Thay đổi các tùy chọn mặc định cho ánh xạ của bạn. @@ -617,7 +617,7 @@ Lặp lại cho đến khi nhấn lại Giữ và giữ Giữ cho đến khi nhấn lại - Không ánh xạ lại + Cho phép phím hoạt động bình thường Giữ cho đến khi vuốt lại Cho phép các ứng dụng khác kích hoạt bản đồ phím này @@ -634,7 +634,7 @@ Lưu ý từ nhà phát triển Không có gì đảm bảo rằng mọi hành động sẽ hoạt động trên thiết bị của bạn và mọi nút đều có thể được phát hiện. Điều này là do Android có nhiều phiên bản khác nhau và các OEM có thể vô tình hoặc cố ý phá vỡ các tính năng. Nếu có điều gì đó không ổn, vui lòng thông báo cho nhà phát triển và không đánh giá ứng dụng kém vì sự cố thường nằm ngoài tầm kiểm soát của nhà phát triển. =) Key Mapper có thể ngừng hoạt động ngẫu nhiên! - PHÊ BÌNH!!! Nhấn \"tắt\" để hy vọng ngăn Android dừng ứng dụng khi ứng dụng này ở chế độ nền. Giao diện OEM của bạn như MIUI hoặc Samsung Experience có thể có các tính năng \"tiết kiệm pin\" khác, vì vậy bạn PHẢI tắt chúng cho Key Mapper bằng cách làm theo hướng dẫn tại Dontkillmyapp.com + QUAN TRỌNG!!! Nhấn \"tắt\" để hy vọng ngăn Android dừng ứng dụng khi nó đang chạy nền. Giao diện OEM của bạn như MIUI hoặc Samsung Experience có thể có các tính năng \"tiết kiệm pin\" khác nên bạn PHẢI tắt chúng cho Key Mapper bằng cách làm theo hướng dẫn tại dontkillmyapp.com Tối ưu hóa pin Android gốc đã tắt. Điều này không đủ tốt cho hầu hết các thiết bị, vì vậy hãy truy cập Dontkillmyapp.com để xem hướng dẫn cách tắt nhiều tính năng diệt ứng dụng hơn nữa trên thiết bị của bạn. Tắt Truy cập Dontkillmyapp.com @@ -664,9 +664,9 @@ Shizuku phải được khởi động trước khi bạn cấp quyền cho Key Mapper sử dụng. Nhấn vào \'Khởi chạy Shizuku\' để mở ứng dụng Shizuku để bạn có thể khởi động ứng dụng. Cài đặt Shizuku Cấp quyền thông báo - Một số tính năng của Key Mapper yêu cầu quyền đăng thông báo, ví dụ: có thông báo tạm dừng/tiếp tục các bản đồ chính của bạn. Nhấn \'cấp\' để cấp quyền. + Một số tính năng của Key Mapper yêu cầu quyền đăng thông báo, ví dụ: có thông báo tạm dừng/tiếp tục các bản đồ chính của bạn. Nhấn \'Cấp quyền\' để cấp quyền. Thông báo có thể được hiển thị! - Bạn đã cấp thành công quyền Key Mapper để hiển thị thông báo. + Bạn đã cấp quyền Key Mapper có thể hiển thị thông báo. Cấp quyền @@ -675,7 +675,7 @@ DTMF Âm nhạc Thông báo - Nhẫn + Chuông Hệ thống Cuộc gọi thoại Chuông @@ -700,19 +700,19 @@ Ctrl left Ctrl right Alt - Alt trái - Alt phải + Alt left + Alt right Shift - Shift trái - Shift phải + Shift left + Shift right Meta Meta trái Meta phải Sym Func - Khóa mũ - Khóa số - Khóa cuộn + Caps Lock + Num Lock + Scroll Lock Bạn phải gõ một phím! @@ -871,10 +871,10 @@ Bật tự động xoay Vô hiệu hóa tự động xoay Chuyển đổi tự động xoay - Chế độ chân dung - Chế độ phong cảnh - Chuyển hướng - Xoay vòng qua các vòng quay + Màn hình dọc + Màn hình ngang + Xoay màn hình + Cài đặt xoay màn hình Xoay vòng qua %s vòng quay Chuyển đổi dữ liệu di động Kích hoạt dữ liệu di động @@ -913,7 +913,7 @@ Tua lại trong %s Không phải tất cả các ứng dụng media đều hỗ trợ tua lại. Ví dụ: Google Play Âm nhạc. Quay lại - Go home + Màn hình chính Mở gần đây Mở trình đơn Chuyển đổi màn hình chia nhỏ @@ -927,13 +927,13 @@ Bật NFC Tắt NFC Chuyển đổi NFC - Ảnh chụp màn hình + Chụp màn hình Khởi chạy trợ lý giọng nói Khởi chạy trợ lý thiết bị Mở máy ảnh Khóa thiết bị Thiết bị khóa an toàn - Bạn sẽ chỉ có thể đăng nhập lại bằng mã PIN của mình. Máy quét dấu vân tay và mở khóa bằng khuôn mặt sẽ bị tắt. Đây là cách đáng tin cậy duy nhất mà tôi tìm thấy để khóa các thiết bị chưa root trước Android Pie 9.0. + Bạn sẽ chỉ có thể đăng nhập lại bằng mã PIN của mình. Máy quét dấu vân tay và mở khóa bằng khuôn mặt sẽ bị tắt. Đây là cách đáng tin cậy duy nhất mà tôi tìm thấy để khóa các thiết bị chưa root trước Android 9.0. Thiết bị ngủ/thức Bạn phải bật tùy chọn phát hiện trigger khi màn hình tắt! Không làm gì cả @@ -944,7 +944,7 @@ Hiển thị bàn phím Ẩn bàn phím Hiển thị hộp chọn bàn phím - Chuyển đổi bàn phím + Chuyển đổi bàn phím thiết lập Chuyển sang %s Cắt Sao chép @@ -952,8 +952,8 @@ Chọn từ tại con trỏ Mở cài đặt Hiển thị menu nguồn - Chuyển đổi chế độ trên máy bay - Bật chế độ trên máy bay + Chuyển đổi chế độ máy bay + Bật chế độ máy bay Tắt chế độ Máy bay Khởi chạy ứng dụng Khởi chạy phím tắt ứng dụng @@ -964,7 +964,7 @@ Chụm màn hình Nhập văn bản Mở URL - Gửi ý định + Gửi thông tin Bắt đầu cuộc gọi điện thoại Trả lời cuộc gọi điện thoại Kết thúc cuộc gọi điện thoại @@ -984,14 +984,14 @@ Nội dung Giao diện Điện thoại - Trưng bày + Hiển thị Thông báo Boolean - Mảng Boolean + Boolean array Số nguyên - Mảng số nguyên + Integer array Chuỗi Mảng chuỗi Dài @@ -1000,8 +1000,8 @@ Mảng byte Double Mảng đôi - Char - Mảng char + Ký tự + Mảng ký tự Nổi Mảng nổi Ngắn @@ -1009,7 +1009,7 @@ Chỉ có thể là \"đúng\" hoặc \"sai\" Danh sách \"đúng\" và \"sai\" được phân tách bằng dấu phẩy. Ví dụ: đúng, sai, đúng Một số nguyên hợp lệ trong ngôn ngữ lập trình Java. - Danh sách các số nguyên hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: 100.399 + Danh sách các số nguyên hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: 100,399 Một danh sách được phân tách bằng dấu phẩy. Ví dụ: loại 1, loại 2 Bất kỳ văn bản nào. Danh sách các chuỗi được phân tách bằng dấu phẩy. Ví dụ: chuỗi1, chuỗi2 @@ -1018,13 +1018,13 @@ Một Byte hợp lệ trong ngôn ngữ lập trình Java. Danh sách các Byte hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: 123,3 Một Double hợp lệ trong ngôn ngữ lập trình Java. - Danh sách các Nhân đôi hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: 1.0,3.234 + Danh sách các Mảng đôi hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: 1.0,3.234 Một Char hợp lệ trong ngôn ngữ lập trình Java. Ví dụ: \'a\' hoặc \'b\' Danh sách các ký tự hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: a,b,c Float hợp lệ trong ngôn ngữ lập trình Java. Ví dụ: 3,145 - Danh sách các Float hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: 1241.123 + Danh sách các Mảng nổi hợp lệ được phân tách bằng dấu phẩy trong ngôn ngữ lập trình Java. Ví dụ: 1241.123 Một đoạn ngắn hợp lệ bằng ngôn ngữ lập trình Java. Ví dụ: 2342 - Danh sách các video ngắn hợp lệ được phân tách bằng dấu phẩy bằng ngôn ngữ lập trình Java. Ví dụ: 3242,12354 + Danh sách các Mảng ngắn hợp lệ được phân tách bằng dấu phẩy bằng ngôn ngữ lập trình Java. Ví dụ: 3242,12354 Các cờ cho một Ý định được lưu dưới dạng cờ bit. Những cờ này thay đổi cách xử lý Ý định. Nếu mục này trống đối với Ý định hoạt động thì Trình ánh xạ khóa sẽ sử dụng FLAG_ACTIVITY_NEW_TASK theo mặc định. Để biết thêm thông tin, hãy nhấn vào \'tài liệu\' để xem tài liệu dành cho nhà phát triển Android. @@ -1036,10 +1036,10 @@ Website Bản dịch Phiên bản %s - Tỷ lệ + Đánh giá Nhật ký thay đổi Discord - Những thứ nhàm chán + Thông tin pháp lý Giấy phép Giấy phép nguồn mở cho ứng dụng này. Chính sách bảo mật From ad9bd5824aa42dfea32aec4d361d8b8b893bd166 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Oct 2024 18:25:33 +0200 Subject: [PATCH 058/118] tests: add tests from pro build flavor --- app/build.gradle | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a91d792b60..8762a7ce08 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -110,18 +110,22 @@ android { composeOptions { kotlinCompilerExtensionVersion "1.5.10" } -} -android.sourceSets { - androidTest { - assets.srcDirs += files("$projectDir/schemas".toString()) - resources.srcDirs += ["src/test/resources"] + sourceSets { + androidTest { + assets.srcDirs += files("$projectDir/schemas".toString()) + resources.srcDirs += ["src/test/resources"] + } + + test { + java.srcDirs += ["src/pro/test/java"] + } } -} -android.applicationVariants.all { variant -> - variant.outputs.all { - outputFileName = "keymapper-${variant.versionName}.apk" + applicationVariants.configureEach { variant -> + variant.outputs.configureEach { + outputFileName = "keymapper-${variant.versionName}.apk" + } } } From 9bca41b0d922b35afeb35d0d27129a37a7a3b553 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Oct 2024 20:21:23 +0200 Subject: [PATCH 059/118] #1274 use SplashActivity explicitly in app run configuration because the trigger assistant activity is chosen as the default activity --- .idea/runConfigurations/app.xml | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .idea/runConfigurations/app.xml diff --git a/.idea/runConfigurations/app.xml b/.idea/runConfigurations/app.xml new file mode 100644 index 0000000000..772f9e4c1b --- /dev/null +++ b/.idea/runConfigurations/app.xml @@ -0,0 +1,68 @@ + + + + + \ No newline at end of file From b45a80023bbd744efbfe28c46fa104017f701122 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 12 Oct 2024 20:38:48 +0200 Subject: [PATCH 060/118] #1274 allow using the screen off constraint with assistant triggers successfully --- .../github/sds100/keymapper/KeyMapperApp.kt | 2 +- .../github/sds100/keymapper/ServiceLocator.kt | 4 +-- .../constraints/GetConstraintErrorUseCase.kt | 8 ------ .../system/display/AndroidDisplayAdapter.kt | 28 ++++++++++++++----- docs/user-guide/constraints.md | 4 +-- 5 files changed, 26 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt index f864d19539..170339f2f2 100644 --- a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt +++ b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt @@ -118,7 +118,7 @@ class KeyMapperApp : MultiDexApplication() { val fileAdapter by lazy { AndroidFileAdapter(this) } val popupMessageAdapter by lazy { AndroidToastAdapter(this) } val vibratorAdapter by lazy { AndroidVibratorAdapter(this) } - val displayAdapter by lazy { AndroidDisplayAdapter(this) } + val displayAdapter by lazy { AndroidDisplayAdapter(this, coroutineScope = appCoroutineScope) } val audioAdapter by lazy { AndroidVolumeAdapter(this) } val suAdapter by lazy { SuAdapterImpl( diff --git a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt index d56e85ba97..cab33f85f1 100755 --- a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt @@ -24,7 +24,7 @@ import io.github.sds100.keymapper.system.bluetooth.BluetoothAdapter import io.github.sds100.keymapper.system.camera.CameraAdapter import io.github.sds100.keymapper.system.clipboard.ClipboardAdapter import io.github.sds100.keymapper.system.devices.DevicesAdapter -import io.github.sds100.keymapper.system.display.DisplayAdapter +import io.github.sds100.keymapper.system.display.AndroidDisplayAdapter import io.github.sds100.keymapper.system.files.FileAdapter import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.intents.IntentAdapter @@ -204,7 +204,7 @@ object ServiceLocator { fun vibratorAdapter(context: Context): VibratorAdapter = (context.applicationContext as KeyMapperApp).vibratorAdapter - fun displayAdapter(context: Context): DisplayAdapter = + fun displayAdapter(context: Context): AndroidDisplayAdapter = (context.applicationContext as KeyMapperApp).displayAdapter fun audioAdapter(context: Context): VolumeAdapter = diff --git a/app/src/main/java/io/github/sds100/keymapper/constraints/GetConstraintErrorUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/constraints/GetConstraintErrorUseCase.kt index c0713cde87..09092ba5ae 100644 --- a/app/src/main/java/io/github/sds100/keymapper/constraints/GetConstraintErrorUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/constraints/GetConstraintErrorUseCase.kt @@ -71,14 +71,6 @@ class GetConstraintErrorUseCaseImpl( return Error.PermissionDenied(Permission.WRITE_SETTINGS) } - Constraint.ScreenOff, - Constraint.ScreenOn, - -> { - if (!permissionAdapter.isGranted(Permission.ROOT)) { - return Error.PermissionDenied(Permission.ROOT) - } - } - is Constraint.FlashlightOn, is Constraint.FlashlightOff -> { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return Error.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt index 90923e3ac6..f122db133d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/display/AndroidDisplayAdapter.kt @@ -13,12 +13,19 @@ import io.github.sds100.keymapper.system.SettingsUtils import io.github.sds100.keymapper.util.Error import io.github.sds100.keymapper.util.Result import io.github.sds100.keymapper.util.Success +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch /** * Created by sds100 on 17/04/2021. */ -class AndroidDisplayAdapter(context: Context) : DisplayAdapter { +class AndroidDisplayAdapter( + context: Context, + private val coroutineScope: CoroutineScope, +) : DisplayAdapter { companion object { /** @@ -35,8 +42,18 @@ class AndroidDisplayAdapter(context: Context) : DisplayAdapter { context ?: return when (intent.action) { - Intent.ACTION_SCREEN_ON -> isScreenOn.value = true - Intent.ACTION_SCREEN_OFF -> isScreenOn.value = false + Intent.ACTION_SCREEN_ON -> { + // This intent is received before the assistant activity is launched so + // wait 100ms before updating. This is so that one can use the screen-off + // constraint with the assistant triggers. + coroutineScope.launch { + delay(100) + + isScreenOn.update { true } + } + } + + Intent.ACTION_SCREEN_OFF -> isScreenOn.update { false } } } } @@ -44,6 +61,7 @@ class AndroidDisplayAdapter(context: Context) : DisplayAdapter { override val isScreenOn = MutableStateFlow(true) private val displayManager: DisplayManager = ctx.getSystemService()!! + override var orientation: Orientation = getDisplayOrientation() init { displayManager.registerDisplayListener( @@ -62,11 +80,7 @@ class AndroidDisplayAdapter(context: Context) : DisplayAdapter { }, null, ) - } - override var orientation: Orientation = getDisplayOrientation() - - init { val filter = IntentFilter() filter.addAction(Intent.ACTION_SCREEN_ON) filter.addAction(Intent.ACTION_SCREEN_OFF) diff --git a/docs/user-guide/constraints.md b/docs/user-guide/constraints.md index fecfc5f3b4..f552b58504 100644 --- a/docs/user-guide/constraints.md +++ b/docs/user-guide/constraints.md @@ -24,12 +24,12 @@ Your mapping will only work if a specific bluetooth device is connected/disconne ### Orientation (2.2.0+) This will restrict your gesture map to work only when the device is set to a specific screen orientation. -### Screen is on/off (ROOT) +### Screen is on/off !!! info "Only for key maps" !!! attention - You must [grant Key Mapper root permission](settings.md#key-mapper-has-root-permission) and select the [option](../keymaps#special-options) to detect the key map when the screen is off. + If you are not using a custom trigger then you must [grant Key Mapper root permission](settings.md#key-mapper-has-root-permission) and select the [option](../keymaps#special-options) to detect the key map when the screen is off. Only allow the key map to be triggered when the screen is on or off. From cc84b3f7fe8e08ecee8c572755edffe9c9c95822 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 13 Oct 2024 10:35:00 +0200 Subject: [PATCH 061/118] #1274 style: rename variable --- .../sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt index 2f4b687aef..ff930a4603 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapControllerTest.kt @@ -3295,9 +3295,9 @@ class KeyMapControllerTest { continue } - val deviceDescriptor = triggerKeyDeviceToInputDevice(key.device) + val inputDevice = triggerKeyDeviceToInputDevice(key.device) - inputKeyEvent(key.keyCode, KeyEvent.ACTION_UP, deviceDescriptor) + inputKeyEvent(key.keyCode, KeyEvent.ACTION_UP, inputDevice) } } From 717de79d3456493d4f9d07b62e364ff7230c36b5 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 13 Oct 2024 10:55:22 +0200 Subject: [PATCH 062/118] fix: add padding between tabs and trigger list --- .../main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml | 2 ++ app/src/main/res/layout-w600dp-land/fragment_trigger.xml | 1 + app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml | 1 + app/src/main/res/layout/fragment_trigger.xml | 1 + 4 files changed, 5 insertions(+) diff --git a/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml b/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml index 6a19fa20f4..e448caf5d5 100644 --- a/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml +++ b/app/src/main/res/layout-w1000dp-h400dp-land/fragment_trigger.xml @@ -36,6 +36,7 @@ android:id="@+id/listLayout" android:layout_width="match_parent" android:layout_height="0dp" + android:layout_marginTop="8dp" app:layout_behavior="@string/appbar_scrolling_view_behavior" app:layout_constraintBottom_toTopOf="@id/radioGroupClickType" app:layout_constraintEnd_toEndOf="parent" @@ -46,6 +47,7 @@ android:id="@+id/recyclerViewTriggerKeys" android:layout_width="match_parent" android:layout_height="match_parent" + android:layout_marginTop="8dp" android:clipToPadding="false" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:listitem="@layout/list_item_simple" /> diff --git a/app/src/main/res/layout-w600dp-land/fragment_trigger.xml b/app/src/main/res/layout-w600dp-land/fragment_trigger.xml index a5b28d57fd..1568310d6a 100644 --- a/app/src/main/res/layout-w600dp-land/fragment_trigger.xml +++ b/app/src/main/res/layout-w600dp-land/fragment_trigger.xml @@ -38,6 +38,7 @@ android:id="@+id/listLayout" android:layout_width="0dp" android:layout_height="0dp" + android:layout_marginTop="8dp" android:layout_marginBottom="8dp" app:layout_behavior="@string/appbar_scrolling_view_behavior" app:layout_constraintBottom_toBottomOf="parent" diff --git a/app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml b/app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml index fc43cbecdf..425ac271bf 100644 --- a/app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml +++ b/app/src/main/res/layout-w900dp-h600dp/fragment_trigger.xml @@ -35,6 +35,7 @@ android:id="@+id/listLayout" android:layout_width="match_parent" android:layout_height="0dp" + android:layout_marginTop="8dp" app:layout_behavior="@string/appbar_scrolling_view_behavior" app:layout_constraintBottom_toTopOf="@id/radioGroupClickType" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/fragment_trigger.xml b/app/src/main/res/layout/fragment_trigger.xml index 189b235875..a1c2a74a2b 100644 --- a/app/src/main/res/layout/fragment_trigger.xml +++ b/app/src/main/res/layout/fragment_trigger.xml @@ -36,6 +36,7 @@ android:id="@+id/listLayout" android:layout_width="match_parent" android:layout_height="0dp" + android:layout_marginTop="8dp" app:layout_behavior="@string/appbar_scrolling_view_behavior" app:layout_constraintBottom_toTopOf="@id/radioGroupClickType" app:layout_constraintEnd_toEndOf="parent" From f6e1e956e356faddc1d3292f8da37e105f94b9a1 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 13 Oct 2024 11:21:03 +0200 Subject: [PATCH 063/118] #1274 fix: hide click type buttons if a parallel trigger has an assistant key --- .../trigger/BaseConfigTriggerViewModel.kt | 25 ++++++++----- .../keymapper/ConfigKeyMapUseCaseTest.kt | 35 +++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt index 048e5b09ae..d0a6da04d5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt @@ -115,18 +115,34 @@ abstract class BaseConfigTriggerViewModel( } }.flowOn(Dispatchers.Default).stateIn(coroutineScope, SharingStarted.Eagerly, State.Loading) + /** + * The click type radio buttons are only visible if there is one key + * or there are only key code keys in the trigger. It is not possible to do a long press of + * non-key code keys in a parallel trigger. + */ val clickTypeRadioButtonsVisible: StateFlow = config.mapping.map { state -> when (state) { is State.Data -> { val trigger = state.data.trigger - trigger.mode is TriggerMode.Parallel || trigger.keys.size == 1 + if (trigger.mode is TriggerMode.Parallel) { + trigger.keys.all { it is KeyCodeTriggerKey } + } else { + trigger.keys.size == 1 + } } State.Loading -> false } }.flowOn(Dispatchers.Default).stateIn(coroutineScope, SharingStarted.Eagerly, false) + val doublePressButtonVisible: StateFlow = config.mapping.map { state -> + when (state) { + is State.Data -> state.data.trigger.keys.size == 1 + State.Loading -> false + } + }.flowOn(Dispatchers.Default).stateIn(coroutineScope, SharingStarted.Eagerly, false) + /** * Only show the buttons for the trigger mode if keys have been added. The buttons * shouldn't be shown when no trigger is selected because they aren't relevant @@ -141,13 +157,6 @@ abstract class BaseConfigTriggerViewModel( } .stateIn(coroutineScope, SharingStarted.Eagerly, false) - val doublePressButtonVisible: StateFlow = config.mapping.map { state -> - when (state) { - is State.Data -> state.data.trigger.keys.size == 1 - State.Loading -> false - } - }.flowOn(Dispatchers.Default).stateIn(coroutineScope, SharingStarted.Eagerly, false) - val checkedClickTypeRadioButton: StateFlow = config.mapping.map { state -> when (state) { is State.Data -> { diff --git a/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt b/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt index 4a3e07767d..cbe893edec 100644 --- a/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt @@ -3,10 +3,15 @@ package io.github.sds100.keymapper import android.view.KeyEvent import io.github.sds100.keymapper.actions.ActionData import io.github.sds100.keymapper.constraints.Constraint +import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapUseCaseImpl import io.github.sds100.keymapper.mappings.keymaps.KeyMap import io.github.sds100.keymapper.mappings.keymaps.KeyMapAction +import io.github.sds100.keymapper.mappings.keymaps.trigger.AssistantTriggerKey +import io.github.sds100.keymapper.mappings.keymaps.trigger.AssistantTriggerType +import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice +import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode import io.github.sds100.keymapper.system.keyevents.KeyEventUtils import io.github.sds100.keymapper.util.State import io.github.sds100.keymapper.util.dataOrNull @@ -18,6 +23,7 @@ import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.`is` +import org.junit.Assert.fail import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock @@ -42,6 +48,35 @@ class ConfigKeyMapUseCaseTest { ) } + @Test + fun `Set trigger mode to short press when adding assistant key to long press trigger key`() = + runTest(testDispatcher) { + fail() + } + + @Test + fun `Do not allow long press for parallel trigger with assistant key`() = + runTest(testDispatcher) { + val keyMap = KeyMap( + trigger = Trigger( + mode = TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS), + keys = listOf( + triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), + AssistantTriggerKey( + type = AssistantTriggerType.ANY, + clickType = ClickType.SHORT_PRESS, + ), + ), + ), + ) + + useCase.mapping.value = State.Data(keyMap) + useCase.setTriggerLongPress() + + val trigger = useCase.mapping.value.dataOrNull()!!.trigger + assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + } + /** * Issue #753. If a modifier key is used as a trigger then it the * option to not override the default action must be chosen so that the modifier From d5f335b0d6df40fbe92c1a8f1fd0613ecfa648ad Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 13 Oct 2024 11:36:02 +0200 Subject: [PATCH 064/118] #1274 set parallel trigger to short press when adding assistant key --- .../mappings/keymaps/ConfigKeyMapUseCase.kt | 7 +++- .../keymapper/ConfigKeyMapUseCaseTest.kt | 38 ++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt index bfeaae44ec..4a2c3be12f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt @@ -61,8 +61,11 @@ class ConfigKeyMapUseCaseImpl( newKeys.size <= 1 -> TriggerMode.Undefined /* Automatically make it a parallel trigger when the user makes a trigger with more than one key - because this is what most users are expecting when they make a trigger with multiple keys */ - newKeys.size == 2 && !containsKey -> TriggerMode.Parallel(newKeys[0].clickType) + because this is what most users are expecting when they make a trigger with multiple keys. + + It must be a short press because long pressing the assistant key isn't supported. + */ + !containsKey -> TriggerMode.Parallel(ClickType.SHORT_PRESS) else -> trigger.mode } diff --git a/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt b/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt index cbe893edec..97fe4ecee9 100644 --- a/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.`is` -import org.junit.Assert.fail import org.junit.Before import org.junit.Test import org.mockito.kotlin.mock @@ -48,10 +47,45 @@ class ConfigKeyMapUseCaseTest { ) } + @Test + fun `Set trigger mode to short press when adding assistant key to multiple long press trigger keys`() = + runTest(testDispatcher) { + useCase.mapping.value = State.Data(KeyMap()) + + useCase.addKeyCodeTriggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, TriggerKeyDevice.Any) + useCase.addKeyCodeTriggerKey(KeyEvent.KEYCODE_VOLUME_UP, TriggerKeyDevice.Any) + useCase.setTriggerLongPress() + + useCase.addAssistantTriggerKey(AssistantTriggerType.ANY) + + val trigger = useCase.mapping.value.dataOrNull()!!.trigger + assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + } + + @Test + fun `Set trigger mode to short press when adding assistant key to double press trigger key`() = + runTest(testDispatcher) { + useCase.mapping.value = State.Data(KeyMap()) + + useCase.addKeyCodeTriggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, TriggerKeyDevice.Any) + useCase.setTriggerDoublePress() + useCase.addAssistantTriggerKey(AssistantTriggerType.ANY) + + val trigger = useCase.mapping.value.dataOrNull()!!.trigger + assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) + } + @Test fun `Set trigger mode to short press when adding assistant key to long press trigger key`() = runTest(testDispatcher) { - fail() + useCase.mapping.value = State.Data(KeyMap()) + + useCase.addKeyCodeTriggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, TriggerKeyDevice.Any) + useCase.setTriggerLongPress() + useCase.addAssistantTriggerKey(AssistantTriggerType.ANY) + + val trigger = useCase.mapping.value.dataOrNull()!!.trigger + assertThat(trigger.mode, `is`(TriggerMode.Parallel(clickType = ClickType.SHORT_PRESS))) } @Test From 83e3d69d01866eb59ba67660b8f8d77db240fc1f Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 13 Oct 2024 11:40:21 +0200 Subject: [PATCH 065/118] #1274 fix: use custom JSON name for type field in AssistantTriggerKey --- .../mappings/keymaps/trigger/AssistantTriggerKey.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt index a5fe9dce05..023648fee5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt @@ -3,13 +3,18 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger import io.github.sds100.keymapper.data.entities.AssistantTriggerKeyEntity import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import io.github.sds100.keymapper.mappings.ClickType +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.util.UUID @Serializable data class AssistantTriggerKey( override val uid: String = UUID.randomUUID().toString(), + + // A custom JSON name is required because this conflicts with the built-in "type" property. + @SerialName("assistantType") val type: AssistantTriggerType, + override val clickType: ClickType, ) : TriggerKey() { From 9b7c0a795c9392a55041e2797c2c6778196fe769 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 13 Oct 2024 18:33:02 +0200 Subject: [PATCH 066/118] #1274 show dialog to select which assistant type when clicking more options on an assistant trigger key --- .../mappings/keymaps/ConfigKeyMapUseCase.kt | 36 +++++++--- .../trigger/BaseConfigTriggerViewModel.kt | 67 ++++++++++--------- .../keymaps/trigger/TriggerKeyListItem.kt | 5 ++ .../main/res/layout/list_item_trigger_key.xml | 26 +++---- 4 files changed, 81 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt index 4a2c3be12f..9a0a953c4c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt @@ -140,6 +140,10 @@ class ConfigKeyMapUseCaseImpl( ) } + override fun getTriggerKey(uid: String): TriggerKey? { + return mapping.value.dataOrNull()?.trigger?.keys?.find { it.uid == uid } + } + override fun setParallelTriggerMode() = editTrigger { trigger -> if (trigger.mode is TriggerMode.Parallel) { return@editTrigger trigger @@ -249,27 +253,37 @@ class ConfigKeyMapUseCaseImpl( } override fun setTriggerKeyClickType(keyUid: String, clickType: ClickType) { - editTriggerKey(keyUid) { - it.setClickType(clickType = clickType) + editTriggerKey(keyUid) { key -> + key.setClickType(clickType = clickType) } } override fun setTriggerKeyDevice(keyUid: String, device: TriggerKeyDevice) { - editTriggerKey(keyUid) { - if (it is KeyCodeTriggerKey) { - it.copy(device = device) + editTriggerKey(keyUid) { key -> + if (key is KeyCodeTriggerKey) { + key.copy(device = device) } else { - it + key } } } override fun setTriggerKeyConsumeKeyEvent(keyUid: String, consumeKeyEvent: Boolean) { - editTriggerKey(keyUid) { - if (it is KeyCodeTriggerKey) { - it.copy(consumeEvent = consumeKeyEvent) + editTriggerKey(keyUid) { key -> + if (key is KeyCodeTriggerKey) { + key.copy(consumeEvent = consumeKeyEvent) + } else { + key + } + } + } + + override fun setAssistantTriggerKeyType(keyUid: String, type: AssistantTriggerType) { + editTriggerKey(keyUid) { key -> + if (key is AssistantTriggerKey) { + key.copy(type = type) } else { - it + key } } } @@ -505,6 +519,7 @@ interface ConfigKeyMapUseCase : ConfigMappingUseCase { fun addKeyCodeTriggerKey(keyCode: Int, device: TriggerKeyDevice) fun addAssistantTriggerKey(type: AssistantTriggerType) fun removeTriggerKey(uid: String) + fun getTriggerKey(uid: String): TriggerKey? fun moveTriggerKey(fromIndex: Int, toIndex: Int) fun restoreState(keyMap: KeyMap) @@ -522,6 +537,7 @@ interface ConfigKeyMapUseCase : ConfigMappingUseCase { fun setTriggerKeyClickType(keyUid: String, clickType: ClickType) fun setTriggerKeyDevice(keyUid: String, device: TriggerKeyDevice) fun setTriggerKeyConsumeKeyEvent(keyUid: String, consumeKeyEvent: Boolean) + fun setAssistantTriggerKeyType(keyUid: String, type: AssistantTriggerType) fun setVibrateEnabled(enabled: Boolean) fun setVibrationDuration(duration: Defaultable) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt index d0a6da04d5..028bb3a929 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt @@ -316,48 +316,52 @@ abstract class BaseConfigTriggerViewModel( fun onRemoveKeyClick(uid: String) = config.removeTriggerKey(uid) fun onMoveTriggerKey(fromIndex: Int, toIndex: Int) = config.moveTriggerKey(fromIndex, toIndex) - fun onTriggerKeyOptionsClick(id: String) { + open fun onTriggerKeyOptionsClick(id: String) { runBlocking { _openEditOptions.emit(id) } } fun onChooseDeviceClick(keyUid: String) { coroutineScope.launch { - val idAny = "any" - val idInternal = "this_device" - val devices = config.getAvailableTriggerKeyDevices() - val showDeviceDescriptors = displayKeyMap.showDeviceDescriptors.first() - - val listItems = devices.map { device: TriggerKeyDevice -> - when (device) { - TriggerKeyDevice.Any -> idAny to getString(R.string.any_device) - TriggerKeyDevice.Internal -> idInternal to getString(R.string.this_device) - is TriggerKeyDevice.External -> { - if (showDeviceDescriptors) { - val name = InputDeviceUtils.appendDeviceDescriptorToName( - device.descriptor, - device.name, - ) - device.descriptor to name - } else { - device.descriptor to device.name - } + chooseDeviceForKeyCodeTriggerKey(keyUid) + } + } + + private suspend fun chooseDeviceForKeyCodeTriggerKey(keyUid: String) { + val idAny = "any" + val idInternal = "this_device" + val devices = config.getAvailableTriggerKeyDevices() + val showDeviceDescriptors = displayKeyMap.showDeviceDescriptors.first() + + val listItems = devices.map { device: TriggerKeyDevice -> + when (device) { + TriggerKeyDevice.Any -> idAny to getString(R.string.any_device) + TriggerKeyDevice.Internal -> idInternal to getString(R.string.this_device) + is TriggerKeyDevice.External -> { + if (showDeviceDescriptors) { + val name = InputDeviceUtils.appendDeviceDescriptorToName( + device.descriptor, + device.name, + ) + device.descriptor to name + } else { + device.descriptor to device.name } } } + } - val triggerKeyDeviceId = showPopup( - "pick_trigger_key_device", - PopupUi.SingleChoice(listItems), - ) ?: return@launch - - val selectedTriggerKeyDevice = when (triggerKeyDeviceId) { - idAny -> TriggerKeyDevice.Any - idInternal -> TriggerKeyDevice.Internal - else -> devices.single { it is TriggerKeyDevice.External && it.descriptor == triggerKeyDeviceId } - } + val triggerKeyDeviceId = showPopup( + "pick_trigger_key_device", + PopupUi.SingleChoice(listItems), + ) ?: return - config.setTriggerKeyDevice(keyUid, selectedTriggerKeyDevice) + val selectedTriggerKeyDevice = when (triggerKeyDeviceId) { + idAny -> TriggerKeyDevice.Any + idInternal -> TriggerKeyDevice.Internal + else -> devices.single { it is TriggerKeyDevice.External && it.descriptor == triggerKeyDeviceId } } + + config.setTriggerKeyDevice(keyUid, selectedTriggerKeyDevice) } fun onRecordTriggerButtonClick() { @@ -443,6 +447,7 @@ abstract class BaseConfigTriggerViewModel( extraInfo = getTriggerKeyExtraInfo(key, showDeviceDescriptors), linkType = linkDrawable, isDragDropEnabled = trigger.keys.size > 1, + isChooseDeviceButtonVisible = key is KeyCodeTriggerKey, ) } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyListItem.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyListItem.kt index 27f5c554b8..736d722609 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyListItem.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerKeyListItem.kt @@ -17,4 +17,9 @@ data class TriggerKeyListItem( val linkType: TriggerKeyLinkType, val isDragDropEnabled: Boolean, + + /** + * The button for choosing the device is only visible for key code trigger keys. + */ + val isChooseDeviceButtonVisible: Boolean, ) diff --git a/app/src/main/res/layout/list_item_trigger_key.xml b/app/src/main/res/layout/list_item_trigger_key.xml index 940286e96b..1e203fba97 100644 --- a/app/src/main/res/layout/list_item_trigger_key.xml +++ b/app/src/main/res/layout/list_item_trigger_key.xml @@ -6,6 +6,7 @@ + + app:layout_constraintTop_toTopOf="@+id/buttonRemove"> + app:srcCompat="@drawable/ic_baseline_devices_other_24" /> + app:srcCompat="@drawable/ic_outline_more_vert_24" /> Date: Sun, 13 Oct 2024 19:28:33 +0200 Subject: [PATCH 067/118] #1274 update google play billing library --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 8762a7ce08..bc76e3f059 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -164,8 +164,8 @@ dependencies { implementation "dev.rikka.shizuku:api:$shizuku_version" implementation "dev.rikka.shizuku:provider:$shizuku_version" implementation "org.lsposed.hiddenapibypass:hiddenapibypass:4.3" - proImplementation "com.android.billingclient:billing:7.1.0" - proImplementation "com.android.billingclient:billing-ktx:7.1.0" + proImplementation "com.android.billingclient:billing:7.1.1" + proImplementation "com.android.billingclient:billing-ktx:7.1.1" // splitties implementation "com.louiscad.splitties:splitties-bitflags:$splitties_version" From ff67ae8c01d877e929330b96be3280a5ec322de4 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 13 Oct 2024 19:31:19 +0200 Subject: [PATCH 068/118] #1274 set version to 2.7.0 --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index 7860daf1ec..afaa584e70 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ -VERSION_NAME=2.6.3 +VERSION_NAME=2.7.0 VERSION_CODE=66 VERSION_NUM=0 \ No newline at end of file From 454dd8204af757b55e3211e2c5062d8318445558 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 13 Oct 2024 19:39:38 +0200 Subject: [PATCH 069/118] #1274 set version to 2.7.0 alpha --- app/version.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/version.properties b/app/version.properties index afaa584e70..6723bba07c 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ -VERSION_NAME=2.7.0 +VERSION_NAME=2.7.0-alpha VERSION_CODE=66 VERSION_NUM=0 \ No newline at end of file From 917981f24fc7f0c27d76a41fb82bd74b5a3dea87 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Oct 2024 18:59:10 +0200 Subject: [PATCH 070/118] #1274 add Dagger Hilt dependency injection and split up MainActivity into BaseMainActivity --- app/build.gradle | 3 +++ .../free/java/io/github/sds100/keymapper/MainActivity.kt | 3 +++ .../keymapper/{MainActivity.kt => BaseMainActivity.kt} | 4 ++-- .../main/java/io/github/sds100/keymapper/KeyMapperApp.kt | 2 ++ .../system/notifications/NotificationController.kt | 6 +++--- build.gradle | 3 ++- 6 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 app/src/free/java/io/github/sds100/keymapper/MainActivity.kt rename app/src/main/java/io/github/sds100/keymapper/{MainActivity.kt => BaseMainActivity.kt} (95%) diff --git a/app/build.gradle b/app/build.gradle index bc76e3f059..6eb86e9740 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,6 +5,7 @@ apply plugin: "androidx.navigation.safeargs.kotlin" apply plugin: "kotlinx-serialization" apply plugin: "org.jetbrains.kotlin.plugin.parcelize" apply plugin: "org.jlleitschuh.gradle.ktlint" +apply plugin: "com.google.dagger.hilt.android" android { @@ -164,6 +165,8 @@ dependencies { implementation "dev.rikka.shizuku:api:$shizuku_version" implementation "dev.rikka.shizuku:provider:$shizuku_version" implementation "org.lsposed.hiddenapibypass:hiddenapibypass:4.3" + implementation("com.google.dagger:hilt-android:2.51") + kapt("com.google.dagger:hilt-android-compiler:2.51") proImplementation "com.android.billingclient:billing:7.1.1" proImplementation "com.android.billingclient:billing-ktx:7.1.1" diff --git a/app/src/free/java/io/github/sds100/keymapper/MainActivity.kt b/app/src/free/java/io/github/sds100/keymapper/MainActivity.kt new file mode 100644 index 0000000000..8c248798d4 --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/MainActivity.kt @@ -0,0 +1,3 @@ +import io.github.sds100.keymapper.BaseMainActivity + +class MainActivity : BaseMainActivity() diff --git a/app/src/main/java/io/github/sds100/keymapper/MainActivity.kt b/app/src/main/java/io/github/sds100/keymapper/BaseMainActivity.kt similarity index 95% rename from app/src/main/java/io/github/sds100/keymapper/MainActivity.kt rename to app/src/main/java/io/github/sds100/keymapper/BaseMainActivity.kt index 4e3580d10a..2b309bd304 100644 --- a/app/src/main/java/io/github/sds100/keymapper/MainActivity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/BaseMainActivity.kt @@ -21,7 +21,7 @@ import timber.log.Timber * Created by sds100 on 19/02/2020. */ -class MainActivity : AppCompatActivity() { +abstract class BaseMainActivity : AppCompatActivity() { companion object { const val ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG = @@ -51,7 +51,7 @@ class MainActivity : AppCompatActivity() { requestPermissionDelegate = RequestPermissionDelegate(this, showDialogs = true) - ServiceLocator.permissionAdapter(this@MainActivity).request + ServiceLocator.permissionAdapter(this@BaseMainActivity).request .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) .onEach { permission -> requestPermissionDelegate.requestPermission( diff --git a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt index 170339f2f2..8f0c251013 100644 --- a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt +++ b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.OnLifecycleEvent import androidx.lifecycle.ProcessLifecycleOwner import androidx.multidex.MultiDexApplication import com.google.android.material.color.DynamicColors +import dagger.hilt.android.HiltAndroidApp import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.entities.LogEntryEntity import io.github.sds100.keymapper.logging.KeyMapperLoggingTree @@ -64,6 +65,7 @@ import java.util.Calendar /** * Created by sds100 on 19/05/2020. */ +@HiltAndroidApp class KeyMapperApp : MultiDexApplication() { val appCoroutineScope = MainScope() diff --git a/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt b/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt index 815b868bb8..e66573c0d6 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt @@ -3,8 +3,8 @@ package io.github.sds100.keymapper.system.notifications import androidx.annotation.VisibleForTesting import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import io.github.sds100.keymapper.BaseMainActivity import io.github.sds100.keymapper.Constants -import io.github.sds100.keymapper.MainActivity import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.PauseMappingsUseCase import io.github.sds100.keymapper.mappings.fingerprintmaps.AreFingerprintGesturesSupportedUseCase @@ -250,7 +250,7 @@ class NotificationController( private fun attemptStartAccessibilityService() { if (!controlAccessibilityService.startService()) { coroutineScope.launch { - _openApp.emit(MainActivity.ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG) + _openApp.emit(BaseMainActivity.ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG) } } } @@ -258,7 +258,7 @@ class NotificationController( private fun attemptRestartAccessibilityService() { if (!controlAccessibilityService.restartService()) { coroutineScope.launch { - _openApp.emit(MainActivity.ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG) + _openApp.emit(BaseMainActivity.ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG) } } } diff --git a/build.gradle b/build.gradle index a891f2f9bb..cbb9f0b487 100644 --- a/build.gradle +++ b/build.gradle @@ -14,10 +14,11 @@ buildscript { def nav_version = '2.6.0' classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$nav_version" - classpath 'com.android.tools.build:gradle:8.4.2' + classpath "com.android.tools.build:gradle:8.4.2" classpath "org.jlleitschuh.gradle:ktlint-gradle:12.1.0" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" + classpath "com.google.dagger:hilt-android-gradle-plugin:2.52" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files From 155bde555d5901113714b4aa973cc8f73b4120a3 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Oct 2024 19:08:16 +0200 Subject: [PATCH 071/118] refactor: use data object instead of object for Result errors --- .../io/github/sds100/keymapper/util/Result.kt | 82 +++++++++---------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Result.kt b/app/src/main/java/io/github/sds100/keymapper/util/Result.kt index 9399cba72b..6f43448f24 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Result.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Result.kt @@ -24,37 +24,37 @@ sealed class Error : Result() { data class SdkVersionTooLow(val minSdk: Int) : Error() data class SdkVersionTooHigh(val maxSdk: Int) : Error() data class InputMethodNotFound(val imeLabel: String) : Error() - object NoVoiceAssistant : Error() - object NoDeviceAssistant : Error() - object NoCameraApp : Error() - object NoSettingsApp : Error() - object FrontFlashNotFound : Error() - object BackFlashNotFound : Error() + data object NoVoiceAssistant : Error() + data object NoDeviceAssistant : Error() + data object NoCameraApp : Error() + data object NoSettingsApp : Error() + data object FrontFlashNotFound : Error() + data object BackFlashNotFound : Error() data class ImeDisabled(val ime: ImeInfo) : Error() data class DeviceNotFound(val descriptor: String) : Error() - object InvalidNumber : Error() + data object InvalidNumber : Error() data class NumberTooBig(val max: Int) : Error() data class NumberTooSmall(val min: Int) : Error() - object EmptyText : Error() - object NoIncompatibleKeyboardsInstalled : Error() - object NoMediaSessions : Error() - object BackupVersionTooNew : Error() - object LauncherShortcutsNotSupported : Error() + data object EmptyText : Error() + data object NoIncompatibleKeyboardsInstalled : Error() + data object NoMediaSessions : Error() + data object BackupVersionTooNew : Error() + data object LauncherShortcutsNotSupported : Error() data class AppNotFound(val packageName: String) : Error() data class AppDisabled(val packageName: String) : Error() - object AppShortcutCantBeOpened : Error() - object InsufficientPermissionsToOpenAppShortcut : Error() - object NoCompatibleImeEnabled : Error() - object NoCompatibleImeChosen : Error() + data object AppShortcutCantBeOpened : Error() + data object InsufficientPermissionsToOpenAppShortcut : Error() + data object NoCompatibleImeEnabled : Error() + data object NoCompatibleImeChosen : Error() - object AccessibilityServiceDisabled : Error() - object AccessibilityServiceCrashed : Error() + data object AccessibilityServiceDisabled : Error() + data object AccessibilityServiceCrashed : Error() - object CantShowImePickerInBackground : Error() - object CantFindImeSettings : Error() - object GestureStrokeCountTooHigh : Error() - object GestureDurationTooHigh : Error() + data object CantShowImePickerInBackground : Error() + data object CantFindImeSettings : Error() + data object GestureStrokeCountTooHigh : Error() + data object GestureDurationTooHigh : Error() data class PermissionDenied(val permission: Permission) : Error() { companion object { @@ -86,40 +86,40 @@ sealed class Error : Result() { } } - object FailedToFindAccessibilityNode : Error() + data object FailedToFindAccessibilityNode : Error() data class FailedToPerformAccessibilityGlobalAction(val action: Int) : Error() - object FailedToDispatchGesture : Error() + data object FailedToDispatchGesture : Error() - object CameraInUse : Error() - object CameraDisconnected : Error() - object CameraDisabled : Error() - object MaxCamerasInUse : Error() - object CameraError : Error() + data object CameraInUse : Error() + data object CameraDisconnected : Error() + data object CameraDisabled : Error() + data object MaxCamerasInUse : Error() + data object CameraError : Error() data class FailedToModifySystemSetting(val setting: String) : Error() - object FailedToChangeIme : Error() - object NoAppToOpenUrl : Error() - object NoAppToPhoneCall : Error() + data object FailedToChangeIme : Error() + data object NoAppToOpenUrl : Error() + data object NoAppToPhoneCall : Error() data class NotAFile(val uri: String) : Error() data class NotADirectory(val uri: String) : Error() - object StoragePermissionDenied : Error() + data object StoragePermissionDenied : Error() data class CannotCreateFileInTarget(val uri: String) : Error() data class SourceFileNotFound(val uri: String) : Error() data class TargetFileNotFound(val uri: String) : Error() data class TargetDirectoryNotFound(val uri: String) : Error() - object UnknownIOError : Error() - object FileOperationCancelled : Error() - object TargetDirectoryMatchesSourceDirectory : Error() + data object UnknownIOError : Error() + data object FileOperationCancelled : Error() + data object TargetDirectoryMatchesSourceDirectory : Error() data class NoSpaceLeftOnTarget(val uri: String) : Error() - object NoFileName : Error() + data object NoFileName : Error() - object EmptyJson : Error() - object CantFindSoundFile : Error() + data object EmptyJson : Error() + data object CantFindSoundFile : Error() data class CorruptJsonFile(val reason: String) : Error() - object ShizukuNotStarted : Error() - object CantDetectKeyEventsInPhoneCall : Error() + data object ShizukuNotStarted : Error() + data object CantDetectKeyEventsInPhoneCall : Error() } inline fun Result.onSuccess(f: (T) -> Unit): Result { From 9c8582d5a03f5085b444105bf92405273daebb85 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Oct 2024 21:42:02 +0200 Subject: [PATCH 072/118] #1274 fix errors for free build --- .../github/sds100/keymapper/MainActivity.kt | 2 +- .../keymaps/detection/KeyMapController.kt | 133 +++++++++++------- .../trigger/AdvancedTriggersBottomSheet.kt | 1 + 3 files changed, 85 insertions(+), 51 deletions(-) diff --git a/app/src/free/java/io/github/sds100/keymapper/MainActivity.kt b/app/src/free/java/io/github/sds100/keymapper/MainActivity.kt index 8c248798d4..51dddd9b2e 100644 --- a/app/src/free/java/io/github/sds100/keymapper/MainActivity.kt +++ b/app/src/free/java/io/github/sds100/keymapper/MainActivity.kt @@ -1,3 +1,3 @@ -import io.github.sds100.keymapper.BaseMainActivity +package io.github.sds100.keymapper class MainActivity : BaseMainActivity() diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt index 36e5ff4df4..8516e81376 100644 --- a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/detection/KeyMapController.kt @@ -17,6 +17,7 @@ import io.github.sds100.keymapper.mappings.keymaps.KeyMap import io.github.sds100.keymapper.mappings.keymaps.KeyMapAction import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger +import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode import io.github.sds100.keymapper.system.devices.InputDeviceInfo @@ -110,47 +111,51 @@ class KeyMapController( val parallelTriggerModifierKeyIndices = mutableListOf>() // Only process key maps that can be triggered - val validKeyMaps = value.filter { - it.actionList.isNotEmpty() && it.isEnabled + val validKeyMaps = value.filter { keyMap -> + keyMap.actionList.isNotEmpty() && + keyMap.isEnabled && + keyMap.trigger.keys.all { it is KeyCodeTriggerKey } } for ((triggerIndex, keyMap) in validKeyMaps.withIndex()) { // TRIGGER STUFF - keyMap.trigger.keys.forEachIndexed { keyIndex, key -> - if (keyMap.trigger.mode == TriggerMode.Sequence && - key.clickType == ClickType.LONG_PRESS - ) { - - if (keyMap.trigger.keys.size > 1) { - longPressSequenceTriggerKeys.add(key) + keyMap.trigger.keys + .mapNotNull { it as? KeyCodeTriggerKey } + .forEachIndexed { keyIndex, key -> + if (keyMap.trigger.mode == TriggerMode.Sequence && + key.clickType == ClickType.LONG_PRESS + ) { + + if (keyMap.trigger.keys.size > 1) { + longPressSequenceTriggerKeys.add(key) + } } - } - if (( - keyMap.trigger.mode == TriggerMode.Sequence || - keyMap.trigger.mode == TriggerMode.Undefined - ) && - key.clickType == ClickType.DOUBLE_PRESS - ) { - doublePressKeys.add(TriggerKeyLocation(triggerIndex, keyIndex)) - } - - when (key.device) { - TriggerKeyDevice.Internal -> { - detectInternalEvents = true + if (( + keyMap.trigger.mode == TriggerMode.Sequence || + keyMap.trigger.mode == TriggerMode.Undefined + ) && + key.clickType == ClickType.DOUBLE_PRESS + ) { + doublePressKeys.add(TriggerKeyLocation(triggerIndex, keyIndex)) } - TriggerKeyDevice.Any -> { - detectInternalEvents = true - detectExternalEvents = true - } + when (key.device) { + TriggerKeyDevice.Internal -> { + detectInternalEvents = true + } + + TriggerKeyDevice.Any -> { + detectInternalEvents = true + detectExternalEvents = true + } - is TriggerKeyDevice.External -> { - detectExternalEvents = true + is TriggerKeyDevice.External -> { + detectExternalEvents = true + } } } - } val encodedActionList = encodeActionList(keyMap.actionList) @@ -328,11 +333,13 @@ class KeyMapController( for (triggerIndex in parallelTriggers) { val trigger = triggers[triggerIndex] - trigger.keys.forEachIndexed { keyIndex, key -> - if (isModifierKey(key.keyCode)) { - parallelTriggerModifierKeyIndices.add(triggerIndex to keyIndex) + trigger.keys + .mapNotNull { it as? KeyCodeTriggerKey } + .forEachIndexed { keyIndex, key -> + if (isModifierKey(key.keyCode)) { + parallelTriggerModifierKeyIndices.add(triggerIndex to keyIndex) + } } - } } reset() @@ -571,14 +578,18 @@ class KeyMapController( metaStateFromKeyEvent = metaState // remove the metastate from any modifier keys that remapped and are pressed down - parallelTriggerModifierKeyIndices.forEach { + for (it in parallelTriggerModifierKeyIndices) { val triggerIndex = it.first val eventIndex = it.second - val event = triggers[triggerIndex].keys[eventIndex] + val key = triggers[triggerIndex].keys[eventIndex] + + if (key !is KeyCodeTriggerKey) { + continue + } if (parallelTriggerEventsAwaitingRelease[triggerIndex][eventIndex]) { metaStateFromKeyEvent = - metaStateFromKeyEvent.minusFlag(KeyEventUtils.modifierKeycodeToMetaState(event.keyCode)) + metaStateFromKeyEvent.minusFlag(KeyEventUtils.modifierKeycodeToMetaState(key.keyCode)) } } @@ -644,6 +655,10 @@ class KeyMapController( // consume the event if the trigger contains this keycode. for ((keyIndex, key) in triggerKeys.withIndex()) { + if (key !is KeyCodeTriggerKey) { + continue + } + if (key.keyCode == event.keyCode && triggerKeys[keyIndex].consumeKeyEvent) { consumeEvent = true } @@ -1478,22 +1493,32 @@ class KeyMapController( return key.matchesEvent(event) } - private fun KeyCodeTriggerKey.matchesEvent(event: Event): Boolean = when (this.device) { - TriggerKeyDevice.Any -> this.keyCode == event.keyCode && this.clickType == event.clickType - is TriggerKeyDevice.External -> - this.keyCode == event.keyCode && - event.descriptor != null && - event.descriptor == this.device.descriptor && - this.clickType == event.clickType - - TriggerKeyDevice.Internal -> - this.keyCode == event.keyCode && - event.descriptor == null && - this.clickType == event.clickType + private fun TriggerKey.matchesEvent(event: Event): Boolean { + if (this !is KeyCodeTriggerKey) { + return false + } + + return when (this.device) { + TriggerKeyDevice.Any -> this.keyCode == event.keyCode && this.clickType == event.clickType + is TriggerKeyDevice.External -> + this.keyCode == event.keyCode && + event.descriptor != null && + event.descriptor == this.device.descriptor && + this.clickType == event.clickType + + TriggerKeyDevice.Internal -> + this.keyCode == event.keyCode && + event.descriptor == null && + this.clickType == event.clickType + } } - private fun KeyCodeTriggerKey.matchesWithOtherKey(otherKey: KeyCodeTriggerKey): Boolean = - when (this.device) { + private fun TriggerKey.matchesWithOtherKey(otherKey: TriggerKey): Boolean { + if (!(this is KeyCodeTriggerKey && otherKey is KeyCodeTriggerKey)) { + return false + } + + return when (this.device) { TriggerKeyDevice.Any -> this.keyCode == otherKey.keyCode && this.clickType == otherKey.clickType @@ -1508,6 +1533,7 @@ class KeyMapController( otherKey.device == TriggerKeyDevice.Internal && this.clickType == otherKey.clickType } + } private fun longPressDelay(trigger: Trigger): Long = trigger.longPressDelay?.toLong() ?: defaultLongPressDelay.value @@ -1571,4 +1597,11 @@ class KeyMapController( ) private data class TriggerKeyLocation(val triggerIndex: Int, val keyIndex: Int) + + private val TriggerKey.consumeKeyEvent: Boolean + get() = if (this is KeyCodeTriggerKey) { + this.consumeEvent + } else { + false + } } diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt index 80a213c4e2..46844b8fa3 100644 --- a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch @Composable fun AdvancedTriggersBottomSheet( modifier: Modifier = Modifier, + viewModel: ConfigTriggerViewModel, onDismissRequest: () -> Unit, sheetState: SheetState, ) { From 7baf10811b6a8d8a7e5896c231c55d9d344bdcb1 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Oct 2024 21:42:52 +0200 Subject: [PATCH 073/118] #1274 add revenuecat public api key to BuildConfig from local.properties --- app/build.gradle | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 6eb86e9740..511b965480 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -83,6 +83,10 @@ android { } pro { dimension "pro" + + def localProperties = new Properties() + localProperties.load(new FileInputStream(rootProject.file("local.properties"))) + buildConfigField("String", "REVENUECAT_API_KEY", localProperties["REVENUECAT_API_KEY"]) } } @@ -167,8 +171,8 @@ dependencies { implementation "org.lsposed.hiddenapibypass:hiddenapibypass:4.3" implementation("com.google.dagger:hilt-android:2.51") kapt("com.google.dagger:hilt-android-compiler:2.51") - proImplementation "com.android.billingclient:billing:7.1.1" - proImplementation "com.android.billingclient:billing-ktx:7.1.1" + proImplementation 'com.revenuecat.purchases:purchases:8.8.1' + // splitties implementation "com.louiscad.splitties:splitties-bitflags:$splitties_version" From a5ba747241e9bfc97933fa17d79812e3a4c6ec95 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 15 Oct 2024 21:45:33 +0200 Subject: [PATCH 074/118] #1274 pass viewmodel to AdvancedTriggersBottomSheet so it is compatible with free build flavor --- .../mappings/keymaps/trigger/RecordTriggerButtonRow.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt index aacff7e842..c32a852fc9 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt @@ -48,10 +48,10 @@ fun RecordTriggerButtonRow( if (showBottomSheet) { AdvancedTriggersBottomSheet( modifier = Modifier.systemBarsPadding(), + viewModel = viewModel, onDismissRequest = { showBottomSheet = false }, - onChooseAssistantTrigger = viewModel::onChooseAssistantTrigger, sheetState = sheetState, ) } From 3b19cfb1b4c42a5916b437d9a7784b8d9de2f6bf Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 16 Oct 2024 20:48:31 +0200 Subject: [PATCH 075/118] #1274 delete hilt and inject PurchasingUseCase into ConfigTriggerViewModel --- app/build.gradle | 3 --- .../keymaps/trigger/ConfigTriggerViewModel.kt | 2 ++ .../sds100/keymapper/purchasing/PurchasingManager.kt | 11 +++++++++++ .../sds100/keymapper/purchasing/PurchasingUseCase.kt | 5 +++++ .../java/io/github/sds100/keymapper/KeyMapperApp.kt | 7 +++++-- .../java/io/github/sds100/keymapper/ServiceLocator.kt | 4 ++++ .../mappings/keymaps/ConfigKeyMapViewModel.kt | 5 +++++ .../java/io/github/sds100/keymapper/util/Inject.kt | 5 +++++ build.gradle | 1 - 9 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt create mode 100644 app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingUseCase.kt diff --git a/app/build.gradle b/app/build.gradle index 511b965480..5e51ebea7b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,7 +5,6 @@ apply plugin: "androidx.navigation.safeargs.kotlin" apply plugin: "kotlinx-serialization" apply plugin: "org.jetbrains.kotlin.plugin.parcelize" apply plugin: "org.jlleitschuh.gradle.ktlint" -apply plugin: "com.google.dagger.hilt.android" android { @@ -169,8 +168,6 @@ dependencies { implementation "dev.rikka.shizuku:api:$shizuku_version" implementation "dev.rikka.shizuku:provider:$shizuku_version" implementation "org.lsposed.hiddenapibypass:hiddenapibypass:4.3" - implementation("com.google.dagger:hilt-android:2.51") - kapt("com.google.dagger:hilt-android-compiler:2.51") proImplementation 'com.revenuecat.purchases:purchases:8.8.1' diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt index 448c9190c8..b196367496 100644 --- a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt @@ -4,6 +4,7 @@ import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapUseCase import io.github.sds100.keymapper.mappings.keymaps.CreateKeyMapShortcutUseCase import io.github.sds100.keymapper.mappings.keymaps.DisplayKeyMapUseCase import io.github.sds100.keymapper.onboarding.OnboardingUseCase +import io.github.sds100.keymapper.purchasing.PurchasingUseCase import io.github.sds100.keymapper.util.ui.ResourceProvider import kotlinx.coroutines.CoroutineScope @@ -19,6 +20,7 @@ class ConfigTriggerViewModel( createKeyMapShortcut: CreateKeyMapShortcutUseCase, displayKeyMap: DisplayKeyMapUseCase, resourceProvider: ResourceProvider, + purchasingUseCase: PurchasingUseCase, ) : BaseConfigTriggerViewModel( coroutineScope, onboarding, diff --git a/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt b/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt new file mode 100644 index 0000000000..66e0d5a455 --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt @@ -0,0 +1,11 @@ +package io.github.sds100.keymapper.purchasing + +import android.content.Context +import kotlinx.coroutines.CoroutineScope + +class PurchasingManagerImpl( + context: Context, + private val coroutineScope: CoroutineScope, +) : PurchasingManager + +interface PurchasingManager diff --git a/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingUseCase.kt b/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingUseCase.kt new file mode 100644 index 0000000000..ee61efbcb5 --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingUseCase.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.purchasing + +class PurchasingUseCaseImpl(private val manager: PurchasingManager) : PurchasingUseCase + +interface PurchasingUseCase diff --git a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt index 8f0c251013..295bb9f614 100644 --- a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt +++ b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt @@ -9,11 +9,11 @@ import androidx.lifecycle.OnLifecycleEvent import androidx.lifecycle.ProcessLifecycleOwner import androidx.multidex.MultiDexApplication import com.google.android.material.color.DynamicColors -import dagger.hilt.android.HiltAndroidApp import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.entities.LogEntryEntity import io.github.sds100.keymapper.logging.KeyMapperLoggingTree import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerController +import io.github.sds100.keymapper.purchasing.PurchasingManagerImpl import io.github.sds100.keymapper.settings.ThemeUtils import io.github.sds100.keymapper.shizuku.ShizukuAdapterImpl import io.github.sds100.keymapper.system.AndroidSystemFeatureAdapter @@ -65,7 +65,6 @@ import java.util.Calendar /** * Created by sds100 on 19/05/2020. */ -@HiltAndroidApp class KeyMapperApp : MultiDexApplication() { val appCoroutineScope = MainScope() @@ -154,6 +153,10 @@ class KeyMapperApp : MultiDexApplication() { ) } + val purchasingManager: PurchasingManagerImpl by lazy { + PurchasingManagerImpl(this.applicationContext, appCoroutineScope) + } + private val loggingTree by lazy { KeyMapperLoggingTree( appCoroutineScope, diff --git a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt index cab33f85f1..01286c4f87 100755 --- a/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/ServiceLocator.kt @@ -15,6 +15,7 @@ import io.github.sds100.keymapper.data.repositories.RoomLogRepository import io.github.sds100.keymapper.data.repositories.SettingsPreferenceRepository import io.github.sds100.keymapper.logging.LogRepository import io.github.sds100.keymapper.mappings.fingerprintmaps.FingerprintMapRepository +import io.github.sds100.keymapper.purchasing.PurchasingManagerImpl import io.github.sds100.keymapper.shizuku.ShizukuAdapter import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter import io.github.sds100.keymapper.system.airplanemode.AirplaneModeAdapter @@ -252,6 +253,9 @@ object ServiceLocator { fun appCoroutineScope(context: Context): CoroutineScope = (context.applicationContext as KeyMapperApp).appCoroutineScope + fun purchasingManager(context: Context): PurchasingManagerImpl = + (context.applicationContext as KeyMapperApp).purchasingManager + private fun createDatabase(context: Context): AppDatabase = Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt index f50029da10..89778bed30 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt @@ -15,6 +15,7 @@ import io.github.sds100.keymapper.mappings.keymaps.trigger.ConfigTriggerKeyViewM import io.github.sds100.keymapper.mappings.keymaps.trigger.ConfigTriggerViewModel import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerUseCase import io.github.sds100.keymapper.onboarding.OnboardingUseCase +import io.github.sds100.keymapper.purchasing.PurchasingUseCase import io.github.sds100.keymapper.ui.utils.getJsonSerializable import io.github.sds100.keymapper.ui.utils.putJsonSerializable import io.github.sds100.keymapper.util.State @@ -38,6 +39,7 @@ class ConfigKeyMapViewModel( private val displayMapping: DisplayKeyMapUseCase, createActionUseCase: CreateActionUseCase, resourceProvider: ResourceProvider, + purchasingUseCase: PurchasingUseCase, ) : ViewModel(), ConfigMappingViewModel, ResourceProvider by resourceProvider { @@ -70,6 +72,7 @@ class ConfigKeyMapViewModel( createKeyMapShortcut, displayMapping, resourceProvider, + purchasingUseCase, ) override val configConstraintsViewModel = ConfigConstraintsViewModel( @@ -130,6 +133,7 @@ class ConfigKeyMapViewModel( private val displayMapping: DisplayKeyMapUseCase, private val createActionUseCase: CreateActionUseCase, private val resourceProvider: ResourceProvider, + private val purchasingUseCase: PurchasingUseCase, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -143,6 +147,7 @@ class ConfigKeyMapViewModel( displayMapping, createActionUseCase, resourceProvider, + purchasingUseCase, ) as T } } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt index cea744e65c..a275a3277d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt @@ -32,6 +32,7 @@ import io.github.sds100.keymapper.mappings.keymaps.CreateKeyMapShortcutViewModel import io.github.sds100.keymapper.mappings.keymaps.ListKeyMapsUseCaseImpl import io.github.sds100.keymapper.onboarding.AppIntroUseCaseImpl import io.github.sds100.keymapper.onboarding.AppIntroViewModel +import io.github.sds100.keymapper.purchasing.PurchasingUseCaseImpl import io.github.sds100.keymapper.reportbug.ReportBugUseCaseImpl import io.github.sds100.keymapper.reportbug.ReportBugViewModel import io.github.sds100.keymapper.settings.ConfigSettingsUseCaseImpl @@ -129,6 +130,9 @@ object Inject { ServiceLocator.resourceProvider(context), ) + fun purchasingUseCase(ctx: Context) = + PurchasingUseCaseImpl(ServiceLocator.purchasingManager(ctx)) + fun configKeyMapViewModel( ctx: Context, ): ConfigKeyMapViewModel.Factory = ConfigKeyMapViewModel.Factory( @@ -140,6 +144,7 @@ object Inject { UseCases.displayKeyMap(ctx), UseCases.createAction(ctx), ServiceLocator.resourceProvider(ctx), + purchasingUseCase(ctx), ) fun configFingerprintMapViewModel( diff --git a/build.gradle b/build.gradle index cbb9f0b487..381194ab37 100644 --- a/build.gradle +++ b/build.gradle @@ -18,7 +18,6 @@ buildscript { classpath "org.jlleitschuh.gradle:ktlint-gradle:12.1.0" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" - classpath "com.google.dagger:hilt-android-gradle-plugin:2.52" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files From 121f4f87857d0499df66d1e055ed2bd01631abc8 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 16 Oct 2024 21:11:13 +0200 Subject: [PATCH 076/118] remove unused home_tab_titles string array from resources --- app/src/main/res/values-cs/strings.xml | 4 ---- app/src/main/res/values-es/strings.xml | 5 +---- app/src/main/res/values-pl/strings.xml | 5 +---- app/src/main/res/values-ru/strings.xml | 4 ---- app/src/main/res/values-sk/strings.xml | 4 ---- app/src/main/res/values-vi/strings.xml | 5 +---- app/src/main/res/values-zh-rCN/strings.xml | 4 ---- 7 files changed, 3 insertions(+), 28 deletions(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 586b487fd6..7cf53a6497 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -159,10 +159,6 @@ Akce Spouštěč Otisk prstu - - @string/tab_keyevents - \@řetězec/tab_otisk - diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 90b3e8b743..250922d5b4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -161,10 +161,7 @@ Acciones Activador Huella digital - - @string/tab_keyevents - @string/tab_fingerprint - + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3efa3bf9c6..53ed08557e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -176,10 +176,7 @@ Działania Wyzwalacz Odcisk palca - - @string/tab_keyevents - @string/tab_fingerprint - + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 35fadc1244..1b8166dc29 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -161,10 +161,6 @@ Действия События Отпечаток пальцев - - События нажатий - Отпечаток пальца - diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index cb2f69c12e..7b4f7cc9f8 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -154,10 +154,6 @@ Akcie Spúšťač Odtlačok prsta - - @string/tab_keyevents - @string/tab_fingerprint - diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 094f2853d8..79e790db0f 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -172,10 +172,7 @@ Hành động Trigger Dấu vân tay - - @string/tab_keyevents - @string/tab_fingerprint - + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0f9249a0bb..4aeb221369 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -176,10 +176,6 @@ 动作 触发器 指纹 - - 按键事件 - 指纹 - From 00ed19c7cd95089974c62e02b4a66976490b2c3a Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 16 Oct 2024 21:31:19 +0200 Subject: [PATCH 077/118] #1274 add debug_release build type that does not alter the package name so can develop and test billing at the same time --- app/build.gradle | 14 +++++++++++++- systemstubs/build.gradle.kts | 3 +++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 5e51ebea7b..232343263e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,6 +55,18 @@ android { versionNameSuffix "-debug" } + // Do not alter the package name so can test revenuecat and billing while developing. + debug_release { + minifyEnabled false + versionNameSuffix "-debug" + signingConfig signingConfigs.debug + + /* + This is required because the splitties library does not have a debug_release build type. + */ + matchingFallbacks = ["debug"] + } + ci { postprocessing { removeUnusedCode true @@ -65,7 +77,7 @@ android { } /* - This is required because the splitties library doesn"t have a ci build type. + This is required because the splitties library does not have a ci build type. */ matchingFallbacks = ["debug"] diff --git a/systemstubs/build.gradle.kts b/systemstubs/build.gradle.kts index 7121b0ba36..55b3f6ce1a 100644 --- a/systemstubs/build.gradle.kts +++ b/systemstubs/build.gradle.kts @@ -22,6 +22,9 @@ android { "proguard-rules.pro", ) } + + create("debug_release") { + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 From 381c874d8ec1100d4861f303daf6c5f4013137ef Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 16 Oct 2024 21:36:03 +0200 Subject: [PATCH 078/118] #1274 bump compose library versions and add tooling for debug_release build type --- app/build.gradle | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 232343263e..f76514ff5b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -217,11 +217,12 @@ dependencies { kapt "androidx.room:room-compiler:$room_version" // Compose - implementation "androidx.compose.ui:ui-android:1.7.2" + implementation "androidx.compose.ui:ui-android:1.7.4" implementation "androidx.compose.material3:material3-android:1.3.0" - implementation "androidx.compose.ui:ui-tooling-preview-android:1.7.2" - implementation "androidx.compose.material:material-icons-extended-android:1.7.2" - debugImplementation "androidx.compose.ui:ui-tooling:1.7.2" + implementation "androidx.compose.ui:ui-tooling-preview-android:1.7.4" + implementation "androidx.compose.material:material-icons-extended-android:1.7.4" + debugImplementation "androidx.compose.ui:ui-tooling:1.7.4" + debug_releaseImplementation "androidx.compose.ui:ui-tooling:1.7.4" // debugImplementation "com.squareup.leakcanary:leakcanary-android:2.6" From fc22ab092839e6fce6777f56a116bdb1a0eb2c4f Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 16 Oct 2024 21:41:26 +0200 Subject: [PATCH 079/118] #1274 extend debug_release from debug build type --- app/build.gradle | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f76514ff5b..f8b1fa1cf5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -55,11 +55,12 @@ android { versionNameSuffix "-debug" } - // Do not alter the package name so can test revenuecat and billing while developing. debug_release { - minifyEnabled false - versionNameSuffix "-debug" - signingConfig signingConfigs.debug + // Extend from debug build type so compose Live Edit and rapid building works + initWith debug + + // Do not alter the package name so can test revenuecat and billing while developing. + applicationIdSuffix "" /* This is required because the splitties library does not have a debug_release build type. From 48aac718594aa6793bf32ecc24d39f3eb17d5760 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 16 Oct 2024 22:10:03 +0200 Subject: [PATCH 080/118] #1274 purchase assistant trigger and check whether it is purchased --- .../mappings/keymaps/trigger/ConfigTriggerViewModel.kt | 4 ++-- .../sds100/keymapper/purchasing/PurchasingUseCase.kt | 5 ----- .../mappings/keymaps/ConfigKeyMapViewModel.kt | 10 +++++----- .../java/io/github/sds100/keymapper/util/Inject.kt | 6 +----- 4 files changed, 8 insertions(+), 17 deletions(-) delete mode 100644 app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingUseCase.kt diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt index b196367496..b3a1651c31 100644 --- a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt @@ -4,7 +4,7 @@ import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapUseCase import io.github.sds100.keymapper.mappings.keymaps.CreateKeyMapShortcutUseCase import io.github.sds100.keymapper.mappings.keymaps.DisplayKeyMapUseCase import io.github.sds100.keymapper.onboarding.OnboardingUseCase -import io.github.sds100.keymapper.purchasing.PurchasingUseCase +import io.github.sds100.keymapper.purchasing.PurchasingManager import io.github.sds100.keymapper.util.ui.ResourceProvider import kotlinx.coroutines.CoroutineScope @@ -20,7 +20,7 @@ class ConfigTriggerViewModel( createKeyMapShortcut: CreateKeyMapShortcutUseCase, displayKeyMap: DisplayKeyMapUseCase, resourceProvider: ResourceProvider, - purchasingUseCase: PurchasingUseCase, + private val purchasingManager: PurchasingManager, ) : BaseConfigTriggerViewModel( coroutineScope, onboarding, diff --git a/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingUseCase.kt b/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingUseCase.kt deleted file mode 100644 index ee61efbcb5..0000000000 --- a/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingUseCase.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.github.sds100.keymapper.purchasing - -class PurchasingUseCaseImpl(private val manager: PurchasingManager) : PurchasingUseCase - -interface PurchasingUseCase diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt index 89778bed30..8575a29006 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapViewModel.kt @@ -15,7 +15,7 @@ import io.github.sds100.keymapper.mappings.keymaps.trigger.ConfigTriggerKeyViewM import io.github.sds100.keymapper.mappings.keymaps.trigger.ConfigTriggerViewModel import io.github.sds100.keymapper.mappings.keymaps.trigger.RecordTriggerUseCase import io.github.sds100.keymapper.onboarding.OnboardingUseCase -import io.github.sds100.keymapper.purchasing.PurchasingUseCase +import io.github.sds100.keymapper.purchasing.PurchasingManager import io.github.sds100.keymapper.ui.utils.getJsonSerializable import io.github.sds100.keymapper.ui.utils.putJsonSerializable import io.github.sds100.keymapper.util.State @@ -39,7 +39,7 @@ class ConfigKeyMapViewModel( private val displayMapping: DisplayKeyMapUseCase, createActionUseCase: CreateActionUseCase, resourceProvider: ResourceProvider, - purchasingUseCase: PurchasingUseCase, + purchasingManager: PurchasingManager, ) : ViewModel(), ConfigMappingViewModel, ResourceProvider by resourceProvider { @@ -72,7 +72,7 @@ class ConfigKeyMapViewModel( createKeyMapShortcut, displayMapping, resourceProvider, - purchasingUseCase, + purchasingManager, ) override val configConstraintsViewModel = ConfigConstraintsViewModel( @@ -133,7 +133,7 @@ class ConfigKeyMapViewModel( private val displayMapping: DisplayKeyMapUseCase, private val createActionUseCase: CreateActionUseCase, private val resourceProvider: ResourceProvider, - private val purchasingUseCase: PurchasingUseCase, + private val purchasingManager: PurchasingManager, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") @@ -147,7 +147,7 @@ class ConfigKeyMapViewModel( displayMapping, createActionUseCase, resourceProvider, - purchasingUseCase, + purchasingManager, ) as T } } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt index a275a3277d..2914328dd9 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Inject.kt @@ -32,7 +32,6 @@ import io.github.sds100.keymapper.mappings.keymaps.CreateKeyMapShortcutViewModel import io.github.sds100.keymapper.mappings.keymaps.ListKeyMapsUseCaseImpl import io.github.sds100.keymapper.onboarding.AppIntroUseCaseImpl import io.github.sds100.keymapper.onboarding.AppIntroViewModel -import io.github.sds100.keymapper.purchasing.PurchasingUseCaseImpl import io.github.sds100.keymapper.reportbug.ReportBugUseCaseImpl import io.github.sds100.keymapper.reportbug.ReportBugViewModel import io.github.sds100.keymapper.settings.ConfigSettingsUseCaseImpl @@ -130,9 +129,6 @@ object Inject { ServiceLocator.resourceProvider(context), ) - fun purchasingUseCase(ctx: Context) = - PurchasingUseCaseImpl(ServiceLocator.purchasingManager(ctx)) - fun configKeyMapViewModel( ctx: Context, ): ConfigKeyMapViewModel.Factory = ConfigKeyMapViewModel.Factory( @@ -144,7 +140,7 @@ object Inject { UseCases.displayKeyMap(ctx), UseCases.createAction(ctx), ServiceLocator.resourceProvider(ctx), - purchasingUseCase(ctx), + ServiceLocator.purchasingManager(ctx), ) fun configFingerprintMapViewModel( From 89948a902f682f187487154c943b9092df3ae738 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 7 Nov 2024 17:36:28 +0100 Subject: [PATCH 081/118] #1328 fix: don't crash when generating the key events for a non-ASCII text action --- .../system/inputmethod/KeyMapperImeMessenger.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/KeyMapperImeMessenger.kt b/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/KeyMapperImeMessenger.kt index a0e1f0ccde..61ec51ce61 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/KeyMapperImeMessenger.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/inputmethod/KeyMapperImeMessenger.kt @@ -168,13 +168,17 @@ class KeyMapperImeMessengerImpl( val chars = text.toCharArray(startIndex = 0, endIndex = 1) - val events: Array = keyCharacterMap.getEvents(chars) + val events: Array? = keyCharacterMap.getEvents(chars) - for (i in events.indices) { - keyEventRelayService.sendKeyEvent(events[i], imePackageName) - } + // The events can be null if there isn't a way to input the character + // with the current key character map. + if (events != null) { + for (e in events) { + keyEventRelayService.sendKeyEvent(e, imePackageName) + } - return + return + } } // Otherwise, revert to the special key event containing From ce49d6fcc16e96a76e4a6c466a91606fdb1002f5 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Thu, 7 Nov 2024 16:37:09 +0000 Subject: [PATCH 082/118] New Crowdin translations by GitHub Action --- app/src/main/res/values-pt/strings.xml | 1010 +++++++++++++++++ .../android/pt_BR/full_description.txt | 52 +- .../android/pt_BR/short_description.txt | 2 +- 3 files changed, 1037 insertions(+), 27 deletions(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 8b2b1737b0..606dad7480 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,66 +1,1076 @@ + Liberte suas chaves! + Sem ação definida + Este aplicativo requer o serviço de acessibilidade. Isso detecta suas ações fora do aplicativo. O mapeamento depende disso. Também obrigatório para criar gatilhos. + %d selecionado + Habilitar + Abrir + ¯\\_(ツ)_/¯\n\nNada encontrado! + Grave um gatilho! + Adicionar ação! + ¯\\_(ツ)_/¯\n\nCriar mapeamento! + ¯\\_(ツ)_/¯\n\nNenhuma ação para este atalho! + Nenhum mapeamento criado! + Tudo em ordem! + Aplicativo funcional, pode ser necessário ajuste adicional dependendo do uso. + O seu dispositivo não é compatível com algumas ações. + Ações não suportadas + Requer root + Pressione… + Sem ação + Sem gatilho + Código desconhecido: %s + Dispositivo desconhecido + Ativo + Desligado + Padrão do sistema + Este dispositivo + Qualquer dispositivo + Nome deste dispositivo desconhecido + Padrão + Ativar serviço de acessibilidade + Reiniciar o serviço de acessibilidade + Compartilhar + Parar repetição… + Gatilho liberado + Gatilho repetido + Limite atingido + Desliza novamente + Gatilho liberado + Gatilho repetido + Mostrar apps ocultos + Modificador + Alternar + IMPORTANTE!!! Essas coordenadas estão corretas apenas quando sua tela está na mesma orientação que a captura de tela! Esta ação cancelará qualquer toque ou gesto que você esteja fazendo na tela.\n\nSe precisar de ajuda para encontrar as coordenadas de um ponto na sua tela, tire uma captura de tela e toque na captura onde você deseja que esta ação pressione. + Nota: Ao usar \"pinçar para dentro\", X e Y são as coordenadas FINAIS; ao usar \"pinçar para fora\", X e Y são as coordenadas INICIAIS. + Dispositivo desconhecido! + Ações para correção! + Eventos para correção! + Realizar ações + Pressionado até o gatilho… + Sem dispositivo + ¯\\_(ツ)_/¯\n\nSem extras! + Criar novo mapa + Configuração da ação do evento de tecla concluída + Coordenada selecionada + Key Mapper registros + Enviar para + Novidades + Clique na tecla do dispositivo que você deseja que seja inserida. + \n\nIMPORTANTE! + Inserir esta tecla como uma ação funcionará apenas se você estiver usando um teclado compatível com o aplicativo. + IMPORTANTE! + Inserir este código de tecla como uma ação funcionará apenas se você estiver usando um teclado compatível com o aplicativo. + O arquivo de som será copiado para a pasta de dados privados do aplicativo, o que significa que suas ações ainda funcionarão mesmo se o arquivo for movido ou excluído. Ele também será salvo com seus mapas de teclas na pasta compactada. + Você pode excluir arquivos de som salvos nas configurações. + Digite algum texto que você deseja que seja inserido ao realizar esta ação. + Digite o número de telefone. + Digite um URL que você deseja abrir. O http://, https:// ou www. Não são necessários. + Não foi possível encontrar dispositivos pareados. O bluetooth está ligado? + A opção \"Permitir que outros aplicativos acionem este mapa de teclas\" será ativada para o mapa de teclas que você selecionar, se ainda não estiver ativada. Se você desativar essa opção mais tarde, quaisquer atalhos ou intenções para acionar este mapa de teclas não funcionarão. + Ativado + Desativado + Redefinir + Visto que você tenha ativado o administrador do dispositivo, deve DESATIVÁ-LO se quiser desinstalar o aplicativo. + Adicione uma restrição! + Aguarde %sms + Iniciar atividade: %s + Iniciar serviço: %s + Enviar transmissão: %s + UUID do mapa de teclas + Usar shell (ROOT necessário) + Key Mapper precisa de permissão para modificar o modo Não Perturbar se você quer que os botões funcionem corretamente no modo Não Perturbar! + Este acionador não funcionará como esperado no modo Não Perturbe! + A opção para acionar quando a tela está desligada precisa de permissão de super usuário para funcionar! + A opção para acionar quando a tela está desligada não funcionará! + Este acionador não funcionará enquanto o telefone estiver tocando ou durante uma chamada! + O sistema operacional não permite que os serviços de acessibilidade detectem os pressionamentos do botão de volume enquanto seu telefone está tocando ou durante uma chamada, mas permite que os serviços de método de entrada os detectem. Portanto, você deve usar um dos teclados do aplicativo se quiser que este acionador funcione. + Muitos dedos para realizar o gesto devido às limitações do Android. + A duração do gesto é muito alta devido às limitações do Android. + Seus mapeamentos pararão de funcionar aleatoriamente! + Seus mapeamentos estão pausados! + Resumir + O serviço de acessibilidade precisa estar ativado para que seus mapeamentos funcionem! + Seu telefone finalizou o aplicativo quando estava em segundo plano ou ele travou! + O serviço de acessibilidade está ativado! Seus mapeamentos devem funcionar. + O registro extra está ativado! Desative isso se você não estiver tentando corrigir um problema. + Desligar + Sobre + Abrir %s + Pressiona a tecla \'%s\' + Digita \'%s\' + Inserir %s%s + Inserir %s através do terminal + Inserir %s%s de %s + Abrir %s + Tocar nas coordenadas %d, %d + Tocar nas coordenadas %d, %d (%s) + Deslizar com %d dedo(s) das coordenadas %d/%d para %d/%d em %dms + Deslizar com %d dedo(s) das coordenadas %d/%d para %d/%d em %dms (%s) + %s com %d dedo(s) nas coordenadas %d/%d com uma distância de pinçamento de %dpx em %dms + %s com %d dedo(s) nas coordenadas %d/%d com uma distância de pinçamento de %dpx em %dms (%s) + Ligar para %s + Toca o som: %s + Opções + Ações + Gatilho + Restrições + Deslizar para cima + Deslizar para baixo + Deslizar para a esquerda + Deslizar para a direita + Tipo de clique + Extras + Iniciar X + Iniciar Y + Finalizar X + Finalizar Y + Distância de pinçamento (px) + Tipo de pinçamento + Pinçar para dentro + Pinçar para fora + Código da chave + Do dispositivo + Nome do atalho + Descrição coordenada (opcional) + Entrada de texto + Abrir URL + Discar número + Ação + Categorias + Dados + Pacote + Classe + Nome + Valor (%s) + Descrição para Mapeador de Teclas (obrigatório) + Bandeiras + Descrição do Arquivo de Áudio + Mostrar SSID da rede WiFi + Ao mesmo tempo + Na sequencia + E + OU + Pressionar por curto período + Pressione por mais tempo + Pressione duas vezes + Confirmado + Negado + Atividade + Serviço + Receptor de Transmissão + Gatilho e ações + Eventos e mais + Opções + Restrições + Ações + Gatilho + Digital + + @string/tab_keyevents + @string/tab_fingerprint + + Escolha %s + “Backup” concluído! + Backup falhou! + Restauração bem sucedida! + A restauração falhou! + Backup automático concluído! + Falha ao realizar o backup automático! + Captura feita + Erro de IO ¯\\_(ツ)_/¯ + A resolução da captura de tela não corresponde à resolução deste dispositivo! + Copiou UUID para clipboard + Gatilho acionado + Copiou registro + Root ativado! + Falha selecionando som. + Sem sons salvos! + Key Mapper usou Shizuku para permitir WRITE_SECURE_SETTINGS + Key Mapper usou root para permitir WRITE_SECURE_SETTINGS + Expiração do gatilho (ms) + Atraso do toque longo (ms) + Expiração toque duplo (ms) + Atraso repetição (ms) + Limite repetição + Repetir a cada... (ms) + Duração da vibração (ms) + Quantas vezes? + Quantas repetições? + Atraso antes da próxima ação (ms) + Duração de pressão (ms) + Duração do deslize (ms) + Número de dedos + Coordenadas para definir com captura de tela + Iniciar + Finalizar + Duração do pinça (ms) + Número de dedos + %s está em primeiro plano + %s não está visível + %s está reproduzindo mídia + %s não está reproduzindo mídia + %s está conectado + %s está desconectado + A tela está ligada + A tela está desligada + A lanterna de %s está desligada + A lanterna de %s está ligada + e + ou + Aplicativo + Bluetooth + Tela + Orientação + Aplicativo em primeiro plano + Aplicativo não está em primeiro plano + Dispositivo Bluetooth conectado + Dispositivo Bluetooth desconectado + A tela está ligada + A tela está desligada + Retrato (0°) + Paisagem (90°) + Retrato (180°) + Paisagem (270°) + Retrato (qualquer orientação) + Paisagem (qualquer orientação) + Aplicativo reproduzindo mídia + Aplicativo não reproduzindo mídia + Mídia em reprodução + Nenhuma mídia em reprodução + Lanterna ligada + Lanterna desligada + Wi-Fi ativado + Wi-Fi desativado + Conectado à rede Wi-Fi + Desconectado da rede Wi-Fi + Você precisará digitar o SSID manualmente, pois aplicativos não têm permissão para consultar a lista de redes Wi-Fi conhecidas no Android 10 e versões posteriores. + + Deixe em branco para corresponder a qualquer rede Wi-Fi. + Qualquer + Conectado a rede Wi-Fi: %s + Desconectado da rede Wi-Fi: %s + Conectado à qualquer rede Wi-Fi + Desconectado, sem rede Wi-Fi + Método de entrada selecionado + %s foi selecionado + Método de entrada não selecionado + %s Não escolhido + O Dispositivo esta bloqueado + Este dispositivo está desbloqueado + Chamada telefônica + Fora de chamada + Tocando + Carregando + Descarregando + Retrato (0°) + Paisagem (90°) + Retrato (180°) + Paisagem (270°) + Pressionar por curto período + Pressione por mais tempo + Pressione duas vezes + Selecionar ação + Desativa bloqueio de app + Veja dontkillmyapp.com + \n\nApós ler vá para o próximo slide. + Guia do usuário + Reiniciar o serviço de acessibilidade + Desative e ative o serviço de acessibilidade. + Reiniciar + Reportar um erro + Escolha o local tocando em \"criar relatório\". A seguir veja como enviar. + Criar relatório + Compartilhar relatório de erros + Você pode enviar via Discord ou GitHub. Anexe o erro na mensagem! + Discord + GitHub + Configurações + Concluído + Selecionar tudo + Sobre + Pesquisa + Guia rápido de início + Ajuda + Habilitar + Desativar + Desativar tudo + Habilitar tudo + Reportar erro + Mostrar método de entrada + Adicionar dados + Salvar + Duplicar + Backup + Restaurar + Salvar tudo + Salvar geral + Toque e pause + Toque para retomar + Redefinir + Salvar + Alternar mensagens curtas + Copiar + Limpar + Adicionar ação + Gravação de tecla + Concluído + Salvar + Corrigir + %d… + Adicionar restrição + Selecionar código de tecla + Sim! + Selecionar ação + Adicionar extra + Criar atalho de acesso rápido + Crie o atalho manualmente + Guia de Intenção + Ajuda + Selecionar captura de tela (opcional) + Escolher atividade + Definir bandeiras + Copiar + Sem limite + Escolher arquivo de som + Editar ação + Substituir ação + Sim? + Criar título de atalho + Permissão de super usuário necessária! + Mais + Escolha o fluxo + Escolha o flash + Não consigo encontrar a página de configurações de acessibilidade + Não é possível gravar o gatilho? + Descartar suas alterações + Tem certeza de que deseja descartar suas alterações? + Se você sabe que seu telefone não está enraizado ou não sabe o que é root, não poderá usar recursos que funcionam apenas em dispositivos enraizados. Ao tocar em ‘OK’, você será levado às configurações. + Nas configurações, role até a parte inferior e toque em \'Key Mapper tem permissão de root\' para que você possa usar recursos/ações de root. + Baixando… + Pressionar por muito tempo só funciona para botões físicos de volume e navegação. Se você habilitar para outras teclas, as teclas não funcionarão quando não forem pressionadas por muito tempo. + Conceder permissão WRITE_SECURE_SETTINGS + Um PC/Mac é necessário para conceder esta permissão. Leia o guia online. + Seu dispositivo não parece ter uma página de configurações de serviços de acessibilidade. Toque em \"guia\" para ler o guia online que explica como consertar isso. + "Não é possível pressionar várias teclas duas vezes simultaneamente." + As teclas precisam ser listadas de cima para baixo na ordem em que serão pressionadas. + Um gatilho de \"sequência\" tem um tempo limite diferente dos gatilhos paralelos. Isso significa que após pressionar a primeira tecla, você terá um tempo definido para inserir o restante das teclas no gatilho. Todas as teclas que. + O Android não permite que aplicativos vejam dispositivos Bluetooth não emparelhados. Eles só detectam conexão/desconexão. Se o dispositivo Bluetooth já estiver emparelhado ao iniciar o serviço de acessibilidade, reconecte-o para o aplicativo reconhecer. + Alterar local ou desativar o backup automático? + As restrições de tela ligada/desligada só funcionarão se você tiver ativado a opção de mapa de teclas \"detectar gatilho quando a tela estiver desligada\". Esta opção só será exibida para algumas teclas (por exemplo, botões de volume) e se você tiver root + Se você tiver qualquer outro tipo de bloqueio de tela escolhido, como PIN ou padrão, então você não precisa se preocupar. Mas se você tiver um bloqueio de tela por senha, você NÃO conseguirá desbloquear seu telefone se usar o Método de Entrada Básico do Key Mapper, porque ele não possui uma interface gráfica. Você pode conceder ao Key Mapper a permissão WRITE_SECURE_SETTINGS para que ele possa mostrar uma notificação para alternar entre o teclado e o bloqueio de tela. Há um guia sobre como fazer isso se você tocar no ponto de interrogação na parte inferior da tela. + Selecione o método de entrada para ações que exigem um. Você pode alterar isso mais tarde tocando em \"Selecionar teclado para ações\" no menu inferior da tela inicial. + Você precisa escolher o layout de teclado \"Caps Lock para câmera\" para o seu teclado; caso contrário, a tecla Caps Lock ainda bloqueará as letras maiúsculas. Você pode encontrar essa configuração em configurações do dispositivo -> Idiomas e entrada -> Teclado físico -> Toque no seu teclado -> Configurar layouts de teclado. Isso irá remapear a tecla Caps Lock para KEYCODE_CAMERA, permitindo que o Key Mapper a remapeie corretamente.\n\nDepois de fazer isso, você deve remover a tecla de ativação Caps Lock e gravar a tecla Caps Lock novamente. Deve aparecer \"Câmera\" em vez de \"Caps Lock\" se você seguiu os passos corretamente. + Reinicie seu dispositivo se o botão \"Gravar gatilho\" estiver contando regressivamente e os botões que você está pressionando não estiverem aparecendo. Se seus botões ainda não aparecerem após reiniciar, então o Key Mapper não suporta seus botões. Não há uma solução para isso. + Nenhum dispositivo externo conectado. + Instale o Teclado GUI do Key Mapper. + Isto é altamente recomendado! Este é um teclado adequado que você pode usar com o Key Mapper. O que vem embutido no Key Mapper (o Método de Entrada Básico) não possui teclado na tela. Escolha de onde deseja instalá-lo. + Instale o Teclado Leanback do Key Mapper. + Isto é altamente recomendado! Este é um teclado adequado para Android TV que você pode usar com o Key Mapper. O que vem embutido no Key Mapper (o Método de Entrada Básico) não possui teclado na tela. Escolha de onde deseja instalá-lo. + Instale o Teclado GUI do Key Mapper. + Escolha de onde deseja baixá-lo. + Instale o Teclado Leanback do Key Mapper. + Escolha de onde deseja baixá-lo. + Esta ação precisa de algumas configurações extras. + Existem 3 maneiras de configurar seu dispositivo para usar esta ação. Aqui estão as vantagens e desvantagens de cada uma. + + \n\n1. Baixe o Shizuku (recomendado). Você não precisa usar um teclado na tela diferente do que já está usando, mas isso exigirá um minuto de configuração toda vez que você reiniciar seu dispositivo. + + \n\n2. Baixe o Teclado GUI do Key Mapper. Este é um teclado na tela que você pode usar com o Key Mapper, mas não poderá usar o teclado que está atualmente em uso, como o Gboard. + + \n\n3. Não faça nada e use o teclado embutido do Key Mapper. Isso não é recomendado, pois você não terá nenhum teclado na tela ao usar o Key Mapper! Não há vantagens. + Esta ação precisa de algumas configurações extras. + Existem 3 maneiras de configurar seu dispositivo para usar esta ação. Aqui estão as vantagens e desvantagens de cada uma. + + \n\n1. Baixe o Shizuku (recomendado). Você não precisa usar um teclado na tela diferente do que já está usando, mas isso exigirá um minuto de configuração toda vez que você reiniciar seu dispositivo. + + \n\n2. Baixe o Teclado Leanback do Key Mapper. Este é um teclado na tela otimizado para Android TV que você pode usar com o Key Mapper, mas não poderá usar o teclado que está atualmente em uso, como o Gboard. + + \n\n3. Não faça nada e use o teclado embutido do Key Mapper. Isso não é recomendado, pois você não terá nenhum teclado na tela ao usar o Key Mapper! Não há vantagens. + Desativar otimização de bateria + Você DEVE ler tudo isso senão você ficará frustrado no futuro!\n\nTocar em \"corrigir parcialmente\" pode impedir que o Android pare o aplicativo enquanto ele está em segundo plano.\n\nISSO NÃO É SUFICIENTE. A interface do seu OEM, como MIUI ou Samsung Experience, pode ter outros recursos de encerramento de aplicativos, então você DEVE desativá-los para o Key Mapper também, seguindo o guia online em dontkillmyapp.com. + Enviar feedback + Leia o guia sobre como relatar problemas no site. + Ative o serviço de acessibilidade para que você possa gravar um gatilho. + Reinicie o serviço de acessibilidade desligando-o e ligando-o novamente para que você possa gravar um gatilho. + Ative o serviço de acessibilidade para que você possa testar a ação. + Reinicie o serviço de acessibilidade desligando-o e ligando-o novamente para que você possa testar a ação. + Reinicie o serviço de acessibilidade desligando-o e ligando-o novamente. + Usar este gatilho pode causar uma tela preta quando você desbloqueia seu dispositivo após usar a configuração de fixação de tela nas configurações do seu dispositivo. Isso pode ser corrigido com uma reinicialização. Isso não acontece em todos os dispositivos, então fique atento e desative a configuração se isso ocorrer! + Redefinir mapas de gestos de impressão digital + Você tem certeza de que deseja redefinir seus mapas de gestos de impressão digital? + O Key Mapper foi fechado ou travou + É muito provável que seu telefone tenha encerrado o Key Mapper enquanto ele tentava rodar em segundo plano. Isso é não é culpa do desenvolvedor e não há nada que eles possam fazer para corrigir isso, então, por favor, não deixe uma avaliação negativa 😃. + + \n\nVocê já seguiu o guia em dontkillmyapp.com antes para impedir que seu telefone mate o Key Mapper? + Sim + Não + Erro ao gerar relatório de erros + Solicitar permissão Shizuku + Como você está usando o Shizuku, é altamente recomendável conceder essa permissão, pois alguns recursos do Key Mapper podem ser feitos sem que você precise configurar nada (por exemplo, inserir códigos de chave sem nós). + Corrigir erro + É necessária permissão + O Key Mapper precisa da permissão \"dispositivos próximos\" para poder obter a lista de dispositivos Bluetooth pareados. + Você não tem nenhum aplicativo de arquivos instalado que permita criar um arquivo para o Key Mapper. Por favor, instale um gerenciador de arquivos. + Você não tem nenhum aplicativo de arquivos instalado que permita escolher um arquivo para o Key Mapper. Por favor, instale um gerenciador de arquivos. + O serviço de acessibilidade deve estar habilitado + @string/accessibility_service_explanation + Conceder acesso Não Perturbe + Você será levado à página de configurações do seu dispositivo para gerenciar quais aplicativos podem modificar o estado de Não Perturbe. Isso não está presente em alguns dispositivos, então toque em não mostrar novamente se você não vir o Key Mapper na lista. + Sim + Confirmar + Concluído + Assinar + Guia + Guia + Guia + Habilitar recursos root + Conceder + Participar + Alterar + Corrigir parcialmente + Ok + Ligar + Reiniciar + Nunca mais mostrar + Abrir guia online + Desligar + Fique fora + Não + Cancelar + Não mostrar novamente + Guia on-line + Configurações + Documentação + O que mudou + Reportar erro + Reiniciar + Ir para o guia + Shizuku + Teclado GUI do Mapeador de Teclas + Teclado Leanback do Mapeador de Teclas + Não faça nada + Corrigir + Cancelar + Corrigir + Seletor de teclado + Pausar/retomar mapeamentos + Aviso de teclado oculto + Teclado Toggle Key Mapper + Novas funcionalidades + Toque para trocar teclado. + Seletor de teclado + Em execução + Toque para abrir o Key Mapper. + Pausar + Pausado + Toque para abrir o Key Mapper. + Retomar + Descartar + Reiniciar + O serviço de acessibilidade está desativado + Iniciar o serviço de acessibilidade. + O serviço de acessibilidade precisa ser reiniciado! + O serviço de acessibilidade travou! Seu telefone pode estar matando-o agressivamente! Toque para reiniciar o serviço de acessibilidade. + Parar serviço + O teclado está escondido! + Toque em \'mostrar teclado\' para começar a mostrar o teclado novamente. + Teclado Toggle Key Mapper + Toque em \"alternar\" para alternar entre o teclado do Key Mapper. + Alternar + Remapeie os gestos de impressão digital com o Key Mapper! + Seu dispositivo suporta remapeamento de swipes no sensor de impressão digital. Toque para começar a remapeamento! + Você precisa configurar algumas coisas novamente! + Parece que você estava usando a configuração para mudar automaticamente o teclado ou mostrar o seletor de métodos de entrada quando um dispositivo Bluetooth se conecta ou desconecta. O Key Mapper agora permite que você use qualquer dispositivo de entrada e não apenas dispositivos Bluetooth. Não há uma maneira de migrar as configurações antigas de forma que a nova funcionalidade funcione, então você terá que escolher os dispositivos novamente nas configurações do Key Mapper. Toque para abrir o Key Mapper. + Atraso de pressão longa padrão (ms) + Por quanto tempo um botão deve ser pressionado para ser detectado como um pressionamento longo. O padrão é 500 ms. Pode ser substituído nas opções de um mapa de teclas. + Duração padrão do pressionamento duplo (ms) + Quão rápido um botão precisa ser pressionado duas vezes para ser detectado como um pressionamento duplo. O padrão é 300 ms. Pode ser substituído nas opções de um mapa de teclas. + Por quanto tempo vibrar se a vibração estiver habilitada para um mapa de teclas. O padrão é 200 ms. Pode ser substituído nas opções de um mapa de teclas. + Vibração padrão (ms) + Quanto tempo o gatilho precisa ser mantido pressionado para que a ação comece a se repetir. O padrão é 400 ms. Pode ser substituído nas opções de um mapa de teclas. + Atraso repetição (ms) + O atraso entre cada vez que uma ação é repetida. O padrão é 50 ms. Pode ser substituído nas opções de um mapa de teclas. + Atraso repetição (ms) + O tempo permitido para completar um gatilho de sequência. O padrão é 1000 ms. Pode ser substituído nas opções de um mapa de teclas. + Expiração do gatilho (ms) + Redefinir + Força todos os mapas de teclas a vibrar. + Força vibrar + Notificação do Teclado + Exiba uma notificação persistente para permitir que você escolha um teclado. + Notificação de pausa/retomada de mapeamentos + Exiba uma notificação persistente que inicia/pausa seus mapeamentos. + Fazer backup automático de mapeamentos para um local especificado + Nenhum local escolhido. + Escolha dispositivos + Mostrar seletor de teclado automaticamente + Quando um dispositivo que você escolheu se conecta ou desconecta, o seletor de teclado será exibido automaticamente. Escolha os dispositivos abaixo. + Alterar automaticamente o teclado na tela quando um dispositivo (por exemplo, um teclado) conecta/desconecta + O último teclado Key Mapper usado será automaticamente selecionado quando um dispositivo escolhido for conectado. Seu teclado normal será automaticamente selecionado quando o dispositivo for desconectado. + Alterar automaticamente o teclado na tela quando você começar a inserir texto + O último teclado não Key Mapper usado será selecionado automaticamente quando você tentar abrir o teclado. Seu teclado Key Mapper será selecionado automaticamente quando você parar de usá-lo. + Mostrar uma mensagem na tela ao alterar o teclado automaticamente + O Key Mapper tem permissão de root + Ative isso se você quiser usar recursos/ações que funcionam apenas em dispositivos com root. O Key Mapper deve ter permissão de root do seu aplicativo de gerenciamento de acesso root (por exemplo, Magisk, SuperSU) para que esses recursos funcionem. + Ative isso apenas se você souber que seu dispositivo está rooteado e que você concedeu permissão de root ao Key Mapper. + Tema escuro + Configurações de notificação + Alterne entre o teclado do Key Mapper e o teclado padrão ao tocar na notificação. + Alternar notificação de teclado do Key Mapper + Alterar automaticamente o teclado ao alternar os mapas de teclas + Selecione automaticamente o teclado do Key Mapper ao retomar seus mapas de teclas e selecione seu teclado padrão ao pausá-los. + Ocultar alertas da tela inicial + Oculte os alertas na parte superior da tela inicial. + Mostrar os primeiros 5 caracteres do ID do dispositivo para gatilhos específicos do dispositivo + Isso é útil para diferenciar entre dispositivos que têm o mesmo nome. + Corrigir teclados que estão configurados para inglês dos EUA + Isso corrige teclados que não têm o layout de teclado correto quando um serviço de acessibilidade está habilitado. Toque para ler mais e configurar. + Corrigir teclados que estão configurados para inglês dos EUA + Há um bug no Android 11 que ao ativar um serviço de acessibilidade faz o Android pensar que todos os dispositivos externos são o mesmo dispositivo virtual interno. Como ele não consegue identificar esses dispositivos corretamente, + Escolha os dispositivos + Instalar o teclado GUI do Key Mapper (opcional) + Instale o teclado Key Mapper Leanback (opcional) + Habilitar o teclado GUI do Key Mapper ou o método de entrada básico do Key Mapper + Habilitar o teclado Leanback do Key Mapper ou o método de entrada básico do Key Mapper + Use o teclado que você acabou de habilitar + (Recomendado) Leia o guia do usuário para esta configuração. + Ativar o registro de depuração extra + Visualizar e compartilhar + Reportar problema + Apagar som # + Exclua arquivos de som que podem ser usados para a ação Som. + Conceder permissão + Permissão concedida + 1. Shizuku não está instalado! Toque para baixar o aplicativo Shizuku. + 1. Shizuku está instalado. + 2. Shizuku não foi iniciado! Toque para abrir o aplicativo Shizuku e então leia as instruções que explicam como iniciá-lo. + 2. Shizuku iniciado. + O Key Mapper não tem permissão para usar Shizuku. Toque para conceder essa permissão. + O Key Mapper usará Shizuku automaticamente. Toque para ler quais recursos do Key Mapper usam Shizuku. + Opções de mapeamento padrão + Altere as opções padrão para seus mapeamentos. + Mostrar automaticamente o seletor de teclado + Toque para ver as configurações que permitem mostrar automaticamente o seletor de teclado. + Notificações + Padrões + Configurações de root + Essas opções só funcionarão em dispositivos root! Se você não sabe o que é root ou se seu dispositivo tem root, não deixe uma avaliação ruim se elas não funcionarem. :) + Requer permissão WRITE_SECURE_SETTINGS + Essas opções só são habilitadas se o Key Mapper tiver a permissão WRITE_SECURE_SETTINGS. Clique no botão abaixo para saber como conceder a permissão. + Suporte Shizuku + Shizuku é um aplicativo que permite que o Key Mapper faça coisas que somente aplicativos de sistema podem fazer. Você não precisa usar o teclado do Key Mapper, por exemplo. Toque para aprender como configurar isso. + Siga estes passos para configurar o Shizuku. + Alterar o teclado automaticamente + Essas são configurações muito úteis e é recomendável que você as confira! + Registros + Isso pode adicionar latência aos seus mapas de teclas, portanto, ative-o somente se estiver tentando depurar o aplicativo ou se o desenvolvedor tiver solicitado. + O que mudou + Licença + Política de privacidade + Créditos + Código-fonte + Perfil do desenvolvedor no GitHub + Avalie e comente + Tópico XDA + Versão + Traduzir + Servidor Discord + Canal do YouTube (Tutoriais) + Mostrar caixa de diálogo de volume + Pressione por mais tempo + Vibrar + Mostrar uma mensagem na tela + Vibrar quando as teclas são pressionadas pela primeira vez e novamente quando pressionadas por muito tempo. + Detectar gatilho quando a tela estiver desligada + Repita + %dx + depois de %dms + todo %dms + até ser deslizado novamente + até ser pressionado novamente + até ser liberado + Repita + Repita até liberar + Repita até ser pressionado novamente + Segure firme + Mantenha pressionado até ser pressionado novamente + Não remapeie + Mantenha pressionado até deslizar novamente + Permitir que outros aplicativos acionem este mapa de teclas + O serviço de acessibilidade está ativado :) + Tudo bem. O Key Mapper agora pode detectar seus pressionamentos de botão. + Reiniciar o serviço de acessibilidade + Os serviços de acessibilidade estão habilitados, mas foram encerrados pelo seu telefone ou travaram. Reinicie-o. + Reiniciar + Ativar serviço de acessibilidade + @string/accessibility_service_explanation + O serviço de acessibilidade está desativado + Você só pode gravar um gatilho se o serviço de acessibilidade estiver habilitado. + Diretamente dos {developers} + Não há garantia de que cada ação funcionará no seu dispositivo e que cada botão pode ser detectado. Isso ocorre porque existem muitas versões do Android, e os fabricantes podem quebrar recursos acidentalmente ou intencionalmente. Se algo não funcionar, por favor, notifique o desenvolvedor e evite dar uma avaliação negativa ao aplicativo, pois o problema muitas vezes está fora do controle do desenvolvedor. =) + O Key Mapper pode parar de funcionar aleatoriamente! + CRÍTICO!!! Toque em \"desligar\" para, com sorte, impedir que o Android pare o aplicativo enquanto ele estiver em segundo plano. A skin do seu OEM, como MIUI ou Samsung Experience, pode ter outros recursos de \"economia de bateria\" + A otimização de bateria do Android Stock está desativada. Isso não é bom o suficiente para a maioria dos dispositivos, então vá para dontkillmyapp.com para tutoriais que mostram como desativar ainda mais recursos que matam aplicativos no seu dispositivo. + Desligar + Acesse dontkillmyapp.com + Remapeando botões de volume? + Você pode remapear os botões de volume + O Key Mapper precisa do acesso Não Perturbe se você quiser que ações que alterem o volume e botões de volume remapeados funcionem. + Tudo certo + Contribuindo + "Este aplicativo é de código aberto! Você pode começar a contribuir indo para o repositório sds100/KeyMapper no GitHub e entrando no servidor Discord. Mesmo que você não saiba programar, você pode contribuir ajudando outros" + Toque nos 3 pontos para alterar o comportamento de repetição e mais. Você pode testar uma ação e corrigir erros de ação tocando nela. + Remapeando gestos do leitor de impressão digital + Você pode remapear os gestos do leitor de impressão digital! :) + Você não pode remapear os gestos do leitor de impressão digital! + Você precisará habilitar o serviço de acessibilidade para que o Key Mapper possa verificar se o seu dispositivo consegue detectar gestos de impressão digital. + Seu dispositivo pode detectar gestos de impressão digital! Há uma aba no topo da tela inicial para remapear gestos de impressão digital. + Seu dispositivo não permite que aplicativos de terceiros detectem gestos de impressão digital! Não há nada que o desenvolvedor possa fazer sobre isso. Alguns dispositivos têm a configuração para deslizar para baixo no leitor de impressão digital para abrir a bandeja de notificações e não permitem que aplicativos de terceiros detectem gestos de impressão digital. + Habilitar + Você precisa configurar algumas configurações novamente + Parece que você estava usando a configuração para mudar automaticamente o teclado ou mostrar o seletor de métodos de entrada quando um dispositivo Bluetooth se conecta ou desconecta. O Key Mapper agora permite que você use qualquer dispositivo de entrada e não apenas dispositivos Bluetooth. Não há uma maneira de migrar as configurações antigas de forma que a nova funcionalidade funcione, então você terá que escolher os dispositivos novamente. + Conceda permissão a Shizuku + Parece que você tem o Shizuku instalado. É recomendado conceder ao Key Mapper permissão para usar o Shizuku, para que o Key Mapper possa fazer mais coisas sem a intervenção do usuário. Por exemplo, registrar pressionamentos de botões sem que você precise usar o \'teclado do Key Mapper\'. Toque em \'mais informações\' para ler todos os benefícios. Toque em \'conceder\' para dar a permissão. + Shizuku permissão concedida! + Você concedeu permissão ao Key Mapper Shizuku com sucesso. + Mais informação + Conceder + Shizuku não foi iniciado + O Shizuku deve ser iniciado antes que você conceda permissão ao Key Mapper para usá-lo. Toque em \'Launch Shizuku\' para abrir o aplicativo Shizuku para que você possa iniciá-lo. + Iniciar Shizuku + Conceder permissão de notificação + Alguns recursos do Key Mapper exigem permissão para postar notificações, por exemplo, há uma notificação para pausar/retomar seus mapas de teclas. Toque em \"conceder\" para dar permissão. + Notificações podem ser exibidas! + Você concedeu com sucesso permissão ao Key Mapper para mostrar notificações. + Conceder + Acessibilidade + Alarme + DTMF + Música + Notificações + Anel de Transmissão + Sistema + Chamada de voz + Modo de toque normal + Vibrar + Silencioso + Frente + Voltar + Alarmes + Prioridade + Nada + Pausar mapeamentos + Retomar mapeamentos + Serviço desativado + O serviço de acessibilidade do Key Mapper está desabilitado + O serviço de teclado Key Mapper está desabilitado + Teclado Toggle Key Mapper + Ctrl + Ctrl+Esquerda + Ctrl+Direita + Alt + Alt + Esquerda + Alt+Direita + Shift + Shift+Seta para a esquerda + ~Deslocar para a direita + Meta + Meta esquerda + Meta direita + Sym + Func + Caps Lock + Num Lock + Scroll Lock + Você deve digitar uma chave! + A regra deve ter pelo menos um gatilho + Você precisa escolher uma ação! + O atalho deve ter um título! + Você deve estar usando um dos teclados do Key Mapper para que esta ação funcione! + Não é possível encontrar o atalho. O aplicativo está instalado ou habilitado? + O aplicativo com nome de pacote %s não está instalado! + A aplicação não está instalada! + Aplicativo Desativado! + Aplicativo %s desativado! + Você precisa conceder permissão ao Key Mapper para modificar as configurações do sistema. + Isso requer permissão de root! + Esta ação requer permissão da câmera! + Requer Android %s ou posterior + Requer Android %s ou posterior + Seu dispositivo não possui uma câmera. + Este dispositivo não oferece suporte ao NFC. + Seu dispositivo não possui um Sensor de Impressão Digital. + Este dispositivo não suporta wifi. + Seu dispositivo não suporta Bluetooth. + Seu dispositivo não oferece suporte à aplicação de políticas de dispositivo. + Seu dispositivo não possui uma câmera. + Seu dispositivo não possui nenhum recurso de telefonia. + Classe \"%s\" não encontrada! + Não é possível encontrar a página de configurações do teclado! + O Key Mapper precisa ser um administrador de dispositivo! + No modo Não Perturbe! + O Key Mapper não tem permissão para usar esse atalho + O aplicativo precisa de permissão para alterar o estado Não Perturbe! + Esta ação precisa de permissão para ler o estado do telefone! + Não é possível encontrar a página de permissão WRITE_SETTINGS! + Erro ao abrir este atalho de aplicativo + Não há nenhum aplicativo instalado que possa enviar e-mails! + Permissão para alterar o modo Não Perturbe concedida! + Não é possível encontrar as configurações de permissão de acesso Não Perturbe! + O Key Mapper precisa da permissão WRITE_SECURE_SETTINGS. + Nenhum aplicativo pode ser encontrado para abrir esse URL + Falha ao executar o comando \"getevent\". Tem certeza de que está rooteado? + Não há nenhum aplicativo que possa iniciar esta chamada telefônica + A câmera está em uso! + Câmera desconectada! + Câmera desativada! + Erro de câmera! + Número máximo de câmeras em uso! + Não é possível acessar a câmera! + Sem flash frontal + Sem flash traseiro + O serviço de acessibilidade deve estar habilitado para que este aplicativo funcione! + O serviço de acessibilidade precisa ser habilitado! + O serviço de acessibilidade está ativado! + O serviço de acessibilidade precisa ser habilitado! + O serviço de acessibilidade precisa ser reiniciado! + Seu inicializador não suporta atalhos. + Alguns recursos precisam da permissão WRITE_SECURE_SETTINGS. + Permissão WRITE_SECURE_SETTINGS concedida. + O método de entrada selecionado precisa ser habilitado para “Evento de tecla”, “Tecla”, “Texto” e algumas outras ações para funcionar. + Um teclado Key Mapper precisa ser habilitado! + Um teclado Key Mapper está habilitado! + Um teclado Key Mapper deve estar habilitado e selecionado para que algumas de suas ações funcionem! + Não é possível encontrar o método de entrada %s + O seletor de método de entrada não pode ser exibido! + Falha ao encontrar o elemento de acessibilidade! + Falha ao executar a ação global %s! + Seus mapas de teclas não funcionam! Algumas coisas precisam ser consertadas! + Você não pode ir para o final do texto neste campo! + Configurações de otimização de bateria não encontradas! Se existir, abra manualmente. + Extra (%s) não encontrado! + Você não pode ter restrições duplicadas! + Esse gesto já tem essa restrição! + Não pode estar vazio! + Isso não é suportado. :( + Dispositivo não encontrado! + Falha ao selecionar o arquivo + Arquivo JSON vazio! + Acesso ao arquivo negado! %s + Erro de I/O desconhecido! + Cancelado! + Falha ao fazer backup do arquivo. Ele foi excluído? + Número inválido! + Deve ser pelo menos %s! + Deve ser no máximo %s! + A otimização da bateria está ativada! Desative isso porque isso pode fazer com que o Key Mapper pare de funcionar aleatoriamente. + A otimização da bateria está desativada! + Não é possível encontrar as configurações de acesso à notificação! + Permissão de acesso à notificação negada! + Inválido! + Permissão negada para iniciar chamadas telefônicas! + Você precisará atualizar o Key Mapper para a versão mais recente para usar este backup. + Arquivo JSON corrompido! + Não há assistente de voz instalado! + Permissões insuficientes + Você só tem teclados Key Mapper instalados! + Não há aplicativos reproduzindo mídia! + Arquivo de origem não encontrado! %s + Arquivo de destino não encontrado! %s + Falha ao inserir gesto! + Falha ao modificar a configuração do sistema %s! + Você precisa habilitar %s! + Falha ao mudar o IME! + Seu dispositivo não tem aplicativo de câmera! + Seu dispositivo não tem assistente! + Seu dispositivo não tem aplicativo de configurações! + Nenhum aplicativo pode abrir esta URL! + Falha ao criar o arquivo! + Não é uma pasta! %s + Não é um arquivo! %s + Diretório não encontrado! %s + Não é possível encontrar o arquivo de som! + Permissão de armazenamento negada! + A origem e o destino não podem ser os mesmos! + Não há espaço restante no alvo! %s + Permissão Shizuku negada! + Shizuku não começou! + Este arquivo não tem nome! + Você deve conceder permissão ao Key Mapper para ver seus dispositivos Bluetooth pareados. + Negada permissão para ler localização precisa! + Permissão negada para atender e encerrar chamadas telefônicas! + Permissão negada para ver dispositivos Bluetooth pareados! + Permissão negada para mostrar notificações! + Devem ser 2 ou mais! + Deve ser %d ou menos! + Deve ser maior que 0! + Deve ser maior que 0! + Deve ser maior que 0! + Deve ser maior que 0! + Deve ser %d ou menos! + Alternar WiFi + Habilitar WiFi + Desativar WiFi + Alternar Bluetooth + Habilitar Bluetooth + Desativar Bluetooth + Aumentar o volume + Diminuir volume + Volume mudo + Alternar mudo + Reativar volume + Mostrar caixa de diálogo de volume + Aumentar o fluxo + Aumentar fluxo %s + Diminuir fluxo + Diminuir fluxo %s + Alternar entre os modos de toque (Toque, Vibrar, Silencioso) + Alternar entre os modos de toque (Toque, Vibrar) + Alterar modo de toque + Alterar para o modo %s + Alternar modo Não perturbe + Alternar somente o modo %s DND + Ativar o modo Não perturbe + Habilitar somente o modo %s DND + Desativar o modo Não perturbe + Habilitar rotação automática + Desativar rotação automática + Alternar rotação automática + Modo retrato + Modo paisagem + Mudar orientação + Percorrer as rotações + Percorrer %s rotações + Alternar dados móveis + Habilitar dados móveis + Desativar dados móveis + Alternar brilho automático + Desativar brilho automático + Habilitar brilho automático + Aumentar o brilho da tela + Diminuir o brilho da tela + Expandir gaveta de notificações + Alternar gaveta de notificações + Expandir configurações rápidas + Alternar gaveta de configurações rápidas + Recolher barra de status + Pausar reprodução de mídia + Pausar a reprodução de mídia para um aplicativo + Pausar mídia por %s + Retomar reprodução de mídia + Retomar a reprodução de mídia para um aplicativo + Retomar mídia para %s + Reproduzir/Pausar reprodução de mídia + Reproduzir/Pausar reprodução de mídia para um aplicativo + Reproduzir/Pausar mídia para %s + Próxima faixa + Próxima faixa para um aplicativo + Próxima faixa para %s + Faixa anterior + Faixa anterior para um aplicativo + Faixa anterior para %s + Avanço rápido + Avanço rápido para um aplicativo + Avanço rápido para %s + Nem todos os aplicativos de mídia suportam avanço rápido. Por exemplo, Google Play Music. + Rebobinar + Retroceder para um aplicativo + Rebobinar para %s + Nem todos os aplicativos de mídia suportam retrocesso. Por exemplo, Google Play Music. + Volte + Ir para casa + Abertura recente + Abrir menu + Alternar tela dividida + Ir para o último aplicativo. (Pressione duas vezes em recentes) + Alternar lanterna + Habilitar lanterna + Desativar lanterna + Alternar lanterna %s + Habilitar lanterna %s + Desativar lanterna %s + Habilitar NFC + Desativar NFC + Alternar NFC + Captura de tela + Iniciar assistente de voz + Assistente de dispositivo de inicialização + Abra a câmera + Dispositivo de bloqueio + Dispositivo de bloqueio seguro + Você só poderá fazer login novamente com seu PIN. O scanner de impressão digital e o desbloqueio facial serão desabilitados. Esta é a única maneira confiável que encontrei para bloquear dispositivos não rooteados antes do Android Pie 9 + Dispositivo de dormir/acordar + Você deve ativar a opção para detectar o gatilho quando a tela estiver desligada! + Não faça nada + Mover o cursor para o final + Esta ação pode não funcionar conforme o esperado em alguns aplicativos. + Alternar teclado + Esta ação só funcionará se você tiver tocado em um campo de entrada onde o teclado deveria ser exibido. + Mostrar teclado + Ocultar teclado + Mostrar seletor de teclado + Trocar teclado + Mudar para %s + Corte + Copiar + Colar + Selecionar palavra no cursor + Abrir configurações + Mostrar menu de energia + Alternar modo avião + Habilitar modo avião + Desativar o modo avião + Iniciar aplicativo + Atalho para iniciar aplicativo + Código de chave de entrada + Evento de tecla de entrada + Tela de toque + Deslize a tela + Tela de pinça + Texto de entrada + Abrir URL + Enviar intenção + Iniciar chamada telefônica + Atender chamada telefônica + Terminar chamada telefônica + Tocar som + Descartar notificação mais recente + Descartar todas as notificações + Navegação + Volume + Mídia + Teclado + Aplicativos + Entrada + Câmera & som + Conectividade + Conteúdo + Interface + Telefone + Tela + Notificações + Booleano + Matriz booleana + Inteiro + Matriz de inteiros + String + Matriz de strings + Long + Mariz long + Byte + Matriz de bytes + Double + Matriz dupla + Char + Matriz de caracteres + Float + Matriz float + Short + Matriz short + Só pode ser \"true\" ou \"false\" + Uma lista separada por vírgulas de \"true\" e \"false\". Por exemplo, true, false, true + Um inteiro válido na linguagem de programação Java. + Uma lista separada por vírgulas de inteiros válidos na linguagem de programação Java. Por exemplo, 100.399 + Uma lista separada por vírgulas. Por exemplo, categoria1, categoria2 + Qualquer texto. + Uma lista de strings separadas por vírgulas. Por exemplo string1, string2 + Um Long válido na linguagem de programação Java. + Uma lista separada por vírgulas de Longs válidos na linguagem de programação Java. Por exemplo, 102302234234234,399083423234429 + Um Byte válido na linguagem de programação Java. + Uma lista separada por vírgulas de bytes válidos na linguagem de programação Java. Por exemplo, 123,3 + Um Double válido na linguagem de programação Java. + Uma lista separada por vírgulas de Doubles válidos na linguagem de programação Java. Por exemplo 1.0,3.234 + Um Char válido na linguagem de programação Java. Por exemplo, \'a\' ou \'b\' + Uma lista separada por vírgulas de Chars válidos na linguagem de programação Java. Por exemplo, a,b,c + Um Float válido na linguagem de programação Java. Por exemplo 3.145 + Uma lista separada por vírgulas de Floats válidos na linguagem de programação Java. Por exemplo 1241.123 + Um Short válido na linguagem de programação Java. Por exemplo 2342 + Uma lista separada por vírgulas de Shorts válidos na linguagem de programação Java. Por exemplo 3242,12354 + As flags para um Intent são armazenadas como flags de bits. Essas flags alteram a maneira como o Intent é tratado. Se isso estiver em branco para um Intent de Atividade, o Key Mapper usará FLAG_ACTIVITY_NEW_TASK por padrão. Para obter muito mais informações, toque em \'docs\' para ver a documentação dos desenvolvedores Android. + Guia rápido de início + Confira o Guia de Início Rápido se você estiver travado. + GitHub + Site + Traduções + Versão %s + Avaliar + O que mudou + Discord + Coisas chatas + Licença + A licença de código aberto para este aplicativo. + Política de Privacidade + Não coletamos nenhuma informação pessoal, mas aqui está uma política de privacidade dizendo isso. + Nossa equipe + Desenvolvedor + Moderador/suporte da comunidade + Moderador/suporte da comunidade + Tradutor (Polonês) + Tradutor (Checo) + Tradutor (Espanhol) diff --git a/fastlane/metadata/android/pt_BR/full_description.txt b/fastlane/metadata/android/pt_BR/full_description.txt index 515ccecd74..28ff1d8e2c 100644 --- a/fastlane/metadata/android/pt_BR/full_description.txt +++ b/fastlane/metadata/android/pt_BR/full_description.txt @@ -1,38 +1,38 @@ -What can be remapped? +O que pode ser remapeado? - * Fingerprint gestures on supported devices. - * Volume buttons. - * Navigation buttons. - * Bluetooth/wired keyboards. - * Buttons on other connected devices should also work. + * Gestos de impressão digital em dispositivos suportados. + * Botões de volume. + * Botões de navegação. + * Teclados Bluetooth/com fio. + * Botões em outros dispositivos conectados também devem funcionar. -ONLY HARDWARE buttons can be remapped. -There is NO GUARANTEE any of these buttons will work and this app is NOT designed to control games. Your device's OEM/vendor can prevent them from being remapped. +SOMENTE botões de HARDWARE podem ser remapeados. +NÃO HÁ GARANTIA de que qualquer um desses botões funcionará e este aplicativo NÃO foi projetado para controlar jogos. O OEM/fornecedor do seu dispositivo pode impedir que ele seja remapeado. -You can combine multiple keys from a specific device or any device to form a "trigger". Each trigger can have multiple actions. The keys can be set to be pressed at the same time or one after another in a sequence. Keys can be remapped when they are short pressed, long pressed or double pressed. A keymap can have a set of "constraints" so it only has an effect in certain situations. +Você pode combinar várias teclas de um dispositivo específico ou de qualquer dispositivo para formar um "gatilho". Cada gatilho pode ter múltiplas ações. As teclas podem ser configuradas para serem pressionadas ao mesmo tempo ou uma após a outra em sequência. As teclas podem ser remapeadas quando pressionadas brevemente, longamente ou duas vezes. Um mapa de teclas pode ter um conjunto de "restrições" para que ele só tenha efeito em determinadas situações. -What can’t be remapped? - * Power button - * Bixby button - * Mouse buttons - * Dpad, thumb sticks or triggers on game controllers +O que não pode ser remapeado? + * Botão de energia + * Botão Bixby + * Botões do mouse + * Dpad, joysticks ou gatilhos em controles de jogos -Your key maps don't work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. +Os mapas de teclas não funcionam se a tela estiver desligada. Esta é uma limitação do Android. Não há nada que o desenvolvedor possa fazer. -What can I remap my keys to do? -Some actions will only work on rooted devices and specific Android versions. +O que posso fazer para remapear minhas chaves? +Algumas ações só funcionarão em dispositivos com root e em versões específicas do Android. -There are too many features to list here so check out the full list here: https://docs.keymapper.club/user-guide/actions +Há muitos recursos para listar aqui, então confira a lista completa aqui: https://docs.keymapper.club/user-guide/actions -Permissions -You don't have to grant all the permissions for the app to work. The app will tell you if a permission needs to be granted for a feature to work. +Permissões +Você não precisa conceder todas as permissões para que o aplicativo funcione. O aplicativo informará se é necessário conceder uma permissão para que um recurso funcione. - * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block keyevents. - * Device Admin: To turn the screen off when using the action to turn off the screen. - * Modify System Settings: To change the brightness and rotation settings. - * Camera: To control the flashlight. + * Serviço de Acessibilidade: Requisito básico para o remapeamento para o trabalho. É necessário para que o aplicativo possa escutar e bloquear eventos-chave. + * Administrador do dispositivo: para desligar a tela ao usar a ação para desligar a tela. + * Modificar configurações do sistema: para alterar as configurações de brilho e rotação. + * Câmera: Para controlar a lanterna. - On some devices, enabling the accessibility service will disable "enhanced data encryption". + Em alguns dispositivos, habilitar o serviço de acessibilidade desabilitará a "criptografia de dados aprimorada". Discord: www.keymapper.club -Website: docs.keymapper.club \ No newline at end of file +Site: docs.keymapper.club \ No newline at end of file diff --git a/fastlane/metadata/android/pt_BR/short_description.txt b/fastlane/metadata/android/pt_BR/short_description.txt index 4de8bd060b..e056032ee7 100644 --- a/fastlane/metadata/android/pt_BR/short_description.txt +++ b/fastlane/metadata/android/pt_BR/short_description.txt @@ -1 +1 @@ -Unleash your keys! Open source! \ No newline at end of file +Liberte suas chaves! Código aberto! \ No newline at end of file From 5b232f2dd236f41081d567ea55317452ed6180fa Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 7 Nov 2024 21:43:41 +0100 Subject: [PATCH 083/118] #1274 feat: show dialog if an error is encountered while purchasing --- .../sds100/keymapper/util/ErrorUtils.kt | 6 ++++++ .../io/github/sds100/keymapper/util/Result.kt | 21 +++++++++++++++++++ app/src/main/res/values/strings.xml | 11 ++++++++++ ...eTest.kt => GetActionFailedUseCaseTest.kt} | 2 +- .../keymaps/ConfigTriggerViewModelTest.kt | 1 + 5 files changed, 40 insertions(+), 1 deletion(-) rename app/src/test/java/io/github/sds100/keymapper/actions/{GetActionErrorUseCaseTest.kt => GetActionFailedUseCaseTest.kt} (99%) diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt b/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt index eb97c58a26..3277c60cbe 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt @@ -141,6 +141,12 @@ fun Error.getFullMessage(resourceProvider: ResourceProvider) = when (this) { Error.CantDetectKeyEventsInPhoneCall -> resourceProvider.getString(R.string.trigger_error_cant_detect_in_phone_call_explanation) Error.GestureStrokeCountTooHigh -> resourceProvider.getString(R.string.trigger_error_gesture_stroke_count_too_high) Error.GestureDurationTooHigh -> resourceProvider.getString(R.string.trigger_error_gesture_duration_too_high) + + Error.PurchasingError.Cancelled -> resourceProvider.getString(R.string.purchasing_error_cancelled) + Error.PurchasingError.NetworkError -> resourceProvider.getString(R.string.purchasing_error_network) + Error.PurchasingError.ProductNotFound -> resourceProvider.getString(R.string.purchasing_error_product_not_found) + Error.PurchasingError.StoreProblem -> resourceProvider.getString(R.string.purchasing_error_store_problem) + is Error.PurchasingError.Unexpected -> this.message } val Error.isFixable: Boolean diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Result.kt b/app/src/main/java/io/github/sds100/keymapper/util/Result.kt index 6f43448f24..931297ad3d 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Result.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Result.kt @@ -120,6 +120,18 @@ sealed class Error : Result() { data object ShizukuNotStarted : Error() data object CantDetectKeyEventsInPhoneCall : Error() + + sealed class PurchasingError : Error() { + data object ProductNotFound : PurchasingError() + data object Cancelled : PurchasingError() + data object StoreProblem : PurchasingError() + data object NetworkError : PurchasingError() + + /** + * This handles errors that haven't been + */ + data class Unexpected(val message: String) : PurchasingError() + } } inline fun Result.onSuccess(f: (T) -> Unit): Result { @@ -156,6 +168,15 @@ inline infix fun Result.otherwise(f: (error: Error) -> Result) = is Error -> f(this) } +inline fun Result.resolve( + onSuccess: (value: T) -> U, + onFailure: (error: Error) -> U, +) = + when (this) { + is Success -> onSuccess(this.value) + is Error -> onFailure(this) + } + inline infix fun Result.valueIfFailure(f: (error: Error) -> T): T = when (this) { is Success -> this.value diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da02a7cb67..1c872f0aeb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1382,5 +1382,16 @@ The developer doesn\'t believe ads are a sustainable or user-friendly form of monetization so these paid triggers help support development ❤️. You will be given priority support as well. Assistant trigger Did you know you can remap your device assistant? Instead of launching Google Assistant or Bixby, your device can perform an action of your choice. It works even when the screen is off! + + Unlock (%s) + Use + Loading… + Retry fetching price + Purchase cancelled. + Network error encountered. Do you have an internet connection? + This product was not found. + Google Play encountered an error. + Something went wrong 😕 + Retry diff --git a/app/src/test/java/io/github/sds100/keymapper/actions/GetActionErrorUseCaseTest.kt b/app/src/test/java/io/github/sds100/keymapper/actions/GetActionFailedUseCaseTest.kt similarity index 99% rename from app/src/test/java/io/github/sds100/keymapper/actions/GetActionErrorUseCaseTest.kt rename to app/src/test/java/io/github/sds100/keymapper/actions/GetActionFailedUseCaseTest.kt index eca3be64c8..72cf56fb62 100644 --- a/app/src/test/java/io/github/sds100/keymapper/actions/GetActionErrorUseCaseTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/actions/GetActionFailedUseCaseTest.kt @@ -27,7 +27,7 @@ import org.mockito.kotlin.whenever @ExperimentalCoroutinesApi @RunWith(MockitoJUnitRunner::class) -class GetActionErrorUseCaseTest { +class GetActionFailedUseCaseTest { private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) diff --git a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModelTest.kt b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModelTest.kt index 62993d252c..dc9e2bf70d 100644 --- a/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModelTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/mappings/keymaps/ConfigTriggerViewModelTest.kt @@ -79,6 +79,7 @@ class ConfigTriggerViewModelTest { onBlocking { getTriggerErrors(any()) }.thenReturn(emptyList()) }, fakeResourceProvider, + purchasingManager = mock(), ) } From 5235760fb79be9ceb0d3f59928204c2fa9e856da Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 7 Nov 2024 21:50:15 +0100 Subject: [PATCH 084/118] #1274 dismiss advanced triggers bottom sheet after choosing assistant trigger --- .../mappings/keymaps/trigger/RecordTriggerButtonRow.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt index c32a852fc9..7cc6b45b95 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt @@ -20,9 +20,6 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -43,14 +40,13 @@ fun RecordTriggerButtonRow( ) { val recordTriggerState by viewModel.recordTriggerState.collectAsState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var showBottomSheet: Boolean by rememberSaveable { mutableStateOf(false) } - if (showBottomSheet) { + if (viewModel.showAdvancedTriggersBottomSheet) { AdvancedTriggersBottomSheet( modifier = Modifier.systemBarsPadding(), viewModel = viewModel, onDismissRequest = { - showBottomSheet = false + viewModel.showAdvancedTriggersBottomSheet }, sheetState = sheetState, ) @@ -61,7 +57,7 @@ fun RecordTriggerButtonRow( onRecordTriggerClick = viewModel::onRecordTriggerButtonClick, recordTriggerState = recordTriggerState, onAdvancedTriggersClick = { - showBottomSheet = true + viewModel.showAdvancedTriggersBottomSheet = true }, ) } From 628dfd395c19c1dcf4c00a07a4fe74eaf56ae9c9 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 7 Nov 2024 22:11:29 +0100 Subject: [PATCH 085/118] #1274 dismiss bottom sheet after it is hidden --- .../mappings/keymaps/trigger/RecordTriggerButtonRow.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt index 7cc6b45b95..1a62fb1585 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/RecordTriggerButtonRow.kt @@ -46,7 +46,7 @@ fun RecordTriggerButtonRow( modifier = Modifier.systemBarsPadding(), viewModel = viewModel, onDismissRequest = { - viewModel.showAdvancedTriggersBottomSheet + viewModel.showAdvancedTriggersBottomSheet = false }, sheetState = sheetState, ) From 2db6560cdd21c9d565e09be4dc5f91112ad96fbe Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 7 Nov 2024 22:12:37 +0100 Subject: [PATCH 086/118] #1274 fix: do not allow multiple trigger keys when switching to a parallel trigger --- .../mappings/keymaps/ConfigKeyMapUseCase.kt | 3 +- .../keymapper/ConfigKeyMapUseCaseTest.kt | 44 +++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt index 9a0a953c4c..83ee68fe9f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapUseCase.kt @@ -164,7 +164,8 @@ class ConfigKeyMapUseCaseImpl( // remove duplicates of keys that have the same keycode and device id .distinctBy { key -> when (key) { - is AssistantTriggerKey -> key.type + // You can't mix assistant trigger types in a parallel trigger because there is no notion of a "down" key event, which means they can't be pressed at the same time + is AssistantTriggerKey -> 0 is KeyCodeTriggerKey -> Pair(key.keyCode, key.device) } } diff --git a/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt b/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt index 97fe4ecee9..cfd6c56ac3 100644 --- a/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt +++ b/app/src/test/java/io/github/sds100/keymapper/ConfigKeyMapUseCaseTest.kt @@ -9,6 +9,7 @@ import io.github.sds100.keymapper.mappings.keymaps.KeyMap import io.github.sds100.keymapper.mappings.keymaps.KeyMapAction import io.github.sds100.keymapper.mappings.keymaps.trigger.AssistantTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.AssistantTriggerType +import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode @@ -22,6 +23,8 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.instanceOf import org.hamcrest.Matchers.`is` import org.junit.Before import org.junit.Test @@ -47,8 +50,43 @@ class ConfigKeyMapUseCaseTest { ) } + /** + * This ensures that it isn't possible to have two or more assistant triggers when the mode is parallel. + */ + @Test + fun `Remove device assistant trigger if setting mode to parallel and voice assistant already exists`() = + runTest(testDispatcher) { + useCase.mapping.value = State.Data(KeyMap()) + + useCase.addKeyCodeTriggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, TriggerKeyDevice.Any) + useCase.addAssistantTriggerKey(AssistantTriggerType.VOICE) + useCase.addAssistantTriggerKey(AssistantTriggerType.DEVICE) + useCase.setParallelTriggerMode() + + val trigger = useCase.mapping.value.dataOrNull()!!.trigger + assertThat(trigger.keys, hasSize(2)) + assertThat(trigger.keys[0], instanceOf(KeyCodeTriggerKey::class.java)) + assertThat(trigger.keys[1], instanceOf(AssistantTriggerKey::class.java)) + } + + @Test + fun `Remove voice assistant trigger if setting mode to parallel and device assistant already exists`() = + runTest(testDispatcher) { + useCase.mapping.value = State.Data(KeyMap()) + + useCase.addKeyCodeTriggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, TriggerKeyDevice.Any) + useCase.addAssistantTriggerKey(AssistantTriggerType.DEVICE) + useCase.addAssistantTriggerKey(AssistantTriggerType.VOICE) + useCase.setParallelTriggerMode() + + val trigger = useCase.mapping.value.dataOrNull()!!.trigger + assertThat(trigger.keys, hasSize(2)) + assertThat(trigger.keys[0], instanceOf(KeyCodeTriggerKey::class.java)) + assertThat(trigger.keys[1], instanceOf(AssistantTriggerKey::class.java)) + } + @Test - fun `Set trigger mode to short press when adding assistant key to multiple long press trigger keys`() = + fun `Set click type to short press when adding assistant key to multiple long press trigger keys`() = runTest(testDispatcher) { useCase.mapping.value = State.Data(KeyMap()) @@ -63,7 +101,7 @@ class ConfigKeyMapUseCaseTest { } @Test - fun `Set trigger mode to short press when adding assistant key to double press trigger key`() = + fun `Set click type to short press when adding assistant key to double press trigger key`() = runTest(testDispatcher) { useCase.mapping.value = State.Data(KeyMap()) @@ -76,7 +114,7 @@ class ConfigKeyMapUseCaseTest { } @Test - fun `Set trigger mode to short press when adding assistant key to long press trigger key`() = + fun `Set click type to short press when adding assistant key to long press trigger key`() = runTest(testDispatcher) { useCase.mapping.value = State.Data(KeyMap()) From cdc1a31772c3951d44608ac114dd23e5e767cd8b Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 8 Nov 2024 11:38:03 +0100 Subject: [PATCH 087/118] #1274 feat: show a button to contact the developer if they have purchased the assistant trigger --- .../sds100/keymapper/util/ShareUtils.kt | 19 +++++++++++++++++++ app/src/main/res/values/strings.xml | 7 +++++++ 2 files changed, 26 insertions(+) create mode 100644 app/src/main/java/io/github/sds100/keymapper/util/ShareUtils.kt diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ShareUtils.kt b/app/src/main/java/io/github/sds100/keymapper/util/ShareUtils.kt new file mode 100644 index 0000000000..888bbe2f94 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/util/ShareUtils.kt @@ -0,0 +1,19 @@ +package io.github.sds100.keymapper.util + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent + +object ShareUtils { + fun sendMail(ctx: Context, email: String, subject: String, body: String) { + try { + val intent = Intent(Intent.ACTION_SEND) + intent.type = "vnd.android.cursor.item/email" + intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(email)) + intent.putExtra(Intent.EXTRA_SUBJECT, subject) + intent.putExtra(Intent.EXTRA_TEXT, body) + ctx.startActivity(intent) + } catch (_: ActivityNotFoundException) { + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1c872f0aeb..480f2b07fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1372,6 +1372,7 @@ Darío B. C. (bydariogamer) https://github.com/bydariogamer Translator (Spanish) + @@ -1382,6 +1383,8 @@ The developer doesn\'t believe ads are a sustainable or user-friendly form of monetization so these paid triggers help support development ❤️. You will be given priority support as well. Assistant trigger Did you know you can remap your device assistant? Instead of launching Google Assistant or Bixby, your device can perform an action of your choice. It works even when the screen is off! + Thank you for supporting the app ❤️! + Your purchase was successful and you can now use the assistant triggers. As a paying user of Key Mapper you will receive priority support to help you to use the app. There is now a button on this page to contact the developer. Unlock (%s) @@ -1394,4 +1397,8 @@ Google Play encountered an error. Something went wrong 😕 Retry + Contact developer + contact@keymapper.club + Key Mapper Pro query + Please describe the problem you are having here. From e1c16b121731001b4301d7dc138a07901c0b871f Mon Sep 17 00:00:00 2001 From: sds100 Date: Fri, 8 Nov 2024 11:42:54 +0100 Subject: [PATCH 088/118] #1274 feat: show thank you dialog after purchasing --- app/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 480f2b07fd..b528796648 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1383,8 +1383,8 @@ The developer doesn\'t believe ads are a sustainable or user-friendly form of monetization so these paid triggers help support development ❤️. You will be given priority support as well. Assistant trigger Did you know you can remap your device assistant? Instead of launching Google Assistant or Bixby, your device can perform an action of your choice. It works even when the screen is off! - Thank you for supporting the app ❤️! - Your purchase was successful and you can now use the assistant triggers. As a paying user of Key Mapper you will receive priority support to help you to use the app. There is now a button on this page to contact the developer. + Thank you for supporting the app! ❤️ + Your purchase was successful and you can now use the assistant triggers. As a paying user of Key Mapper you will receive priority support to help you use the app. There is now a button on this page to contact the developer. Unlock (%s) From 028a8cdc83720b8b9bccc6693e745d8f81c0e08c Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 12 Nov 2024 18:21:25 +0100 Subject: [PATCH 089/118] #1274 feat: show errors if the assistant is not purchased or key mapper is not selected as the device assistant --- .../keymaps/trigger/ConfigTriggerViewModel.kt | 4 --- .../keymapper/purchasing/PurchasingManager.kt | 11 ------ .../purchasing/PurchasingManagerImpl.kt | 23 +++++++++++++ .../io/github/sds100/keymapper/UseCases.kt | 2 ++ .../mappings/keymaps/DisplayKeyMapUseCase.kt | 34 ++++++++++++++++++- .../mappings/keymaps/KeyMapListItemCreator.kt | 13 +++++++ .../keymaps/trigger/AssistantTriggerKey.kt | 7 ++++ .../trigger/BaseConfigTriggerViewModel.kt | 20 ++++++++++- .../mappings/keymaps/trigger/TriggerError.kt | 8 +++++ .../sds100/keymapper/purchasing/ProductId.kt | 5 +++ .../keymapper/purchasing/PurchasingManager.kt | 9 +++++ .../apps/AndroidPackageManagerAdapter.kt | 12 +++++++ .../system/apps/PackageManagerAdapter.kt | 1 + .../sds100/keymapper/util/ErrorUtils.kt | 11 +++++- .../io/github/sds100/keymapper/util/Result.kt | 15 +++++--- app/src/main/res/values/strings.xml | 5 +++ 16 files changed, 158 insertions(+), 22 deletions(-) delete mode 100644 app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt create mode 100644 app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/purchasing/ProductId.kt create mode 100644 app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt index b3a1651c31..8f46a6afcd 100644 --- a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/ConfigTriggerViewModel.kt @@ -8,10 +8,6 @@ import io.github.sds100.keymapper.purchasing.PurchasingManager import io.github.sds100.keymapper.util.ui.ResourceProvider import kotlinx.coroutines.CoroutineScope -/** - * Created by sds100 on 24/11/20. - */ - class ConfigTriggerViewModel( coroutineScope: CoroutineScope, onboarding: OnboardingUseCase, diff --git a/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt b/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt deleted file mode 100644 index 66e0d5a455..0000000000 --- a/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.github.sds100.keymapper.purchasing - -import android.content.Context -import kotlinx.coroutines.CoroutineScope - -class PurchasingManagerImpl( - context: Context, - private val coroutineScope: CoroutineScope, -) : PurchasingManager - -interface PurchasingManager diff --git a/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt b/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt new file mode 100644 index 0000000000..08485a8d5e --- /dev/null +++ b/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt @@ -0,0 +1,23 @@ +package io.github.sds100.keymapper.purchasing + +import android.content.Context +import io.github.sds100.keymapper.util.Error +import io.github.sds100.keymapper.util.Result +import kotlinx.coroutines.CoroutineScope + +class PurchasingManagerImpl( + context: Context, + private val coroutineScope: CoroutineScope, +) : PurchasingManager { + override suspend fun launchPurchasingFlow(product: ProductId): Result { + return Error.PurchasingNotImplemented + } + + override suspend fun getProductPrice(product: ProductId): Result { + return Error.PurchasingNotImplemented + } + + override suspend fun isPurchased(product: ProductId): Result { + return Error.PurchasingNotImplemented + } +} diff --git a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt index 66c73eb798..2d13e8f95a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt +++ b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt @@ -50,6 +50,8 @@ object UseCases { ServiceLocator.inputMethodAdapter(ctx), displaySimpleMapping(ctx), ServiceLocator.settingsRepository(ctx), + ServiceLocator.packageManagerAdapter(ctx), + ServiceLocator.purchasingManager(ctx), ) fun configKeyMap(ctx: Context): ConfigKeyMapUseCase = ConfigKeyMapUseCaseImpl( diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt index e97ed6f0f1..06ec8a32fe 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt @@ -2,15 +2,23 @@ package io.github.sds100.keymapper.mappings.keymaps import android.os.Build import android.view.KeyEvent +import io.github.sds100.keymapper.Constants import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.DisplaySimpleMappingUseCase +import io.github.sds100.keymapper.mappings.keymaps.trigger.AssistantTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerError +import io.github.sds100.keymapper.purchasing.ProductId +import io.github.sds100.keymapper.purchasing.PurchasingManager +import io.github.sds100.keymapper.system.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.inputmethod.KeyMapperImeHelper import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter +import io.github.sds100.keymapper.util.Success +import io.github.sds100.keymapper.util.then +import io.github.sds100.keymapper.util.valueIfFailure import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map @@ -25,6 +33,8 @@ class DisplayKeyMapUseCaseImpl( private val inputMethodAdapter: InputMethodAdapter, displaySimpleMappingUseCase: DisplaySimpleMappingUseCase, private val preferenceRepository: PreferenceRepository, + private val packageManagerAdapter: PackageManagerAdapter, + private val purchasingManager: PurchasingManager, ) : DisplayKeyMapUseCase, DisplaySimpleMappingUseCase by displaySimpleMappingUseCase { private companion object { @@ -44,7 +54,6 @@ class DisplayKeyMapUseCaseImpl( override suspend fun getTriggerErrors(keyMap: KeyMap): List { val trigger = keyMap.trigger val errors = mutableListOf() - // can only detect volume button presses during a phone call with an input method service if (!keyMapperImeHelper.isCompatibleImeChosen() && keyMap.requiresImeKeyEventForwarding()) { errors.add(TriggerError.CANT_DETECT_IN_PHONE_CALL) @@ -69,6 +78,29 @@ class DisplayKeyMapUseCaseImpl( errors.add(TriggerError.SCREEN_OFF_ROOT_DENIED) } + val containsAssistantTrigger = keyMap.trigger.keys.any { it is AssistantTriggerKey } + val containsDeviceAssistantTrigger = + keyMap.trigger.keys.any { it is AssistantTriggerKey && it.requiresDeviceAssistant() } + + val isAssistantTriggerPurchased = + purchasingManager.isPurchased(ProductId.ASSISTANT_TRIGGER).valueIfFailure { false } + + if (containsAssistantTrigger && !isAssistantTriggerPurchased) { + errors.add(TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED) + } + + val isKeyMapperDeviceAssistant = + packageManagerAdapter.getDeviceAssistantPackage() + .then { Success(it == Constants.PACKAGE_NAME) } + .valueIfFailure { false } + + // Show an error if Key Mapper isn't selected as the device assistant + // and an assistant trigger is used. The error shouldn't be shown + // if the assistant trigger feature is not purchased. + if (containsDeviceAssistantTrigger && isAssistantTriggerPurchased && !isKeyMapperDeviceAssistant) { + errors.add(TriggerError.ASSISTANT_NOT_SELECTED) + } + return errors } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt index 3491f35ace..56f1b1fae8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt @@ -10,6 +10,7 @@ import io.github.sds100.keymapper.mappings.keymaps.trigger.Trigger import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerError import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerKeyDevice import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerMode +import io.github.sds100.keymapper.purchasing.ProductId import io.github.sds100.keymapper.system.devices.InputDeviceUtils import io.github.sds100.keymapper.system.keyevents.KeyEventUtils import io.github.sds100.keymapper.system.permissions.Permission @@ -118,6 +119,18 @@ class KeyMapListItemCreator( text = getString(R.string.trigger_error_cant_detect_in_phone_call), error = Error.CantDetectKeyEventsInPhoneCall, ) + + TriggerError.ASSISTANT_NOT_SELECTED -> ChipUi.Error( + id = error.toString(), + text = getString(R.string.trigger_error_assistant_activity_not_chosen), + error = Error.DeviceAssistantNotSet, + ) + + TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> ChipUi.Error( + id = error.toString(), + text = getString(R.string.trigger_error_assistant_not_purchased), + error = Error.ProductNotPurchased(ProductId.ASSISTANT_TRIGGER), + ) } private fun StringBuilder.appendKeyCodeTriggerKeyName( diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt index 023648fee5..fad2475b4c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AssistantTriggerKey.kt @@ -22,6 +22,13 @@ data class AssistantTriggerKey( // assistant event to another app (or can it??). override val consumeEvent: Boolean = true + /** + * Whether this assistant trigger requires the device assistant activity to be set. + */ + fun requiresDeviceAssistant(): Boolean { + return type == AssistantTriggerType.DEVICE || type == AssistantTriggerType.ANY + } + companion object { fun fromEntity( entity: AssistantTriggerKeyEntity, diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt index 028bb3a929..9073200d74 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt @@ -273,7 +273,7 @@ abstract class BaseConfigTriggerViewModel( } } - private fun buildTriggerErrorListItems(triggerErrors: List) = + private fun buildTriggerErrorListItems(triggerErrors: List): List = triggerErrors.map { error -> when (error) { TriggerError.DND_ACCESS_DENIED -> TextListItem.Error( @@ -290,6 +290,16 @@ abstract class BaseConfigTriggerViewModel( id = error.toString(), text = getString(R.string.trigger_error_cant_detect_in_phone_call), ) + + TriggerError.ASSISTANT_NOT_SELECTED -> TextListItem.Error( + id = error.toString(), + text = getString(R.string.trigger_error_assistant_activity_not_chosen), + ) + + TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> TextListItem.Error( + id = error.toString(), + text = getString(R.string.trigger_error_assistant_not_purchased), + ) } } @@ -419,6 +429,14 @@ abstract class BaseConfigTriggerViewModel( TriggerError.CANT_DETECT_IN_PHONE_CALL -> { displayKeyMap.fixError(Error.CantDetectKeyEventsInPhoneCall) } + + TriggerError.ASSISTANT_NOT_SELECTED -> { + TODO() + } + + TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> { + TODO() + } } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerError.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerError.kt index c750d4dfe3..13a35842a5 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerError.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/TriggerError.kt @@ -7,4 +7,12 @@ enum class TriggerError { DND_ACCESS_DENIED, SCREEN_OFF_ROOT_DENIED, CANT_DETECT_IN_PHONE_CALL, + + // Key Mapper is not selected as the assistant activity. This is required for assistant + // triggers. + ASSISTANT_NOT_SELECTED, + + // This error appears when a key map has an assistant trigger but the user hasn't purchased + // the product. + ASSISTANT_TRIGGER_NOT_PURCHASED, } diff --git a/app/src/main/java/io/github/sds100/keymapper/purchasing/ProductId.kt b/app/src/main/java/io/github/sds100/keymapper/purchasing/ProductId.kt new file mode 100644 index 0000000000..f7235a961c --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/purchasing/ProductId.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.purchasing + +enum class ProductId(val packageId: String, val entitlementId: String) { + ASSISTANT_TRIGGER("assistant_trigger", "assistant_trigger"), +} diff --git a/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt b/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt new file mode 100644 index 0000000000..fe906197a4 --- /dev/null +++ b/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt @@ -0,0 +1,9 @@ +package io.github.sds100.keymapper.purchasing + +import io.github.sds100.keymapper.util.Result + +interface PurchasingManager { + suspend fun launchPurchasingFlow(product: ProductId): Result + suspend fun getProductPrice(product: ProductId): Result + suspend fun isPurchased(product: ProductId): Result +} diff --git a/app/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt index 2a5870eede..46256c0a38 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt @@ -136,6 +136,18 @@ class AndroidPackageManagerAdapter( } } + override fun getDeviceAssistantPackage(): Result { + try { + val intent = Intent(Intent.ACTION_ASSIST) + val activity = + packageManager.resolveActivity(intent, 0) ?: return Error.NoDeviceAssistant + + return Success(activity.activityInfo!!.packageName) + } catch (e: ActivityNotFoundException) { + return Error.NoDeviceAssistant + } + } + override fun enableApp(packageName: String) { Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data = Uri.parse("package:$packageName") diff --git a/app/src/main/java/io/github/sds100/keymapper/system/apps/PackageManagerAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/apps/PackageManagerAdapter.kt index 87036708d9..6d0b7982c8 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/apps/PackageManagerAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/apps/PackageManagerAdapter.kt @@ -25,6 +25,7 @@ interface PackageManagerAdapter { fun launchVoiceAssistant(): Result<*> fun launchDeviceAssistant(): Result<*> fun isVoiceAssistantInstalled(): Boolean + fun getDeviceAssistantPackage(): Result fun launchCameraApp(): Result<*> fun launchSettingsApp(): Result<*> diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt b/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt index 3277c60cbe..ba1206c450 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.util import android.content.pm.PackageManager import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.purchasing.ProductId import io.github.sds100.keymapper.system.BuildUtils import io.github.sds100.keymapper.util.ui.ResourceProvider @@ -9,7 +10,7 @@ import io.github.sds100.keymapper.util.ui.ResourceProvider * Created by sds100 on 29/02/2020. */ -fun Error.getFullMessage(resourceProvider: ResourceProvider) = when (this) { +fun Error.getFullMessage(resourceProvider: ResourceProvider): String = when (this) { is Error.PermissionDenied -> Error.PermissionDenied.getMessageForPermission( resourceProvider, @@ -147,6 +148,14 @@ fun Error.getFullMessage(resourceProvider: ResourceProvider) = when (this) { Error.PurchasingError.ProductNotFound -> resourceProvider.getString(R.string.purchasing_error_product_not_found) Error.PurchasingError.StoreProblem -> resourceProvider.getString(R.string.purchasing_error_store_problem) is Error.PurchasingError.Unexpected -> this.message + + Error.DeviceAssistantNotSet -> resourceProvider.getString(R.string.trigger_error_assistant_activity_not_chosen_short) + + is Error.ProductNotPurchased -> when (this.product) { + ProductId.ASSISTANT_TRIGGER -> resourceProvider.getString(R.string.purchasing_error_assistant_not_purchased) + } + + Error.PurchasingNotImplemented -> resourceProvider.getString(R.string.purchasing_error_not_implemented) } val Error.isFixable: Boolean diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Result.kt b/app/src/main/java/io/github/sds100/keymapper/util/Result.kt index 931297ad3d..01c4178629 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Result.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Result.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.util import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.purchasing.ProductId import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.util.ui.ResourceProvider @@ -121,15 +122,21 @@ sealed class Error : Result() { data object ShizukuNotStarted : Error() data object CantDetectKeyEventsInPhoneCall : Error() + // This is returned from the PurchasingManager on FOSS builds that don't + // have the pro features implemented. + data object PurchasingNotImplemented : Error() + + /** + * Key Mapper isn't set as the device assistant. + */ + data object DeviceAssistantNotSet : Error() + data class ProductNotPurchased(val product: ProductId) : Error() + sealed class PurchasingError : Error() { data object ProductNotFound : PurchasingError() data object Cancelled : PurchasingError() data object StoreProblem : PurchasingError() data object NetworkError : PurchasingError() - - /** - * This handles errors that haven't been - */ data class Unexpected(val message: String) : PurchasingError() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b528796648..2d2dd0019b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1385,6 +1385,9 @@ Did you know you can remap your device assistant? Instead of launching Google Assistant or Bixby, your device can perform an action of your choice. It works even when the screen is off! Thank you for supporting the app! ❤️ Your purchase was successful and you can now use the assistant triggers. As a paying user of Key Mapper you will receive priority support to help you use the app. There is now a button on this page to contact the developer. + You must select Key Mapper as the device assistant for this trigger to work. + Key Mapper must be the device assistant. + You must purchase the assistant trigger feature! Unlock (%s) @@ -1392,12 +1395,14 @@ Loading… Retry fetching price Purchase cancelled. + This requires a paid feature that can only be bought by downloading Key Mapper from Google Play. Network error encountered. Do you have an internet connection? This product was not found. Google Play encountered an error. Something went wrong 😕 Retry Contact developer + You must purchase the assistant trigger feature! contact@keymapper.club Key Mapper Pro query Please describe the problem you are having here. From 38bde34ffbfae803e4c2c9408506dd69c3110514 Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 12 Nov 2024 18:25:48 +0100 Subject: [PATCH 090/118] #1274 feat: instruct the user to buy the advanced trigger on the home screen if the assistant trigger is not purchased --- app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt | 2 +- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt b/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt index ba1206c450..e31243b083 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt @@ -152,7 +152,7 @@ fun Error.getFullMessage(resourceProvider: ResourceProvider): String = when (thi Error.DeviceAssistantNotSet -> resourceProvider.getString(R.string.trigger_error_assistant_activity_not_chosen_short) is Error.ProductNotPurchased -> when (this.product) { - ProductId.ASSISTANT_TRIGGER -> resourceProvider.getString(R.string.purchasing_error_assistant_not_purchased) + ProductId.ASSISTANT_TRIGGER -> resourceProvider.getString(R.string.purchasing_error_assistant_not_purchased_home_screen) } Error.PurchasingNotImplemented -> resourceProvider.getString(R.string.purchasing_error_not_implemented) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2d2dd0019b..f19313ca80 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1402,7 +1402,7 @@ Something went wrong 😕 Retry Contact developer - You must purchase the assistant trigger feature! + You must purchase the assistant trigger feature! Tap on the key map and then purchase it by clicking on \'Advanced triggers\'. contact@keymapper.club Key Mapper Pro query Please describe the problem you are having here. From a4723b61632006174f3c948dd26aecccb89a23fd Mon Sep 17 00:00:00 2001 From: sds100 Date: Tue, 12 Nov 2024 19:19:03 +0100 Subject: [PATCH 091/118] #1274 feat: inform the user that advanced triggers are only available from the google play build --- .../trigger/AdvancedTriggersBottomSheet.kt | 123 ++++++++++++++++-- .../trigger/BaseConfigTriggerViewModel.kt | 5 + app/src/main/res/values/strings.xml | 2 + 3 files changed, 116 insertions(+), 14 deletions(-) diff --git a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt index 46844b8fa3..b0c16cb0de 100644 --- a/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt +++ b/app/src/free/java/io/github/sds100/keymapper/mappings/keymaps/trigger/AdvancedTriggersBottomSheet.kt @@ -1,25 +1,52 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close +import androidx.compose.foundation.layout.Arrangement +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.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue.Expanded import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.R +import io.github.sds100.keymapper.compose.KeyMapperTheme import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun AdvancedTriggersBottomSheet( modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, viewModel: ConfigTriggerViewModel, + sheetState: SheetState, +) { + AdvancedTriggersBottomSheet( + modifier, + onDismissRequest, + sheetState, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AdvancedTriggersBottomSheet( + modifier: Modifier = Modifier, onDismissRequest: () -> Unit, sheetState: SheetState, ) { @@ -30,17 +57,85 @@ fun AdvancedTriggersBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, ) { - Text("I am free build.") - IconButton(onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(R.string.advanced_triggers_sheet_title), + style = MaterialTheme.typography.headlineMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = stringResource(R.string.advanced_triggers_sheet_text), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + text = stringResource(R.string.purchasing_not_implemented_bottom_sheet_text), + fontStyle = FontStyle.Italic, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val uriHandler = LocalUriHandler.current + val googlePlayUrl = stringResource(R.string.url_play_store_listing) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedButton( + modifier = Modifier, + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + FilledTonalButton( + modifier = Modifier, + onClick = { + scope.launch { + uriHandler.openUri(googlePlayUrl) + } + }, + ) { + Text(stringResource(R.string.purchasing_download_key_mapper_from_google_play)) } - }) { - Icon( - Icons.Default.Close, - contentDescription = stringResource(R.string.button_dismiss_advanced_triggers_sheet_content_description), - ) } + + Spacer(Modifier.height(16.dp)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun Preview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + density = LocalDensity.current, + initialValue = Expanded, + ) + + AdvancedTriggersBottomSheet( + onDismissRequest = {}, + sheetState = sheetState, + ) } } diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt index 9073200d74..5263d10f01 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt @@ -2,6 +2,9 @@ package io.github.sds100.keymapper.mappings.keymaps.trigger import android.os.Build import android.view.KeyEvent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.ClickType import io.github.sds100.keymapper.mappings.keymaps.ConfigKeyMapUseCase @@ -190,6 +193,8 @@ abstract class BaseConfigTriggerViewModel( private val _fixAppKilling = MutableSharedFlow() val fixAppKilling = _fixAppKilling.asSharedFlow() + var showAdvancedTriggersBottomSheet: Boolean by mutableStateOf(false) + init { val rebuildErrorList = MutableSharedFlow>(replay = 1) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f19313ca80..bc99c5022f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1406,4 +1406,6 @@ contact@keymapper.club Key Mapper Pro query Please describe the problem you are having here. + The advanced triggers are paid feature but you downloaded the FOSS build of Key Mapper that does not include this module or Google Play billing. Please download Key Mapper from Google Play to get access to this feature. + Download Play build From 3cc15495c938e17daa9c470de523f07348fa0185 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 14 Nov 2024 21:42:30 +0100 Subject: [PATCH 092/118] #1274 feat: show error when Key Mapper is not selected as the device assistant --- .../java/io/github/sds100/keymapper/KeyMapperApp.kt | 1 + .../java/io/github/sds100/keymapper/UseCases.kt | 1 - .../mappings/keymaps/DisplayKeyMapUseCase.kt | 13 +++---------- .../mappings/keymaps/KeyMapListItemCreator.kt | 2 +- .../keymaps/trigger/BaseConfigTriggerViewModel.kt | 4 ++-- .../keymapper/purchasing/PurchasingManager.kt | 2 ++ .../system/apps/AndroidPackageManagerAdapter.kt | 11 +++++------ .../system/permissions/AndroidPermissionAdapter.kt | 10 ++++++++++ .../keymapper/system/permissions/Permission.kt | 1 + .../system/permissions/RequestPermissionDelegate.kt | 10 ++++++++++ .../io/github/sds100/keymapper/util/ErrorUtils.kt | 2 -- .../java/io/github/sds100/keymapper/util/Result.kt | 5 +---- 12 files changed, 36 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt index 295bb9f614..b4abdfbba3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt +++ b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt @@ -109,6 +109,7 @@ class KeyMapperApp : MultiDexApplication() { suAdapter, notificationReceiverAdapter, ServiceLocator.settingsRepository(this), + packageManagerAdapter, ) } diff --git a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt index 2d13e8f95a..b309b2fc9e 100644 --- a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt +++ b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt @@ -50,7 +50,6 @@ object UseCases { ServiceLocator.inputMethodAdapter(ctx), displaySimpleMapping(ctx), ServiceLocator.settingsRepository(ctx), - ServiceLocator.packageManagerAdapter(ctx), ServiceLocator.purchasingManager(ctx), ) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt index 06ec8a32fe..9b95283341 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/DisplayKeyMapUseCase.kt @@ -2,7 +2,6 @@ package io.github.sds100.keymapper.mappings.keymaps import android.os.Build import android.view.KeyEvent -import io.github.sds100.keymapper.Constants import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.mappings.DisplaySimpleMappingUseCase @@ -11,13 +10,10 @@ import io.github.sds100.keymapper.mappings.keymaps.trigger.KeyCodeTriggerKey import io.github.sds100.keymapper.mappings.keymaps.trigger.TriggerError import io.github.sds100.keymapper.purchasing.ProductId import io.github.sds100.keymapper.purchasing.PurchasingManager -import io.github.sds100.keymapper.system.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.inputmethod.KeyMapperImeHelper import io.github.sds100.keymapper.system.permissions.Permission import io.github.sds100.keymapper.system.permissions.PermissionAdapter -import io.github.sds100.keymapper.util.Success -import io.github.sds100.keymapper.util.then import io.github.sds100.keymapper.util.valueIfFailure import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.drop @@ -33,7 +29,6 @@ class DisplayKeyMapUseCaseImpl( private val inputMethodAdapter: InputMethodAdapter, displaySimpleMappingUseCase: DisplaySimpleMappingUseCase, private val preferenceRepository: PreferenceRepository, - private val packageManagerAdapter: PackageManagerAdapter, private val purchasingManager: PurchasingManager, ) : DisplayKeyMapUseCase, DisplaySimpleMappingUseCase by displaySimpleMappingUseCase { @@ -46,9 +41,10 @@ class DisplayKeyMapUseCaseImpl( private val keyMapperImeHelper: KeyMapperImeHelper = KeyMapperImeHelper(inputMethodAdapter) - override val invalidateTriggerErrors = merge( + override val invalidateTriggerErrors: Flow = merge( permissionAdapter.onPermissionsUpdate, preferenceRepository.get(Keys.neverShowDndError).map { }.drop(1), + purchasingManager.onCompleteProductPurchase.map { }, ) override suspend fun getTriggerErrors(keyMap: KeyMap): List { @@ -89,10 +85,7 @@ class DisplayKeyMapUseCaseImpl( errors.add(TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED) } - val isKeyMapperDeviceAssistant = - packageManagerAdapter.getDeviceAssistantPackage() - .then { Success(it == Constants.PACKAGE_NAME) } - .valueIfFailure { false } + val isKeyMapperDeviceAssistant = permissionAdapter.isGranted(Permission.DEVICE_ASSISTANT) // Show an error if Key Mapper isn't selected as the device assistant // and an assistant trigger is used. The error shouldn't be shown diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt index 56f1b1fae8..931e880d87 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/KeyMapListItemCreator.kt @@ -123,7 +123,7 @@ class KeyMapListItemCreator( TriggerError.ASSISTANT_NOT_SELECTED -> ChipUi.Error( id = error.toString(), text = getString(R.string.trigger_error_assistant_activity_not_chosen), - error = Error.DeviceAssistantNotSet, + error = Error.PermissionDenied(Permission.DEVICE_ASSISTANT), ) TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> ChipUi.Error( diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt index 5263d10f01..4027bbfc25 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt @@ -436,11 +436,11 @@ abstract class BaseConfigTriggerViewModel( } TriggerError.ASSISTANT_NOT_SELECTED -> { - TODO() + displayKeyMap.fixError(Error.PermissionDenied(Permission.DEVICE_ASSISTANT)) } TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> { - TODO() + showAdvancedTriggersBottomSheet = true } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt b/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt index fe906197a4..4325142743 100644 --- a/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt +++ b/app/src/main/java/io/github/sds100/keymapper/purchasing/PurchasingManager.kt @@ -1,8 +1,10 @@ package io.github.sds100.keymapper.purchasing import io.github.sds100.keymapper.util.Result +import kotlinx.coroutines.flow.MutableSharedFlow interface PurchasingManager { + val onCompleteProductPurchase: MutableSharedFlow suspend fun launchPurchasingFlow(product: ProductId): Result suspend fun getProductPrice(product: ProductId): Result suspend fun isPurchased(product: ProductId): Result diff --git a/app/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt index 46256c0a38..98059ce108 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/apps/AndroidPackageManagerAdapter.kt @@ -137,15 +137,14 @@ class AndroidPackageManagerAdapter( } override fun getDeviceAssistantPackage(): Result { - try { - val intent = Intent(Intent.ACTION_ASSIST) - val activity = - packageManager.resolveActivity(intent, 0) ?: return Error.NoDeviceAssistant + val settingValue = Settings.Secure.getString(ctx.contentResolver, "assistant") - return Success(activity.activityInfo!!.packageName) - } catch (e: ActivityNotFoundException) { + if (settingValue.isNullOrEmpty()) { return Error.NoDeviceAssistant } + + val packageName = settingValue.split("/").first() + return Success(packageName) } override fun enableApp(packageName: String) { diff --git a/app/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt index 7a59597533..386a8fd003 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/permissions/AndroidPermissionAdapter.kt @@ -21,13 +21,17 @@ import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.shizuku.ShizukuUtils import io.github.sds100.keymapper.system.DeviceAdmin import io.github.sds100.keymapper.system.accessibility.ServiceAdapter +import io.github.sds100.keymapper.system.apps.PackageManagerAdapter import io.github.sds100.keymapper.system.root.SuAdapter import io.github.sds100.keymapper.util.Error import io.github.sds100.keymapper.util.Result +import io.github.sds100.keymapper.util.Success import io.github.sds100.keymapper.util.getIdentifier import io.github.sds100.keymapper.util.onFailure import io.github.sds100.keymapper.util.onSuccess import io.github.sds100.keymapper.util.success +import io.github.sds100.keymapper.util.then +import io.github.sds100.keymapper.util.valueIfFailure import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -56,6 +60,7 @@ class AndroidPermissionAdapter( private val suAdapter: SuAdapter, private val notificationReceiverAdapter: ServiceAdapter, private val preferenceRepository: PreferenceRepository, + private val packageManagerAdapter: PackageManagerAdapter, ) : PermissionAdapter { companion object { const val REQUEST_CODE_SHIZUKU_PERMISSION = 1 @@ -333,6 +338,11 @@ class AndroidPermissionAdapter( } else { true } + + Permission.DEVICE_ASSISTANT -> + packageManagerAdapter.getDeviceAssistantPackage() + .then { Success(it == Constants.PACKAGE_NAME) } + .valueIfFailure { false } } override fun isGrantedFlow(permission: Permission): Flow = callbackFlow { diff --git a/app/src/main/java/io/github/sds100/keymapper/system/permissions/Permission.kt b/app/src/main/java/io/github/sds100/keymapper/system/permissions/Permission.kt index 43319c74eb..2bd83e87a0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/permissions/Permission.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/permissions/Permission.kt @@ -19,4 +19,5 @@ enum class Permission { ANSWER_PHONE_CALL, FIND_NEARBY_DEVICES, POST_NOTIFICATIONS, + DEVICE_ASSISTANT, } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/permissions/RequestPermissionDelegate.kt b/app/src/main/java/io/github/sds100/keymapper/system/permissions/RequestPermissionDelegate.kt index 2089876320..1004393c73 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/permissions/RequestPermissionDelegate.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/permissions/RequestPermissionDelegate.kt @@ -100,6 +100,16 @@ class RequestPermissionDelegate( Permission.POST_NOTIFICATIONS -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } + + Permission.DEVICE_ASSISTANT -> { + try { + Intent(Settings.ACTION_VOICE_INPUT_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivityForResultLauncher.launch(this) + } + } catch (e: ActivityNotFoundException) { + } + } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt b/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt index e31243b083..96bed81db7 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ErrorUtils.kt @@ -149,8 +149,6 @@ fun Error.getFullMessage(resourceProvider: ResourceProvider): String = when (thi Error.PurchasingError.StoreProblem -> resourceProvider.getString(R.string.purchasing_error_store_problem) is Error.PurchasingError.Unexpected -> this.message - Error.DeviceAssistantNotSet -> resourceProvider.getString(R.string.trigger_error_assistant_activity_not_chosen_short) - is Error.ProductNotPurchased -> when (this.product) { ProductId.ASSISTANT_TRIGGER -> resourceProvider.getString(R.string.purchasing_error_assistant_not_purchased_home_screen) } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/Result.kt b/app/src/main/java/io/github/sds100/keymapper/util/Result.kt index 01c4178629..222cfafa67 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/Result.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/Result.kt @@ -80,6 +80,7 @@ sealed class Error : Result() { Permission.ANSWER_PHONE_CALL -> R.string.error_answer_end_phone_call_permission_denied Permission.FIND_NEARBY_DEVICES -> R.string.error_find_nearby_devices_permission_denied Permission.POST_NOTIFICATIONS -> R.string.error_notifications_permission_denied + Permission.DEVICE_ASSISTANT -> R.string.trigger_error_assistant_activity_not_chosen_short } return resourceProvider.getString(resId) @@ -126,10 +127,6 @@ sealed class Error : Result() { // have the pro features implemented. data object PurchasingNotImplemented : Error() - /** - * Key Mapper isn't set as the device assistant. - */ - data object DeviceAssistantNotSet : Error() data class ProductNotPurchased(val product: ProductId) : Error() sealed class PurchasingError : Error() { From ad1af280d91b3603a424c87999ea3ac48a808924 Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 14 Nov 2024 21:46:27 +0100 Subject: [PATCH 093/118] #1274 fix: only show button for long press if all the keys are key code triggers --- .../keymaps/trigger/BaseConfigTriggerViewModel.kt | 10 ++++++++++ app/src/main/res/layout/fragment_trigger.xml | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt index 4027bbfc25..e9e6f11bcf 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/trigger/BaseConfigTriggerViewModel.kt @@ -146,6 +146,16 @@ abstract class BaseConfigTriggerViewModel( } }.flowOn(Dispatchers.Default).stateIn(coroutineScope, SharingStarted.Eagerly, false) + /** + * Long press is only allowed for triggers that only use key code trigger keys. + */ + val longPressButtonVisible: StateFlow = config.mapping.map { state -> + when (state) { + is State.Data -> state.data.trigger.keys.all { it is KeyCodeTriggerKey } + State.Loading -> false + } + }.flowOn(Dispatchers.Default).stateIn(coroutineScope, SharingStarted.Eagerly, false) + /** * Only show the buttons for the trigger mode if keys have been added. The buttons * shouldn't be shown when no trigger is selected because they aren't relevant diff --git a/app/src/main/res/layout/fragment_trigger.xml b/app/src/main/res/layout/fragment_trigger.xml index a1c2a74a2b..9b12c7e24d 100644 --- a/app/src/main/res/layout/fragment_trigger.xml +++ b/app/src/main/res/layout/fragment_trigger.xml @@ -102,7 +102,8 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:layout_weight="1" - android:text="@string/radio_button_long_press" /> + android:text="@string/radio_button_long_press" + android:visibility="@{viewModel.longPressButtonVisible ? View.VISIBLE : View.GONE}" /> Date: Thu, 14 Nov 2024 22:23:30 +0100 Subject: [PATCH 094/118] #1274 fix: restore key maps by using gson instance that uses the deserializers --- .../sds100/keymapper/backup/BackupManager.kt | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt b/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt index 268018c5f0..cb5271068a 100644 --- a/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt +++ b/app/src/main/java/io/github/sds100/keymapper/backup/BackupManager.kt @@ -26,6 +26,8 @@ import io.github.sds100.keymapper.data.entities.ConstraintEntity import io.github.sds100.keymapper.data.entities.Extra import io.github.sds100.keymapper.data.entities.FingerprintMapEntity import io.github.sds100.keymapper.data.entities.KeyMapEntity +import io.github.sds100.keymapper.data.entities.TriggerEntity +import io.github.sds100.keymapper.data.entities.TriggerKeyEntity import io.github.sds100.keymapper.data.migration.JsonMigration import io.github.sds100.keymapper.data.migration.Migration10To11 import io.github.sds100.keymapper.data.migration.Migration11To12 @@ -110,6 +112,8 @@ class BackupManagerImpl( GsonBuilder() .registerTypeAdapter(FingerprintMapEntity.DESERIALIZER) .registerTypeAdapter(KeyMapEntity.DESERIALIZER) + .registerTypeAdapter(TriggerEntity.DESERIALIZER) + .registerTypeAdapter(TriggerKeyEntity.DESERIALIZER) .registerTypeAdapter(ActionEntity.DESERIALIZER) .registerTypeAdapter(Extra.DESERIALIZER) .registerTypeAdapter(ConstraintEntity.DESERIALIZER).create() @@ -262,7 +266,6 @@ class BackupManagerImpl( private suspend fun restore(inputStream: InputStream, soundFiles: List): Result<*> { try { val parser = JsonParser() - val gson = Gson() val rootElement = inputStream.bufferedReader().use { val element = parser.parse(it) @@ -299,10 +302,7 @@ class BackupManagerImpl( Migration11To12.migrateKeyMap(json, deviceInfoList ?: JsonArray()) }, // do nothing because this added the log table - JsonMigration( - 12, - 13, - ) { json -> json }, + JsonMigration(12, 13) { json -> json }, ) keymapListJsonArray?.forEach { keyMap -> @@ -330,10 +330,7 @@ class BackupManagerImpl( // do nothing because this added the log table val newFingerprintMapMigrations = listOf( - JsonMigration( - 12, - 13, - ) { json -> json }, + JsonMigration(12, 13) { json -> json }, ) if (rootElement.contains(NAME_FINGERPRINT_MAP_LIST) && backupDbVersion >= 12) { @@ -429,8 +426,9 @@ class BackupManagerImpl( } catch (e: NoSuchElementException) { return Error.CorruptJsonFile(e.message ?: "") } catch (e: Exception) { + e.printStackTrace() + if (throwExceptions) { - e.printStackTrace() throw e } From 9fbb316a39767ede48078e5bd115a6c2285802fe Mon Sep 17 00:00:00 2001 From: sds100 Date: Thu, 14 Nov 2024 22:34:09 +0100 Subject: [PATCH 095/118] #1274 fix: missing member in free PurchasingManagerImpl --- .../sds100/keymapper/purchasing/PurchasingManagerImpl.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt b/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt index 08485a8d5e..b1caee03a0 100644 --- a/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt +++ b/app/src/free/java/io/github/sds100/keymapper/purchasing/PurchasingManagerImpl.kt @@ -4,11 +4,14 @@ import android.content.Context import io.github.sds100.keymapper.util.Error import io.github.sds100.keymapper.util.Result import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow class PurchasingManagerImpl( context: Context, private val coroutineScope: CoroutineScope, ) : PurchasingManager { + override val onCompleteProductPurchase: MutableSharedFlow = MutableSharedFlow() + override suspend fun launchPurchasingFlow(product: ProductId): Result { return Error.PurchasingNotImplemented } From 8e4a1c3a7e06ad1f8a728179ba7ca6819e840dff Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 16 Nov 2024 16:01:06 +0100 Subject: [PATCH 096/118] update libraries --- app/build.gradle | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f8b1fa1cf5..7fcf70b5bf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -153,7 +153,7 @@ dependencies { def room_version = "2.6.1" def coroutinesVersion = "1.9.0" - def nav_version = '2.7.7' + def nav_version = '2.8.4' def work_version = "2.9.1" def epoxy_version = "4.6.2" def splitties_version = "3.0.0" @@ -196,11 +196,11 @@ dependencies { implementation "androidx.legacy:legacy-support-core-ui:1.0.0" implementation "androidx.core:core-ktx:1.13.1" - implementation "androidx.activity:activity-ktx:1.9.1" - implementation "androidx.fragment:fragment-ktx:1.8.2" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4" - implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.4" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.8.4" + implementation "androidx.activity:activity-ktx:1.9.3" + implementation "androidx.fragment:fragment-ktx:1.8.5" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.7" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.8.7" implementation "androidx.room:room-ktx:$room_version" implementation "androidx.work:work-runtime-ktx:$work_version" implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" @@ -209,7 +209,7 @@ dependencies { implementation "androidx.appcompat:appcompat:1.7.0" implementation "androidx.recyclerview:recyclerview:1.3.2" implementation "androidx.preference:preference-ktx:1.2.1" - implementation "androidx.constraintlayout:constraintlayout:2.1.4" + implementation "androidx.constraintlayout:constraintlayout:2.2.0" implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" implementation "androidx.room:room-runtime:$room_version" implementation "androidx.viewpager2:viewpager2:1.1.0" @@ -218,12 +218,12 @@ dependencies { kapt "androidx.room:room-compiler:$room_version" // Compose - implementation "androidx.compose.ui:ui-android:1.7.4" - implementation "androidx.compose.material3:material3-android:1.3.0" - implementation "androidx.compose.ui:ui-tooling-preview-android:1.7.4" - implementation "androidx.compose.material:material-icons-extended-android:1.7.4" - debugImplementation "androidx.compose.ui:ui-tooling:1.7.4" - debug_releaseImplementation "androidx.compose.ui:ui-tooling:1.7.4" + implementation "androidx.compose.ui:ui-android:1.7.5" + implementation "androidx.compose.material3:material3-android:1.3.1" + implementation "androidx.compose.ui:ui-tooling-preview-android:1.7.5" + implementation "androidx.compose.material:material-icons-extended-android:1.7.5" + debugImplementation "androidx.compose.ui:ui-tooling:1.7.5" + debug_releaseImplementation "androidx.compose.ui:ui-tooling:1.7.5" // debugImplementation "com.squareup.leakcanary:leakcanary-android:2.6" @@ -253,6 +253,6 @@ dependencies { androidTestImplementation "androidx.navigation:navigation-testing:$nav_version" androidTestImplementation "android.arch.persistence.room:testing:1.1.1" androidTestImplementation "org.mockito:mockito-android:4.6.1" - debugImplementation "androidx.fragment:fragment-testing:1.8.2" + debugImplementation "androidx.fragment:fragment-testing:1.8.5" implementation "androidx.test:core:$androidXTestCoreVersion" } \ No newline at end of file From 3071715fe9d0455e0c41fe4a8388e160026dd6dc Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 16 Nov 2024 16:13:41 +0100 Subject: [PATCH 097/118] #1274 update store description to say Google Assistant, Pixel Active Edge, and double pressing bixby button are now supported --- fastlane/metadata/android/en-US/full_description.txt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 515ccecd74..13f13d37dd 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,9 +1,9 @@ What can be remapped? - * Fingerprint gestures on supported devices. * Volume buttons. - * Navigation buttons. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Bluetooth/wired keyboards. + * Fingerprint gestures on supported devices. * Buttons on other connected devices should also work. ONLY HARDWARE buttons can be remapped. @@ -12,12 +12,10 @@ There is NO GUARANTEE any of these buttons will work and this app is NOT designe You can combine multiple keys from a specific device or any device to form a "trigger". Each trigger can have multiple actions. The keys can be set to be pressed at the same time or one after another in a sequence. Keys can be remapped when they are short pressed, long pressed or double pressed. A keymap can have a set of "constraints" so it only has an effect in certain situations. What can’t be remapped? - * Power button - * Bixby button * Mouse buttons * Dpad, thumb sticks or triggers on game controllers -Your key maps don't work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. +Your key maps do not work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. What can I remap my keys to do? Some actions will only work on rooted devices and specific Android versions. @@ -27,7 +25,7 @@ There are too many features to list here so check out the full list here: https: Permissions You don't have to grant all the permissions for the app to work. The app will tell you if a permission needs to be granted for a feature to work. - * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block keyevents. + * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block key events. * Device Admin: To turn the screen off when using the action to turn off the screen. * Modify System Settings: To change the brightness and rotation settings. * Camera: To control the flashlight. From 32012b997a31deb6f8d86889fc82e42512a849e8 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sat, 16 Nov 2024 21:44:56 +0100 Subject: [PATCH 098/118] docs: fix translations badges in website and readme --- README.md | 20 ++++++++------------ docs/index.md | 13 +++++++------ 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 1833d7447e..892afcb8be 100644 --- a/README.md +++ b/README.md @@ -27,22 +27,18 @@ Key Mapper is a free and open source Android app that can remap your buttons and 🎉 Check out the [website](https://docs.keymapper.club) for more information and help! 🎉 -[//]: # (## Translations) -[//]: # () -[//]: # (![cs translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Czech&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27cs%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) +## Translations -[//]: # (![es-ES translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Spanish&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27es-ES%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) +[![cs proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=cs&style=flat&logo=crowdin&query=%24.progress.1.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) +[![es-ES proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=es-ES&style=flat&logo=crowdin&query=%24.progress.3.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) +[![pl proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=pl&style=flat&logo=crowdin&query=%24.progress.8.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) +[![pt-BR proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=pt-BR&style=flat&logo=crowdin&query=%24.progress.9.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) +[![ru proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=ru&style=flat&logo=crowdin&query=%24.progress.10.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) +[![sk proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=sk&style=flat&logo=crowdin&query=%24.progress.11.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) +[![zh-CN proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=zh-CN&style=flat&logo=crowdin&query=%24.progress.15.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) -[//]: # (![pl translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Polish&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27pl%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) -[//]: # (![ru translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Russian&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27ru%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) - -[//]: # (![sk translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Slovak&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27sk%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) - -[//]: # (![vi translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Vietnamese&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27vi%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) - -[//]: # (![zh-CN translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Chinese%20(Simplified)&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27zh-CN%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json)) ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=keymapperorg/KeyMapper&type=Date)](https://star-history.com/#keymapperorg/KeyMapper&Date) diff --git a/docs/index.md b/docs/index.md index d23cfa01e9..15cf07fc0e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,12 +19,13 @@ This wiki aims to provide users with a comprehensive guide to using and setting ## Translations -![cs translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Czech&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27cs%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) -![es-ES translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Spanish&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27es-ES%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) -![pl translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Polish&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27pl%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) -![ru translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Russian&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27ru%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) -![sk translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Slovak&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27sk%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) -![zh-CN translation](https://img.shields.io/badge/dynamic/json?color=blue&label=Chinese%20(Simplified)&style=flat&logo=crowdin&query=%24.progress[?(@.data.languageId==%27zh-CN%27)].data.translationProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045.json) +[![cs proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=cs&style=flat&logo=crowdin&query=%24.progress.1.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) +[![es-ES proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=es-ES&style=flat&logo=crowdin&query=%24.progress.3.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) +[![pl proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=pl&style=flat&logo=crowdin&query=%24.progress.8.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) +[![pt-BR proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=pt-BR&style=flat&logo=crowdin&query=%24.progress.9.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) +[![ru proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=ru&style=flat&logo=crowdin&query=%24.progress.10.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) +[![sk proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=sk&style=flat&logo=crowdin&query=%24.progress.11.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) +[![zh-CN proofreading](https://img.shields.io/badge/dynamic/json?color=green&label=zh-CN&style=flat&logo=crowdin&query=%24.progress.15.data.approvalProgress&url=https%3A%2F%2Fbadges.awesome-crowdin.com%2Fstats-13864667-360045-update.json)](https://crowdin.com/project/key-mapper) ## Star History From 58500e6a31259bd456fc7f02acfb3f47a1feec2f Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 24 Nov 2024 14:53:02 +0100 Subject: [PATCH 099/118] #1274 reword strings related to default assistant app --- app/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bc99c5022f..ba117942df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1385,8 +1385,8 @@ Did you know you can remap your device assistant? Instead of launching Google Assistant or Bixby, your device can perform an action of your choice. It works even when the screen is off! Thank you for supporting the app! ❤️ Your purchase was successful and you can now use the assistant triggers. As a paying user of Key Mapper you will receive priority support to help you use the app. There is now a button on this page to contact the developer. - You must select Key Mapper as the device assistant for this trigger to work. - Key Mapper must be the device assistant. + You must select Key Mapper as the default digital assistant app for this trigger to work. + Key Mapper must be the default assistant. You must purchase the assistant trigger feature! From 102fc95c577dacee312316aeb1ea75f1cc99a11c Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 24 Nov 2024 14:54:26 +0100 Subject: [PATCH 100/118] #1274 document the new assistant trigger --- docs/images/advanced-triggers-paywall.png | Bin 0 -> 135024 bytes docs/images/trigger-page.png | Bin 20857 -> 59148 bytes docs/index.md | 2 +- docs/quick-start.md | 2 +- docs/user-guide/fingerprint-gestures.md | 4 +- docs/user-guide/keymaps.md | 47 +++++++++++++++++++++- 6 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 docs/images/advanced-triggers-paywall.png diff --git a/docs/images/advanced-triggers-paywall.png b/docs/images/advanced-triggers-paywall.png new file mode 100644 index 0000000000000000000000000000000000000000..d12a5f897e0a06e7d678d18f1fdf975dac7b3ff6 GIT binary patch literal 135024 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 nH6Q^$qVq2Wh> literal 0 HcmV?d00001 diff --git a/docs/images/trigger-page.png b/docs/images/trigger-page.png index a7adaa5457cc064a2e150a41b643a319cfa5c898..54d015454a0876365e7eabf089c3a386f476ec8a 100644 GIT binary patch literal 59148 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 P00000NkvXXu0mjfuLgKL literal 20857 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 diff --git a/docs/index.md b/docs/index.md index 15cf07fc0e..c21cada0ee 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ Feature development has stopped. -[Key Mapper](http://code.keymapper.club) is a free and open source Android app that can map single or multiple key events to a custom action. +[Key Mapper](https://github.com/keymapperorg/KeyMapper) is a free and open source Android app that can map single or multiple buttons to a custom action. ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/keymapperorg/KeyMapper.svg) ![GitHub Releases Downloads](https://img.shields.io/github/downloads/keymapperorg/keymapper/total.svg?label=GitHub%20Releases%20Downloads) diff --git a/docs/quick-start.md b/docs/quick-start.md index e0bc1b5ee5..173d073429 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -182,7 +182,7 @@ You can see explanations of more options [here](../user-guide/keymaps/#special-o ## Managing key maps -To save your key map and return to the home screen, tap the save :fontawesome-solid-save: icon in the bottom right of the screen. +To save your key map and return to the home screen, tap the save :material-content-save: icon in the bottom right of the screen. Now your key map should already be working. To pause/unpause all of your key maps, pull down the notification tray and tap the Key Mapper notification to toggle between Paused and Running. diff --git a/docs/user-guide/fingerprint-gestures.md b/docs/user-guide/fingerprint-gestures.md index 93fc1cc031..82acd15354 100644 --- a/docs/user-guide/fingerprint-gestures.md +++ b/docs/user-guide/fingerprint-gestures.md @@ -24,9 +24,9 @@ From the Key Mapper home screen, tab the 'Fingerprint' tab. Here you can set actions for the 4 directional gestures. Tapping any one of them will bring you to the action assignment screen for that gesture and by tapping 'Add action' at the bottom of the screen you can assign the action. [Click here for an explanation of all the actions you can choose from.](actions.md) -After choosing an action (or actions) you can press the save :fontawesome-solid-save: icon in the bottom right to save the mapping. +After choosing an action (or actions) you can press the save :material-content-save: icon in the bottom right to save the mapping. -Make sure to save :fontawesome-solid-save: your fingerprint gesture map after applying these changes. +Make sure to save :material-content-save: your fingerprint gesture map after applying these changes. ## Customising actions diff --git a/docs/user-guide/keymaps.md b/docs/user-guide/keymaps.md index 8de321abb1..548850ebe7 100644 --- a/docs/user-guide/keymaps.md +++ b/docs/user-guide/keymaps.md @@ -1,12 +1,12 @@ Refer to the [Quick Start Guide](../quick-start.md) for help with creating key maps. This page gives more detail about every option. -Make sure to save :fontawesome-solid-save: your key map after applying these changes. +Make sure to save :material-content-save: your key map after applying these changes. ## Trigger A trigger is a combination of keys that must be pressed in a certain way to 'trigger' the key map. A key map can only have one trigger. You can change the order of the keys by holding down on one and then dragging it into a new position. -This is the page to create a trigger for a key map. +This is the page to create a trigger for a key map. You will usually want to 'record' a custom trigger that is a combination of physical buttons/keys. There are also ['advanced triggers'](#advanced-triggers) that need to be set up slightly differently. ![](../images/trigger-page.png) @@ -65,6 +65,49 @@ This will change the click type for a key in a sequence trigger. A parallel trig option because all the keys have the same click type. You will find the buttons to change a parallel trigger's click type above the trigger mode buttons as shown in the image at the top of this Trigger section. +## Advanced triggers + +These triggers can be purchased so that the Key Mapper project has a little financial support and the developer is able to invest time maintaining and working on the project. You can see the list of advanced triggers below by tapping 'advanced triggers' on the trigger page. + +![](../images/advanced-triggers-paywall.png) + +### Assistant trigger + +This trigger allows you to remap the various ways that your devices trigger the 'assistant' such as Google Assistant, Bixby, Alexa etc. + +There are 3 assistant options you can choose: + +- **Device assistant** = This is the assistant usually triggered from a long press of a power button or a dedicated button. +- **Voice assistant** = This is the assistant launched from the hands-free voice button on keyboards/headsets. +- **Any assistant** = This will trigger the key map when any of the above are triggered. + +!!! note + It is not possible to create long-press key maps with this trigger! But you can do double press. You also can not use multiple assistant triggers in a parallel trigger because there is no way to detect them being pressed at exactly the same time. + +### Setting up + +There are multiple ways of triggering the assistant on different devices. + +**Long press power button, Pixel squeeze** + +This works on most Android devices. Android devices now have the option for remapping a long press of the power button to the assistant app. Older Pixels, such as the Pixel 2, also had a feature called "Active Edge" that allowed you to _squeeze_ the bottom half of the phone to trigger the assistant. If you select Key Mapper as the 'device assistant' app then your key map will be triggered with both of these methods. + +You can set up the long-press of the power button by going to Android Settings :material-arrow-right-thin: System :material-arrow-right-thin: Gestures :material-arrow-right-thin: Press and hold power button. Then choose the digital assistant instead of showing power menu when you long press the power button. Key Mapper will prompt you to select it as the default digital assistant app. + +**Bixby button** + +This *should* work on Samsung devices that have a dedicated Bixby button but also devices that have the option of remapping the power button to another app when you double press it. You can use the assistant trigger for double pressing the Bixby or power button by picking the 'Assistant trigger' app/activity that shows in your list of apps. + +!!! note + The developer does not have a Samsung device with a Bixby button so there is no guarantee that it works. If it does, please let the developer know so we can be more confident about support in the future 😄. + +**Voice assistant button on keyboards and Bluetooth headphones** + +Many external devices such as headsets, keyboards have a button for launching the voice assistant so you can control your phone hands-free. This also works with Key Mapper. If you + +!!! warning + Some headphones have hardcoded the assistant apps that they will launch and will not work with Key Mapper. The developer has Sony WH1000XM3 headphones that support either Alexa or Google Assistant and refuse to launch Key Mapper when it is selected as the default assistant app. + ## Customising actions You can tap the pencil icon :material-pencil-outline: to the right of the action's name to bring up the following menu. From 69eae2e180e565f3b1293f35e6f531c25effdf50 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 24 Nov 2024 15:03:56 +0100 Subject: [PATCH 101/118] #1274 document the build flavors and build types --- docs/contributing.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index ae02854f2e..37e44f496a 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -13,7 +13,7 @@ You can get the apks for the pre-release versions in 2 ways: There are two types of pre-release versions: - - **Alpha**. These have ".alpha" in the version name and are the most unstable. Expect the most crashes and broken features in these builds. BEWARE! Your data in Key Mapper isn't considered compatible between alpha builds so it is possible that Key Mapper will crash and refuse to fix itself. + - **Alpha**. These have ".alpha" in the version name and are the most unstable. Expect the most crashes and broken features in these builds. BEWARE! Your data in Key Mapper isn't considered compatible between alpha builds so it is possible Key Mapper will crash and refuse to fix itself if you update to a new build that can't understand the data. - **Beta**. These builds have some of the latest features and contain a few bugs. You can safely update between versions. These have ".beta.X" in the version name. These are pre-release builds for the the open-testing channel on Google Play and F-droid always has beta builds. When all known bugs are fixed a new build is released to the app stores. ### How can I help? @@ -34,6 +34,17 @@ You can get the apks for the pre-release versions in 2 ways: !!! info To build the documentation website you need to install [mkdocs-material](https://squidfunk.github.io/mkdocs-material/getting-started/) with Python. Just run `pip install -r requirements.txt` in the root of the project to install it. Then run `mkdocs serve` in the project root. + +### Build flavors and types + +After version 2.7.0 Key Mapper will have 2 build flavours: _free_ and _pro_. The pro flavor includes the closed-source features (e.g assistant trigger) and non-FOSS libraries such as the Google Play Billing library. The free variant stubs out these closed-source features and only uses FOSS libraries. + +There are also 4 build types, which have different optimizations and package names. + +- **debug** = This is the default debug build type that has no optimizations and builds rapidly. It has a `.debug` package name suffix. +- **release** = This is the default release build type that includes a lot of optimizations and creates an apk/app bundle suitable for releasing. There is no package name suffix. +- **debug_release** = This is a debug build type that does not include a package name suffix so that it is possible to test how the production app will look. It is the only way to get the Google Play Billing library functioning because it will break if the package name isn't the same as on the Play store. +- **ci** = This is used for alpha builds to the community in Discord. It includes some optimizations to ensure it is performant but doesn't obfuscate the code so it is possible to understand logs and bug reports. It has a `.ci` package name suffix. ### Introduction to the structure @@ -64,9 +75,7 @@ The `system` package bundles all the packages which are related to the Android f ### Branches 🌴 - master: Everything in the latest stable release. - - develop: The most recent changes. The app is potentially unstable but it can be successfully compiled. A new release is branched off of here. - - - release/*: Branched off develop if it is a new large release (e.g 2.4.0), otherwise it is branched off master for bug fix releases (e.g 2.3.1). Beta releases for a particular release are built from here. Once the code is stable, it will be merged into master. No big changes should be made/merged here as the purpose of this branch is to make a release stable. By separating upcoming releases from develop, new features can be worked on in develop without affecting the upcoming release's code base. + - develop: The most recent changes. The app is potentially unstable but it can be successfully compiled. A new release is branched off of here. - feature/*: Any new changes currently being developed. Merges into develop. - fix/*: A bug fix. This branch should be merged into a release branch and develop. @@ -142,7 +151,7 @@ Follow Google's Kotlin style guide. [https://developer.android.com/kotlin/style- ## Translating 🌍 You can translate this project on the [CrowdIn page](https://crowdin.com/project/key-mapper). Translations will be -merged into production once they are >50% translated. If your language isn't available on the CrowdIn page then contact +merged into production once they are >80% translated. If your language isn't available on the CrowdIn page then contact the developer so we can add it. Our contact details are in the footer of every page on this site. We really appreciate translators so thank you! 🙂 From 6136c7383393bf13de5082b9e1a901247f479a1b Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 24 Nov 2024 15:07:55 +0100 Subject: [PATCH 102/118] #1274 update maintenance notice --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index c21cada0ee..ddde1e53e2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ !!! warning "MAINTENANCE NOTICE!" - Feature development has stopped. + Feature development has slowed down. [Key Mapper](https://github.com/keymapperorg/KeyMapper) is a free and open source Android app that can map single or multiple buttons to a custom action. From 7ac3ccb2064ea3338ab9e4774aea7c8307d02a58 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 24 Nov 2024 15:16:04 +0100 Subject: [PATCH 103/118] #1274 fix docs website redirects and add redirect to advanced triggers --- mkdocs.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index e6defe3138..d81bdb28c4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,7 +94,7 @@ markdown_extensions: plugins: - redirects: redirect_maps: - 'redirects/trigger-by-intent.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/keymaps#trigger-from-other-apps-230' + 'redirects/trigger-by-intent.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/keymaps#allow-other-apps-to-trigger-this-key-map-230' 'redirects/grant-write-secure-settings.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/adb-permissions#write_secure_settings' 'redirects/faq.md': 'https://keymapperorg.github.io/KeyMapper/faq' 'redirects/quick-start.md': 'https://keymapperorg.github.io/KeyMapper/quick-start' @@ -104,14 +104,15 @@ plugins: 'redirects/trigger.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/keymaps#trigger' 'redirects/trigger-options.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/keymaps#special-options' 'redirects/keymap-action-options.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/keymaps#customising-actions' - 'redirects/fingerprint-action-options.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/fingerprint-gestures#customising-fingerprint-gesture-maps' + 'redirects/fingerprint-action-options.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/fingerprint-gestures#customising-actions' 'redirects/trigger-key-options.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/keymaps#key-options' - 'redirects/android-11-device-id-bug-work-around.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/settings#workaround-for-android-11-bug-that-sets-the-device-id-for-input-events-to-1-230-android-11' + 'redirects/android-11-device-id-bug-work-around.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/settings#fix-keyboards-that-are-set-to-us-english-230-android-11' 'redirects/settings.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/settings' 'redirects/report-issues.md': 'https://keymapperorg.github.io/KeyMapper/report-issues' - 'redirects/fix-optimisation.md': 'https://keymapperorg.github.io/KeyMapper/known-issues.html#key-mapper-randomly-stops' + 'redirects/fix-optimisation.md': 'https://keymapperorg.github.io/KeyMapper/faq/#key-mapper-keeps-randomly-stoppingcrashingbuggingfreezing' 'redirects/shizuku-benefits.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/shizuku#benefits' 'redirects/cant-find-accessibility-settings.md': 'https://keymapperorg.github.io/KeyMapper/known-issues#key-mapper-cant-open-the-accessibility-settings-on-some-devices' + 'redirects/advanced-triggers.md': 'https://keymapperorg.github.io/KeyMapper/user-guide/keymaps#advanced-triggers' - search: lang: - en From f70a860b0971c8c3f53a26b02ae031baf51c53e5 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 24 Nov 2024 15:24:29 +0100 Subject: [PATCH 104/118] #1274 add button to learn more about advanced triggers that opens the documentation website --- app/src/main/res/values/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ba117942df..825e8a7950 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -378,6 +378,7 @@ https://github.com/sds100/KeyMapper https://play.google.com/store/apps/details?id=io.github.sds100.keymapper https://keymapperorg.github.io/KeyMapper + https://keymapperorg.github.io/KeyMapper/redirects/advanced-triggers https://play.google.com/store/apps/details?id=io.github.sds100.keymapper.inputmethod.latin https://f-droid.org/en/packages/io.github.sds100.keymapper.inputmethod.latin @@ -1388,6 +1389,7 @@ You must select Key Mapper as the default digital assistant app for this trigger to work. Key Mapper must be the default assistant. You must purchase the assistant trigger feature! + Learn more Unlock (%s) From 947c6417a7bf8cd5a5b092f1d37fbecbc9b783f6 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 24 Nov 2024 16:33:32 +0100 Subject: [PATCH 105/118] no longer show notifications or app intro slides for the fingerprint sensor gesture feature or that chosen bluetooth devices in settings needs to be reconfigured. These notifications needed to be sent many years ago and it is unlikely new users would be affected by it now. Also, most new devices have under-screen fingerprint sensors and won't work with the fingerprint gesture feature anyway. --- .../java/io/github/sds100/keymapper/SplashActivity.kt | 11 ----------- .../sds100/keymapper/onboarding/AppIntroUseCase.kt | 2 -- 2 files changed, 13 deletions(-) diff --git a/app/src/main/java/io/github/sds100/keymapper/SplashActivity.kt b/app/src/main/java/io/github/sds100/keymapper/SplashActivity.kt index db52c9132b..43cb93b643 100644 --- a/app/src/main/java/io/github/sds100/keymapper/SplashActivity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/SplashActivity.kt @@ -31,17 +31,6 @@ class SplashActivity : FragmentActivity() { // Otherwise, show the slides when they are setting up the app for the first time. if (onboarding.shownAppIntro) { appIntroSlides = sequence { - if (!onboarding.approvedFingerprintFeaturePrompt && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - systemFeatureAdapter.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) - ) { - yield(AppIntroSlide.FINGERPRINT_GESTURE_SUPPORT) - } - - if (onboarding.showSetupChosenDevicesAgainAppIntro.firstBlocking()) { - yield(AppIntroSlide.SETUP_CHOSEN_DEVICES_AGAIN) - } - if (onboarding.promptForShizukuPermission.firstBlocking()) { yield(AppIntroSlide.GRANT_SHIZUKU_PERMISSION) } diff --git a/app/src/main/java/io/github/sds100/keymapper/onboarding/AppIntroUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/onboarding/AppIntroUseCase.kt index 19b92f2d1f..1721fc4e46 100644 --- a/app/src/main/java/io/github/sds100/keymapper/onboarding/AppIntroUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/onboarding/AppIntroUseCase.kt @@ -51,8 +51,6 @@ class AppIntroUseCaseImpl( } override fun shownAppIntro() { - preferenceRepository.set(Keys.approvedFingerprintFeaturePrompt, true) - preferenceRepository.set(Keys.approvedSetupChosenDevicesAgain, true) preferenceRepository.set(Keys.shownAppIntro, true) } From cff589702b2817850fe708ed6b66c22dc625d6a7 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 4 Dec 2024 21:32:56 +0100 Subject: [PATCH 106/118] #1274 feat: show a notification presenting the new assistant trigger feature and fix notification trampolines --- .../sds100/keymapper/BaseMainActivity.kt | 23 +- .../github/sds100/keymapper/KeyMapperApp.kt | 2 +- .../io/github/sds100/keymapper/UseCases.kt | 1 + .../io/github/sds100/keymapper/data/Keys.kt | 8 +- .../mappings/keymaps/ConfigKeyMapFragment.kt | 4 + .../keymapper/onboarding/OnboardingUseCase.kt | 89 ++--- .../ControlAccessibilityServiceUseCase.kt | 11 + .../AndroidNotificationAdapter.kt | 37 +- .../ManageNotificationsUseCase.kt | 8 + .../notifications/NotificationController.kt | 322 +++++++++--------- .../system/notifications/NotificationModel.kt | 25 +- .../sds100/keymapper/util/VersionHelper.kt | 5 + .../keymapper/util/ui/NavDestination.kt | 2 +- .../keymapper/util/ui/NavigationViewModel.kt | 6 +- app/src/main/res/navigation/nav_app.xml | 4 + .../main/res/navigation/nav_config_keymap.xml | 10 +- app/src/main/res/values/strings.xml | 2 + .../keymapper/NotificationControllerTest.kt | 141 -------- .../onboarding/FakeOnboardingUseCase.kt | 15 +- 19 files changed, 302 insertions(+), 413 deletions(-) delete mode 100644 app/src/test/java/io/github/sds100/keymapper/NotificationControllerTest.kt diff --git a/app/src/main/java/io/github/sds100/keymapper/BaseMainActivity.kt b/app/src/main/java/io/github/sds100/keymapper/BaseMainActivity.kt index 2b309bd304..039f1cade4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/BaseMainActivity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/BaseMainActivity.kt @@ -12,6 +12,7 @@ import androidx.navigation.findNavController import io.github.sds100.keymapper.Constants.PACKAGE_NAME import io.github.sds100.keymapper.databinding.ActivityMainBinding import io.github.sds100.keymapper.system.permissions.RequestPermissionDelegate +import io.github.sds100.keymapper.util.launchRepeatOnLifecycle import io.github.sds100.keymapper.util.ui.showPopups import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -26,6 +27,9 @@ abstract class BaseMainActivity : AppCompatActivity() { companion object { const val ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG = "$PACKAGE_NAME.ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG" + + const val ACTION_USE_ASSISTANT_TRIGGER = + "$PACKAGE_NAME.ACTION_USE_ASSISTANT_TRIGGER" } private val viewModel by viewModels { @@ -61,8 +65,23 @@ abstract class BaseMainActivity : AppCompatActivity() { } .launchIn(lifecycleScope) - if (intent.action == ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG) { - viewModel.onCantFindAccessibilitySettings() + // Must launch when the activity is resumed + // so the nav controller can be found + launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { + when (intent.action) { + ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG -> { + viewModel.onCantFindAccessibilitySettings() + } + + ACTION_USE_ASSISTANT_TRIGGER -> { + findNavController(R.id.container).navigate( + NavAppDirections.actionToConfigKeymap( + keymapUid = null, + showAdvancedTriggers = true, + ), + ) + } + } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt index b4abdfbba3..c7824b42f0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt +++ b/app/src/main/java/io/github/sds100/keymapper/KeyMapperApp.kt @@ -217,13 +217,13 @@ class KeyMapperApp : MultiDexApplication() { ServiceLocator.settingsRepository(this), notificationAdapter, suAdapter, + permissionAdapter, ), UseCases.pauseMappings(this), UseCases.showImePicker(this), UseCases.controlAccessibilityService(this), UseCases.toggleCompatibleIme(this), ShowHideInputMethodUseCaseImpl(ServiceLocator.accessibilityServiceAdapter(this)), - UseCases.fingerprintGesturesSupported(this), UseCases.onboarding(this), ServiceLocator.resourceProvider(this), ) diff --git a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt index b309b2fc9e..7950a5dad0 100644 --- a/app/src/main/java/io/github/sds100/keymapper/UseCases.kt +++ b/app/src/main/java/io/github/sds100/keymapper/UseCases.kt @@ -122,6 +122,7 @@ object UseCases { fun controlAccessibilityService(ctx: Context): ControlAccessibilityServiceUseCase = ControlAccessibilityServiceUseCaseImpl( ServiceLocator.accessibilityServiceAdapter(ctx), + ServiceLocator.permissionAdapter(ctx), ) fun toggleCompatibleIme(ctx: Context) = diff --git a/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt b/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt index 1003328f38..9bed05c66f 100644 --- a/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt +++ b/app/src/main/java/io/github/sds100/keymapper/data/Keys.kt @@ -42,8 +42,9 @@ object Keys { val hideHomeScreenAlerts = booleanPreferencesKey("pref_hide_home_screen_alerts") val acknowledgedGuiKeyboard = booleanPreferencesKey("pref_acknowledged_gui_keyboard") val showDeviceDescriptors = booleanPreferencesKey("pref_show_device_descriptors") - val approvedFingerprintFeaturePrompt = - booleanPreferencesKey("pref_approved_fingerprint_feature_prompt") + + val approvedAssistantTriggerFeaturePrompt = + booleanPreferencesKey("pref_approved_assistant_trigger_feature_prompt") val shownParallelTriggerOrderExplanation = booleanPreferencesKey("key_shown_parallel_trigger_order_warning") val shownSequenceTriggerExplanation = @@ -58,9 +59,6 @@ object Keys { val fingerprintGesturesAvailable = booleanPreferencesKey("fingerprint_gestures_available") - val approvedSetupChosenDevicesAgain = - booleanPreferencesKey("pref_approved_new_choose_devices_settings") - val rerouteKeyEvents = booleanPreferencesKey("key_reroute_key_events_from_specified_devices") val devicesToRerouteKeyEvents = stringSetPreferencesKey("key_devices_to_reroute_key_events") diff --git a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapFragment.kt b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapFragment.kt index af4caac18c..94d7f0f4ab 100644 --- a/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapFragment.kt +++ b/app/src/main/java/io/github/sds100/keymapper/mappings/keymaps/ConfigKeyMapFragment.kt @@ -50,6 +50,10 @@ class ConfigKeyMapFragment : ConfigMappingFragment() { viewModel.loadKeymap(it) } } + + if (args.showAdvancedTriggers) { + viewModel.configTriggerViewModel.showAdvancedTriggersBottomSheet = true + } } viewModel.configTriggerViewModel.setupNavigation(this) diff --git a/app/src/main/java/io/github/sds100/keymapper/onboarding/OnboardingUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/onboarding/OnboardingUseCase.kt index 8e69bfc3aa..fe3553a583 100644 --- a/app/src/main/java/io/github/sds100/keymapper/onboarding/OnboardingUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/onboarding/OnboardingUseCase.kt @@ -1,6 +1,5 @@ package io.github.sds100.keymapper.onboarding -import androidx.datastore.preferences.core.stringSetPreferencesKey import io.github.sds100.keymapper.Constants import io.github.sds100.keymapper.actions.ActionData import io.github.sds100.keymapper.actions.canUseImeToPerform @@ -59,11 +58,6 @@ class OnboardingUseCaseImpl( preferenceRepository.set(Keys.acknowledgedGuiKeyboard, true) } - override var approvedFingerprintFeaturePrompt by PrefDelegate( - Keys.approvedFingerprintFeaturePrompt, - false, - ) - override var shownParallelTriggerOrderExplanation by PrefDelegate( Keys.shownParallelTriggerOrderExplanation, false, @@ -74,7 +68,7 @@ class OnboardingUseCaseImpl( ) override val showWhatsNew = get(Keys.lastInstalledVersionCodeHomeScreen) - .map { it ?: -1 < Constants.VERSION_CODE } + .map { (it ?: -1) < Constants.VERSION_CODE } override fun showedWhatsNew() { set(Keys.lastInstalledVersionCodeHomeScreen, Constants.VERSION_CODE) @@ -85,67 +79,30 @@ class OnboardingUseCaseImpl( readText() } - override val showFingerprintFeatureNotificationIfAvailable: Flow by lazy { + override var approvedAssistantTriggerFeaturePrompt by PrefDelegate( + Keys.approvedAssistantTriggerFeaturePrompt, + false, + ) + + /** + * Show the assistant trigger only when they *upgrade* to the new version and after they've + * completed the app intro, which asks them whether they want to receive notifications. + */ + override val showAssistantTriggerFeatureNotification: Flow = combine( get(Keys.lastInstalledVersionCodeBackground).map { it ?: -1 }, - showWhatsNew, - get(Keys.approvedFingerprintFeaturePrompt).map { it ?: false }, get(Keys.shownAppIntro).map { it ?: false }, - ) { oldVersionCode, showWhatsNew, approvedPrompt, shownAppIntro -> - // has the user opened the app and will have already seen that they can remap fingerprint gestures - val handledUpdateInHomeScreen = !showWhatsNew - - oldVersionCode < VersionHelper.FINGERPRINT_GESTURES_MIN_VERSION && - !handledUpdateInHomeScreen && - !approvedPrompt && - shownAppIntro + get(Keys.approvedAssistantTriggerFeaturePrompt).map { it ?: false }, + ) { oldVersionCode, shownAppIntro, approvedPrompt -> + oldVersionCode < VersionHelper.ASSISTANT_TRIGGER_MIN_VERSION && + shownAppIntro && + !approvedPrompt } - } - override fun showedFingerprintFeatureNotificationIfAvailable() { + override fun showedAssistantTriggerFeatureNotification() { set(Keys.lastInstalledVersionCodeBackground, Constants.VERSION_CODE) } - override val showSetupChosenDevicesAgainNotification: Flow = - get(Keys.approvedSetupChosenDevicesAgain).map { it ?: false }.map { approvedPreviously -> - val bluetoothDevicesThatShowImePicker = - get(stringSetPreferencesKey("pref_bluetooth_devices_show_ime_picker")).first() - ?: emptySet() - - val bluetoothDevicesThatChangeIme = - get(stringSetPreferencesKey("pref_bluetooth_devices")).first() ?: emptySet() - - val previouslyChoseBluetoothDevices = - bluetoothDevicesThatShowImePicker.isNotEmpty() || bluetoothDevicesThatChangeIme.isNotEmpty() - - return@map !approvedPreviously && previouslyChoseBluetoothDevices - } - - override fun approvedSetupChosenDevicesAgainNotification() { - set(Keys.approvedSetupChosenDevicesAgain, true) - } - - override val showSetupChosenDevicesAgainAppIntro: Flow = - get(Keys.approvedSetupChosenDevicesAgain).map { it ?: false }.map { approvedPreviously -> - - val bluetoothDevicesThatShowImePicker = - get(stringSetPreferencesKey("pref_bluetooth_devices_show_ime_picker")).first() - ?: emptySet() - - val bluetoothDevicesThatChangeIme = - get(stringSetPreferencesKey("pref_bluetooth_devices")).first() - ?: emptySet() - - val previouslyChoseBluetoothDevices = - bluetoothDevicesThatShowImePicker.isNotEmpty() || bluetoothDevicesThatChangeIme.isNotEmpty() - - return@map !approvedPreviously && previouslyChoseBluetoothDevices - } - - override fun approvedSetupChosenDevicesAgainAppIntro() { - set(Keys.approvedSetupChosenDevicesAgain, true) - } - override val showQuickStartGuideHint: Flow = get(Keys.shownQuickStartGuideHint).map { if (it == null) { true @@ -194,18 +151,12 @@ interface OnboardingUseCase { fun isTvDevice(): Boolean fun neverShowGuiKeyboardPromptsAgain() - var approvedFingerprintFeaturePrompt: Boolean var shownParallelTriggerOrderExplanation: Boolean var shownSequenceTriggerExplanation: Boolean - val showFingerprintFeatureNotificationIfAvailable: Flow - fun showedFingerprintFeatureNotificationIfAvailable() - - val showSetupChosenDevicesAgainNotification: Flow - fun approvedSetupChosenDevicesAgainNotification() - - val showSetupChosenDevicesAgainAppIntro: Flow - fun approvedSetupChosenDevicesAgainAppIntro() + val showAssistantTriggerFeatureNotification: Flow + fun showedAssistantTriggerFeatureNotification() + var approvedAssistantTriggerFeaturePrompt: Boolean val showWhatsNew: Flow fun showedWhatsNew() diff --git a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/ControlAccessibilityServiceUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/ControlAccessibilityServiceUseCase.kt index eaf30321a4..406bd1f7c4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/accessibility/ControlAccessibilityServiceUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/accessibility/ControlAccessibilityServiceUseCase.kt @@ -1,5 +1,7 @@ package io.github.sds100.keymapper.system.accessibility +import io.github.sds100.keymapper.system.permissions.Permission +import io.github.sds100.keymapper.system.permissions.PermissionAdapter import kotlinx.coroutines.flow.Flow /** @@ -8,6 +10,7 @@ import kotlinx.coroutines.flow.Flow class ControlAccessibilityServiceUseCaseImpl( private val adapter: ServiceAdapter, + private val permissionAdapter: PermissionAdapter, ) : ControlAccessibilityServiceUseCase { override val serviceState: Flow = adapter.state @@ -26,6 +29,13 @@ class ControlAccessibilityServiceUseCaseImpl( override fun stopService() { adapter.stop() } + + /** + * @return whether the user must manually start/stop the service. + */ + override fun isUserInteractionRequired(): Boolean { + return !permissionAdapter.isGranted(Permission.WRITE_SECURE_SETTINGS) + } } interface ControlAccessibilityServiceUseCase { @@ -33,4 +43,5 @@ interface ControlAccessibilityServiceUseCase { fun startService(): Boolean fun restartService(): Boolean fun stopService() + fun isUserInteractionRequired(): Boolean } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt b/app/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt index b64dea7921..9f13818dc3 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt @@ -8,6 +8,7 @@ import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.google.android.material.color.DynamicColors +import io.github.sds100.keymapper.MainActivity import io.github.sds100.keymapper.R import io.github.sds100.keymapper.util.color import kotlinx.coroutines.CoroutineScope @@ -36,8 +37,8 @@ class AndroidNotificationAdapter( setContentTitle(notification.title) setContentText(notification.text) - if (notification.onClickActionId != null) { - val pendingIntent = createActionPendingIntent(notification.onClickActionId) + if (notification.onClickAction != null) { + val pendingIntent = createActionIntent(notification.onClickAction) setContentIntent(pendingIntent) } @@ -58,12 +59,12 @@ class AndroidNotificationAdapter( setVisibility(NotificationCompat.VISIBILITY_SECRET) } - notification.actions.forEach { action -> + for (action in notification.actions) { addAction( NotificationCompat.Action( 0, action.text, - createActionPendingIntent(action.id), + createActionIntent(action.intentType), ), ) } @@ -100,11 +101,29 @@ class AndroidNotificationAdapter( } } - private fun createActionPendingIntent(actionId: String): PendingIntent { - val intent = Intent(ctx, NotificationClickReceiver::class.java).apply { - action = actionId - } + private fun createActionIntent(intentType: NotificationIntentType): PendingIntent { + when (intentType) { + is NotificationIntentType.Broadcast -> { + val intent = Intent(ctx, NotificationClickReceiver::class.java).apply { + action = intentType.action + } + + return PendingIntent.getBroadcast(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) + } - return PendingIntent.getBroadcast(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) + is NotificationIntentType.MainActivity -> { + val intent = Intent(ctx, MainActivity::class.java).apply { + action = intentType.customIntentAction ?: Intent.ACTION_MAIN + } + + return PendingIntent.getActivity(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) + } + + is NotificationIntentType.Activity -> { + val intent = Intent(intentType.action) + + return PendingIntent.getActivity(ctx, 0, intent, PendingIntent.FLAG_IMMUTABLE) + } + } } } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/notifications/ManageNotificationsUseCase.kt b/app/src/main/java/io/github/sds100/keymapper/system/notifications/ManageNotificationsUseCase.kt index e3524d9227..63ffafbdab 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/notifications/ManageNotificationsUseCase.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/notifications/ManageNotificationsUseCase.kt @@ -3,6 +3,8 @@ package io.github.sds100.keymapper.system.notifications import android.os.Build import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository +import io.github.sds100.keymapper.system.permissions.Permission +import io.github.sds100.keymapper.system.permissions.PermissionAdapter import io.github.sds100.keymapper.system.root.SuAdapter import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -16,6 +18,7 @@ class ManageNotificationsUseCaseImpl( private val preferences: PreferenceRepository, private val notificationAdapter: NotificationAdapter, private val suAdapter: SuAdapter, + private val permissionAdapter: PermissionAdapter, ) : ManageNotificationsUseCase { override val showImePickerNotification: Flow = @@ -82,6 +85,10 @@ class ManageNotificationsUseCaseImpl( override fun deleteChannel(channelId: String) { notificationAdapter.deleteChannel(channelId) } + + override fun isPermissionGranted(): Boolean { + return permissionAdapter.isGranted(Permission.POST_NOTIFICATIONS) + } } interface ManageNotificationsUseCase { @@ -94,6 +101,7 @@ interface ManageNotificationsUseCase { */ val onActionClick: Flow + fun isPermissionGranted(): Boolean fun show(notification: NotificationModel) fun dismiss(notificationId: Int) fun createChannel(channel: NotificationChannelModel) diff --git a/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt b/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt index e66573c0d6..09accd310c 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationController.kt @@ -1,13 +1,12 @@ package io.github.sds100.keymapper.system.notifications -import androidx.annotation.VisibleForTesting +import android.provider.Settings import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import io.github.sds100.keymapper.BaseMainActivity import io.github.sds100.keymapper.Constants import io.github.sds100.keymapper.R import io.github.sds100.keymapper.mappings.PauseMappingsUseCase -import io.github.sds100.keymapper.mappings.fingerprintmaps.AreFingerprintGesturesSupportedUseCase import io.github.sds100.keymapper.onboarding.OnboardingUseCase import io.github.sds100.keymapper.system.accessibility.ControlAccessibilityServiceUseCase import io.github.sds100.keymapper.system.accessibility.ServiceState @@ -28,7 +27,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -44,7 +42,6 @@ class NotificationController( private val controlAccessibilityService: ControlAccessibilityServiceUseCase, private val toggleCompatibleIme: ToggleCompatibleImeUseCase, private val hideInputMethod: ShowHideInputMethodUseCase, - private val areFingerprintGesturesSupported: AreFingerprintGesturesSupportedUseCase, private val onboardingUseCase: OnboardingUseCase, private val resourceProvider: ResourceProvider, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), @@ -55,8 +52,7 @@ class NotificationController( private const val ID_KEYBOARD_HIDDEN = 747 private const val ID_TOGGLE_MAPPINGS = 231 private const val ID_TOGGLE_KEYBOARD = 143 - private const val ID_FEATURE_REMAP_FINGERPRINT_GESTURES = 1 - const val ID_SETUP_CHOSEN_DEVICES_AGAIN = 2 + private const val ID_FEATURE_ASSISTANT_TRIGGER = 900 const val CHANNEL_TOGGLE_KEYMAPS = "channel_toggle_remaps" const val CHANNEL_IME_PICKER = "channel_ime_picker" @@ -87,22 +83,12 @@ class NotificationController( private const val ACTION_DISMISS_TOGGLE_MAPPINGS = "${Constants.PACKAGE_NAME}.ACTION_DISMISS_TOGGLE_MAPPINGS" - private const val ACTION_OPEN_KEY_MAPPER = - "${Constants.PACKAGE_NAME}.ACTION_OPEN_KEY_MAPPER" - private const val ACTION_SHOW_IME_PICKER = "${Constants.PACKAGE_NAME}.ACTION_SHOW_IME_PICKER" private const val ACTION_SHOW_KEYBOARD = "${Constants.PACKAGE_NAME}.ACTION_SHOW_KEYBOARD" private const val ACTION_TOGGLE_KEYBOARD = "${Constants.PACKAGE_NAME}.ACTION_TOGGLE_KEYBOARD" - - @VisibleForTesting - const val ACTION_ON_SETUP_CHOSEN_DEVICES_AGAIN = - "${Constants.PACKAGE_NAME}.ACTION_ON_SETUP_CHOSEN_DEVICES_AGAIN" - - private const val ACTION_FINGERPRINT_GESTURE_FEATURE = - "${Constants.PACKAGE_NAME}.ACTION_FINGERPRINT_GESTURE_FEATURE" } /** @@ -118,6 +104,14 @@ class NotificationController( manageNotifications.deleteChannel(CHANNEL_ID_WARNINGS) manageNotifications.deleteChannel(CHANNEL_ID_PERSISTENT) + manageNotifications.createChannel( + NotificationChannelModel( + id = CHANNEL_NEW_FEATURES, + name = getString(R.string.notification_channel_new_features), + NotificationManagerCompat.IMPORTANCE_LOW, + ), + ) + combine( manageNotifications.showToggleMappingsNotification, controlAccessibilityService.serviceState, @@ -161,32 +155,18 @@ class NotificationController( }.flowOn(dispatchers.default()).launchIn(coroutineScope) coroutineScope.launch(dispatchers.default()) { - combine( - onboardingUseCase.showFingerprintFeatureNotificationIfAvailable, - areFingerprintGesturesSupported.isSupported.map { it ?: false }, - ) { showIfAvailable, isSupported -> - showIfAvailable && isSupported - }.first { it } // suspend until the notification should be shown - - manageNotifications.createChannel( - NotificationChannelModel( - id = CHANNEL_NEW_FEATURES, - name = getString(R.string.notification_channel_new_features), - NotificationManagerCompat.IMPORTANCE_LOW, - ), - ) + // suspend until the notification should be shown. + onboardingUseCase.showAssistantTriggerFeatureNotification.first { it } - manageNotifications.show(fingerprintFeatureNotification()) - onboardingUseCase.showedFingerprintFeatureNotificationIfAvailable() - } + manageNotifications.show(assistantTriggerFeatureNotification()) - onboardingUseCase.showSetupChosenDevicesAgainNotification.onEach { show -> - if (show) { - manageNotifications.show(setupChosenDevicesSettingsAgainNotification()) - } else { - manageNotifications.dismiss(ID_SETUP_CHOSEN_DEVICES_AGAIN) + // Only save that the notification is shown if the app has + // permissions to show notifications so that it is shown + // the next time permission is granted. + if (manageNotifications.isPermissionGranted()) { + onboardingUseCase.showedAssistantTriggerFeatureNotification() } - }.flowOn(dispatchers.default()).launchIn(coroutineScope) + } hideInputMethod.onHiddenChange.onEach { isHidden -> manageNotifications.createChannel( @@ -213,7 +193,6 @@ class NotificationController( ACTION_STOP_SERVICE -> controlAccessibilityService.stopService() ACTION_DISMISS_TOGGLE_MAPPINGS -> manageNotifications.dismiss(ID_TOGGLE_MAPPINGS) - ACTION_OPEN_KEY_MAPPER -> _openApp.emit("") ACTION_SHOW_IME_PICKER -> showImePicker.show(fromForeground = false) ACTION_SHOW_KEYBOARD -> hideInputMethod.show() ACTION_TOGGLE_KEYBOARD -> toggleCompatibleIme.toggle().onSuccess { @@ -221,16 +200,6 @@ class NotificationController( }.onFailure { _showToast.emit(it.getFullMessage(this)) } - - ACTION_FINGERPRINT_GESTURE_FEATURE -> { - onboardingUseCase.approvedFingerprintFeaturePrompt = true - _openApp.emit("") - } - - ACTION_ON_SETUP_CHOSEN_DEVICES_AGAIN -> { - onboardingUseCase.approvedSetupChosenDevicesAgainNotification() - _openApp.emit("") - } } }.flowOn(dispatchers.default()).launchIn(coroutineScope) } @@ -298,94 +267,135 @@ class NotificationController( } } - private fun mappingsPausedNotification(): NotificationModel = NotificationModel( - id = ID_TOGGLE_MAPPINGS, - channel = CHANNEL_TOGGLE_KEYMAPS, - title = getString(R.string.notification_keymaps_paused_title), - text = getString(R.string.notification_keymaps_paused_text), - icon = R.drawable.ic_notification_play, - onClickActionId = ACTION_OPEN_KEY_MAPPER, - showOnLockscreen = true, - onGoing = true, - priority = NotificationCompat.PRIORITY_MIN, - actions = listOf( - NotificationModel.Action( - ACTION_RESUME_MAPPINGS, - getString(R.string.notification_action_resume), - ), - NotificationModel.Action( - ACTION_DISMISS_TOGGLE_MAPPINGS, - getString(R.string.notification_action_dismiss), - ), - NotificationModel.Action( - ACTION_STOP_SERVICE, - getString(R.string.notification_action_stop_acc_service), - ), - ), - ) + private fun mappingsPausedNotification(): NotificationModel { + val stopServiceAction = if (controlAccessibilityService.isUserInteractionRequired()) { + NotificationIntentType.Activity(Settings.ACTION_ACCESSIBILITY_SETTINGS) + } else { + NotificationIntentType.Broadcast(ACTION_STOP_SERVICE) + } - private fun mappingsResumedNotification(): NotificationModel = NotificationModel( - id = ID_TOGGLE_MAPPINGS, - channel = CHANNEL_TOGGLE_KEYMAPS, - title = getString(R.string.notification_keymaps_resumed_title), - text = getString(R.string.notification_keymaps_resumed_text), - icon = R.drawable.ic_notification_pause, - onClickActionId = ACTION_OPEN_KEY_MAPPER, - showOnLockscreen = true, - onGoing = true, - priority = NotificationCompat.PRIORITY_MIN, - actions = listOf( - NotificationModel.Action( - ACTION_PAUSE_MAPPINGS, - getString(R.string.notification_action_pause), - ), - NotificationModel.Action( - ACTION_DISMISS_TOGGLE_MAPPINGS, - getString(R.string.notification_action_dismiss), + return NotificationModel( + id = ID_TOGGLE_MAPPINGS, + channel = CHANNEL_TOGGLE_KEYMAPS, + title = getString(R.string.notification_keymaps_paused_title), + text = getString(R.string.notification_keymaps_paused_text), + icon = R.drawable.ic_notification_play, + onClickAction = NotificationIntentType.MainActivity(), + showOnLockscreen = true, + onGoing = true, + priority = NotificationCompat.PRIORITY_MIN, + actions = listOf( + NotificationModel.Action( + getString(R.string.notification_action_resume), + NotificationIntentType.Broadcast(ACTION_RESUME_MAPPINGS), + ), + NotificationModel.Action( + getString(R.string.notification_action_dismiss), + NotificationIntentType.Broadcast(ACTION_DISMISS_TOGGLE_MAPPINGS), + ), + NotificationModel.Action( + getString(R.string.notification_action_stop_acc_service), + stopServiceAction, + ), ), - NotificationModel.Action( - ACTION_STOP_SERVICE, - getString(R.string.notification_action_stop_acc_service), + ) + } + + private fun mappingsResumedNotification(): NotificationModel { + // Since Notification trampolines are no longer allowed, the notification + // must directly launch the accessibility settings instead of relaying the request + // through a broadcast receiver that eventually calls the ServiceAdapter. + val stopServiceAction = if (controlAccessibilityService.isUserInteractionRequired()) { + NotificationIntentType.Activity(Settings.ACTION_ACCESSIBILITY_SETTINGS) + } else { + NotificationIntentType.Broadcast(ACTION_STOP_SERVICE) + } + + return NotificationModel( + id = ID_TOGGLE_MAPPINGS, + channel = CHANNEL_TOGGLE_KEYMAPS, + title = getString(R.string.notification_keymaps_resumed_title), + text = getString(R.string.notification_keymaps_resumed_text), + icon = R.drawable.ic_notification_pause, + onClickAction = NotificationIntentType.MainActivity(), + showOnLockscreen = true, + onGoing = true, + priority = NotificationCompat.PRIORITY_MIN, + actions = listOf( + NotificationModel.Action( + getString(R.string.notification_action_pause), + NotificationIntentType.Broadcast(ACTION_PAUSE_MAPPINGS), + ), + NotificationModel.Action( + getString(R.string.notification_action_dismiss), + NotificationIntentType.Broadcast(ACTION_DISMISS_TOGGLE_MAPPINGS), + ), + NotificationModel.Action( + getString(R.string.notification_action_stop_acc_service), + stopServiceAction, + ), ), - ), - ) + ) + } - private fun accessibilityServiceDisabledNotification(): NotificationModel = NotificationModel( - id = ID_TOGGLE_MAPPINGS, - channel = CHANNEL_TOGGLE_KEYMAPS, - title = getString(R.string.notification_accessibility_service_disabled_title), - text = getString(R.string.notification_accessibility_service_disabled_text), - icon = R.drawable.ic_notification_pause, - onClickActionId = ACTION_START_SERVICE, - showOnLockscreen = true, - onGoing = true, - priority = NotificationCompat.PRIORITY_MIN, - actions = listOf( - NotificationModel.Action( - ACTION_DISMISS_TOGGLE_MAPPINGS, - getString(R.string.notification_action_dismiss), + private fun accessibilityServiceDisabledNotification(): NotificationModel { + // Since Notification trampolines are no longer allowed, the notification + // must directly launch the accessibility settings instead of relaying the request + // through a broadcast receiver that eventually calls the ServiceAdapter. + val onClickAction = if (controlAccessibilityService.isUserInteractionRequired()) { + NotificationIntentType.Activity(Settings.ACTION_ACCESSIBILITY_SETTINGS) + } else { + NotificationIntentType.Broadcast(ACTION_START_SERVICE) + } + + return NotificationModel( + id = ID_TOGGLE_MAPPINGS, + channel = CHANNEL_TOGGLE_KEYMAPS, + title = getString(R.string.notification_accessibility_service_disabled_title), + text = getString(R.string.notification_accessibility_service_disabled_text), + icon = R.drawable.ic_notification_pause, + onClickAction = onClickAction, + showOnLockscreen = true, + onGoing = true, + priority = NotificationCompat.PRIORITY_MIN, + actions = listOf( + NotificationModel.Action( + getString(R.string.notification_action_dismiss), + NotificationIntentType.Broadcast(ACTION_DISMISS_TOGGLE_MAPPINGS), + ), ), - ), - ) + ) + } - private fun accessibilityServiceCrashedNotification(): NotificationModel = NotificationModel( - id = ID_TOGGLE_MAPPINGS, - channel = CHANNEL_TOGGLE_KEYMAPS, - title = getString(R.string.notification_accessibility_service_crashed_title), - text = getString(R.string.notification_accessibility_service_crashed_text), - icon = R.drawable.ic_notification_pause, - onClickActionId = ACTION_RESTART_SERVICE, - showOnLockscreen = true, - onGoing = true, - priority = NotificationCompat.PRIORITY_MIN, - bigTextStyle = true, - actions = listOf( - NotificationModel.Action( - ACTION_RESTART_SERVICE, - getString(R.string.notification_action_restart_accessibility_service), + private fun accessibilityServiceCrashedNotification(): NotificationModel { + // Since Notification trampolines are no longer allowed, the notification + // must directly launch the accessibility settings instead of relaying the request + // through a broadcast receiver that eventually calls the ServiceAdapter. + val onClickAction = if (controlAccessibilityService.isUserInteractionRequired()) { + NotificationIntentType.Activity(Settings.ACTION_ACCESSIBILITY_SETTINGS) + } else { + NotificationIntentType.Broadcast(ACTION_RESTART_SERVICE) + } + + return NotificationModel( + id = ID_TOGGLE_MAPPINGS, + channel = CHANNEL_TOGGLE_KEYMAPS, + title = getString(R.string.notification_accessibility_service_crashed_title), + text = getString(R.string.notification_accessibility_service_crashed_text), + icon = R.drawable.ic_notification_pause, + onClickAction = onClickAction, + showOnLockscreen = true, + onGoing = true, + priority = NotificationCompat.PRIORITY_MIN, + bigTextStyle = true, + actions = listOf( + NotificationModel.Action( + getString(R.string.notification_action_restart_accessibility_service), + onClickAction, + ), ), - ), - ) + ) + } private fun imePickerNotification(): NotificationModel = NotificationModel( id = ID_IME_PICKER, @@ -393,7 +403,7 @@ class NotificationController( title = getString(R.string.notification_ime_persistent_title), text = getString(R.string.notification_ime_persistent_text), icon = R.drawable.ic_notification_keyboard, - onClickActionId = ACTION_SHOW_IME_PICKER, + onClickAction = NotificationIntentType.Broadcast(ACTION_SHOW_IME_PICKER), showOnLockscreen = false, onGoing = true, priority = NotificationCompat.PRIORITY_MIN, @@ -405,56 +415,40 @@ class NotificationController( title = getString(R.string.notification_toggle_keyboard_title), text = getString(R.string.notification_toggle_keyboard_text), icon = R.drawable.ic_notification_keyboard, - onClickActionId = null, showOnLockscreen = true, onGoing = true, priority = NotificationCompat.PRIORITY_MIN, actions = listOf( NotificationModel.Action( - ACTION_TOGGLE_KEYBOARD, getString(R.string.notification_toggle_keyboard_action), + intentType = NotificationIntentType.Broadcast(ACTION_TOGGLE_KEYBOARD), ), ), ) - private fun fingerprintFeatureNotification(): NotificationModel = NotificationModel( - id = ID_FEATURE_REMAP_FINGERPRINT_GESTURES, - channel = CHANNEL_NEW_FEATURES, - title = getString(R.string.notification_feature_fingerprint_title), - text = getString(R.string.notification_feature_fingerprint_text), - icon = R.drawable.ic_notification_fingerprint, - onClickActionId = ACTION_FINGERPRINT_GESTURE_FEATURE, - priority = NotificationCompat.PRIORITY_LOW, - autoCancel = true, - onGoing = false, - showOnLockscreen = false, - bigTextStyle = true, - ) - - private fun setupChosenDevicesSettingsAgainNotification(): NotificationModel = - NotificationModel( - id = ID_SETUP_CHOSEN_DEVICES_AGAIN, - channel = CHANNEL_NEW_FEATURES, - title = getString(R.string.notification_setup_chosen_devices_again_title), - text = getString(R.string.notification_setup_chosen_devices_again_text), - icon = R.drawable.ic_notification_settings, - onClickActionId = ACTION_ON_SETUP_CHOSEN_DEVICES_AGAIN, - priority = NotificationCompat.PRIORITY_LOW, - autoCancel = true, - onGoing = false, - showOnLockscreen = false, - bigTextStyle = true, - ) - private fun keyboardHiddenNotification(): NotificationModel = NotificationModel( id = ID_KEYBOARD_HIDDEN, channel = CHANNEL_KEYBOARD_HIDDEN, title = getString(R.string.notification_keyboard_hidden_title), text = getString(R.string.notification_keyboard_hidden_text), icon = R.drawable.ic_notification_keyboard_hide, - onClickActionId = ACTION_SHOW_KEYBOARD, + onClickAction = NotificationIntentType.Broadcast(ACTION_SHOW_KEYBOARD), showOnLockscreen = false, onGoing = true, priority = NotificationCompat.PRIORITY_LOW, ) + + private fun assistantTriggerFeatureNotification(): NotificationModel = NotificationModel( + id = ID_FEATURE_ASSISTANT_TRIGGER, + channel = CHANNEL_NEW_FEATURES, + title = getString(R.string.notification_assistant_trigger_feature_title), + text = getString(R.string.notification_assistant_trigger_feature_text), + icon = R.drawable.ic_outline_assistant_24, + onClickAction = NotificationIntentType.MainActivity(BaseMainActivity.ACTION_USE_ASSISTANT_TRIGGER), + priority = NotificationCompat.PRIORITY_LOW, + autoCancel = true, + onGoing = false, + showOnLockscreen = false, + bigTextStyle = true, + ) } diff --git a/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationModel.kt b/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationModel.kt index 2a7c627aea..f7e72a1406 100644 --- a/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationModel.kt @@ -12,10 +12,9 @@ data class NotificationModel( val text: String, @DrawableRes val icon: Int, /** - * The id to send back to the notification id when the notification is tapped or null if nothing - * should happen when the notification is tapped. + * Null if nothing should happen when the notification is tapped. */ - val onClickActionId: String?, + val onClickAction: NotificationIntentType? = null, val showOnLockscreen: Boolean, val onGoing: Boolean, val priority: Int, @@ -23,6 +22,24 @@ data class NotificationModel( val autoCancel: Boolean = false, val bigTextStyle: Boolean = false, ) { + data class Action(val text: String, val intentType: NotificationIntentType) +} + +/** + * Due to restrictions on notification trampolines in Android 12+ you can't launch + * activities from a broadcast receiver in response to a notification action. + */ +sealed class NotificationIntentType { + /** + * Broadcast an intent to the NotificationReceiver. + */ + data class Broadcast(val action: String) : NotificationIntentType() + + /** + * Launch the main activity with the specified action in the intent. If it is null + * then it will just launch the activity without a custom action. + */ + data class MainActivity(val customIntentAction: String? = null) : NotificationIntentType() - data class Action(val id: String, val text: String) + data class Activity(val action: String) : NotificationIntentType() } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/VersionHelper.kt b/app/src/main/java/io/github/sds100/keymapper/util/VersionHelper.kt index 456ef74df2..9f0fd61c77 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/VersionHelper.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/VersionHelper.kt @@ -11,4 +11,9 @@ object VersionHelper { * availability of fingerprint gestures. */ const val FINGERPRINT_GESTURES_MIN_VERSION = 40 + + /** + * This is version 2.7.0 when the assistant trigger was first introduced. + */ + const val ASSISTANT_TRIGGER_MIN_VERSION = 66 } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt index d18cd344e8..d6efa8c902 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavDestination.kt @@ -94,6 +94,6 @@ sealed class NavDestination { object FixAppKilling : NavDestination() object Settings : NavDestination() object About : NavDestination() - data class ConfigKeyMap(val keyMapUid: String?) : NavDestination() + data class ConfigKeyMap(val keyMapUid: String?, val showAdvancedTriggers: Boolean = false) : NavDestination() data class ConfigFingerprintMap(val id: FingerprintMapId) : NavDestination() } diff --git a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt index 23167aab10..ac26bc8fa4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/util/ui/NavigationViewModel.kt @@ -43,7 +43,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.runBlocking -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -215,7 +214,10 @@ fun NavigationViewModel.setupNavigation(fragment: Fragment) { NavAppDirections.actionToConfigFingerprintMap(destination.id.toString()) is NavDestination.ConfigKeyMap -> - NavAppDirections.actionToConfigKeymap(destination.keyMapUid) + NavAppDirections.actionToConfigKeymap( + destination.keyMapUid, + showAdvancedTriggers = destination.showAdvancedTriggers, + ) } fragment.findNavController().navigate(direction) diff --git a/app/src/main/res/navigation/nav_app.xml b/app/src/main/res/navigation/nav_app.xml index 5fa5173b87..c1d17bd810 100644 --- a/app/src/main/res/navigation/nav_app.xml +++ b/app/src/main/res/navigation/nav_app.xml @@ -36,6 +36,10 @@ android:defaultValue="@null" app:argType="string" app:nullable="true" /> + + + + android:label="TriggerKeyOptionsFragment" /> - + android:label="KeymapActionOptionsFragment" /> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 825e8a7950..5ff6871c3c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1390,6 +1390,8 @@ Key Mapper must be the default assistant. You must purchase the assistant trigger feature! Learn more + New trigger! Did you know you can remap your digital assistant? + Instead of launching Google Assistant or Bixby, you can perform an action of your choice. Tap to learn more. Unlock (%s) diff --git a/app/src/test/java/io/github/sds100/keymapper/NotificationControllerTest.kt b/app/src/test/java/io/github/sds100/keymapper/NotificationControllerTest.kt deleted file mode 100644 index 1d5f11890f..0000000000 --- a/app/src/test/java/io/github/sds100/keymapper/NotificationControllerTest.kt +++ /dev/null @@ -1,141 +0,0 @@ -package io.github.sds100.keymapper - -import androidx.core.app.NotificationCompat -import io.github.sds100.keymapper.onboarding.FakeOnboardingUseCase -import io.github.sds100.keymapper.system.accessibility.ServiceState -import io.github.sds100.keymapper.system.notifications.ManageNotificationsUseCase -import io.github.sds100.keymapper.system.notifications.NotificationController -import io.github.sds100.keymapper.system.notifications.NotificationModel -import io.github.sds100.keymapper.util.ui.ResourceProvider -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.`is` -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.junit.MockitoJUnitRunner -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever - -/** - * Created by sds100 on 25/04/2021. - */ - -@ExperimentalCoroutinesApi -@RunWith(MockitoJUnitRunner::class) -class NotificationControllerTest { - - private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private lateinit var controller: NotificationController - private lateinit var mockManageNotifications: ManageNotificationsUseCase - private lateinit var mockResourceProvider: ResourceProvider - private lateinit var fakeOnboarding: FakeOnboardingUseCase - - private lateinit var onActionClick: MutableSharedFlow - - @Before - fun init() { - onActionClick = MutableSharedFlow() - - mockManageNotifications = mock { - on { onActionClick }.then { onActionClick } - on { showToggleMappingsNotification }.then { flow { } } - on { showImePickerNotification }.then { flow { } } - } - - mockResourceProvider = mock() - fakeOnboarding = FakeOnboardingUseCase() - - controller = NotificationController( - testScope, - mockManageNotifications, - pauseMappings = mock { - on { isPaused }.then { flow {} } - }, - showImePicker = mock(), - controlAccessibilityService = mock { - on { serviceState }.then { flow {} } - }, - toggleCompatibleIme = mock { - on { sufficientPermissions }.then { flow {} } - }, - hideInputMethod = mock { - on { onHiddenChange }.then { flow {} } - }, - areFingerprintGesturesSupported = mock { - on { isSupported }.then { flow {} } - }, - onboardingUseCase = fakeOnboarding, - resourceProvider = mockResourceProvider, - dispatchers = TestDispatcherProvider(testDispatcher), - ) - } - - @Test - fun `click setup chosen devices notification, open app and approve`() = - runTest(testDispatcher) { - val value = async { - controller.openApp.first() - } - - onActionClick.emit(NotificationController.ACTION_ON_SETUP_CHOSEN_DEVICES_AGAIN) - - assertThat(value.await(), `is`("")) - assertThat(fakeOnboarding.approvedSetupChosenDevicesAgainNotification, `is`(true)) - } - - @Test - fun `show setup chosen devices notification`() = - runTest(testDispatcher) { - // GIVEN - val title = "title" - val text = "text" - whenever(mockResourceProvider.getString(R.string.notification_setup_chosen_devices_again_title)).then { title } - whenever(mockResourceProvider.getString(R.string.notification_setup_chosen_devices_again_text)).then { text } - - // WHEN - fakeOnboarding.showSetupChosenDevicesAgainNotification.value = true - - // THEN - verify( - mockResourceProvider, - times(1), - ).getString(R.string.notification_setup_chosen_devices_again_title) - - verify( - mockResourceProvider, - times(1), - ).getString(R.string.notification_setup_chosen_devices_again_text) - - val expectedNotification = NotificationModel( - id = NotificationController.ID_SETUP_CHOSEN_DEVICES_AGAIN, - channel = NotificationController.CHANNEL_NEW_FEATURES, - icon = R.drawable.ic_notification_settings, - title = title, - text = text, - onClickActionId = NotificationController.ACTION_ON_SETUP_CHOSEN_DEVICES_AGAIN, - showOnLockscreen = false, - onGoing = false, - priority = NotificationCompat.PRIORITY_LOW, - actions = emptyList(), - autoCancel = true, - bigTextStyle = true, - ) - - verify(mockManageNotifications, times(1)).show(expectedNotification) - - // this should be called when the notification is clicked - assertThat(fakeOnboarding.approvedSetupChosenDevicesAgainNotification, `is`(false)) - } -} diff --git a/app/src/test/java/io/github/sds100/keymapper/onboarding/FakeOnboardingUseCase.kt b/app/src/test/java/io/github/sds100/keymapper/onboarding/FakeOnboardingUseCase.kt index accc847383..2cad21b4a9 100644 --- a/app/src/test/java/io/github/sds100/keymapper/onboarding/FakeOnboardingUseCase.kt +++ b/app/src/test/java/io/github/sds100/keymapper/onboarding/FakeOnboardingUseCase.kt @@ -26,22 +26,13 @@ class FakeOnboardingUseCase : OnboardingUseCase { override fun neverShowGuiKeyboardPromptsAgain() {} - override var approvedFingerprintFeaturePrompt: Boolean = false override var shownParallelTriggerOrderExplanation: Boolean = false override var shownSequenceTriggerExplanation: Boolean = false - override val showFingerprintFeatureNotificationIfAvailable = MutableStateFlow(false) + override val showAssistantTriggerFeatureNotification: Flow = MutableStateFlow(false) - override fun showedFingerprintFeatureNotificationIfAvailable() {} + override fun showedAssistantTriggerFeatureNotification() {} - override val showSetupChosenDevicesAgainNotification = MutableStateFlow(false) - - override fun approvedSetupChosenDevicesAgainNotification() { - approvedSetupChosenDevicesAgainNotification = true - } - - override val showSetupChosenDevicesAgainAppIntro = MutableStateFlow(false) - - override fun approvedSetupChosenDevicesAgainAppIntro() {} + override var approvedAssistantTriggerFeaturePrompt: Boolean = false override val showWhatsNew = MutableStateFlow(false) From a641f91c3561cbcb43bc0dbeb384b6609a543ccf Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 4 Dec 2024 21:46:08 +0100 Subject: [PATCH 107/118] fix: do not fail gradle sync if local.properties doesn't exist for the revenue cat API key --- app/build.gradle | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7fcf70b5bf..c901829a9e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -96,9 +96,13 @@ android { pro { dimension "pro" - def localProperties = new Properties() - localProperties.load(new FileInputStream(rootProject.file("local.properties"))) - buildConfigField("String", "REVENUECAT_API_KEY", localProperties["REVENUECAT_API_KEY"]) + File file = rootProject.file("local.properties") + + if (file.exists()) { + def localProperties = new Properties() + localProperties.load(new FileInputStream(file)) + buildConfigField("String", "REVENUECAT_API_KEY", localProperties["REVENUECAT_API_KEY"]) + } } } From 70acd6238f4d5c4ca3494c27ff3533cf8d64760f Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 4 Dec 2024 21:58:54 +0100 Subject: [PATCH 108/118] #1274 chore: assemble free CI build when creating testing builds and pro builds when releasing to production --- fastlane/Fastfile | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 14c04c8693..ddf5ef0a2b 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -19,7 +19,7 @@ default_platform(:android) desc "Create testing release" lane :testing do - gradle(task: "clean assembleCi") + gradle(task: "clean assembleFreeCi") end desc "Create and deploy production release" @@ -30,9 +30,10 @@ lane :prod do |options| whats_new = File.read("../app/src/main/assets/whats-new.txt") File.write("metadata/android/en-US/changelogs/" + version_code + ".txt", whats_new) - gradle(task: "assembleDebug") - gradle(task: "assembleRelease") - gradle(task: "bundleRelease") +# Do not release a debug build for pro version. +# gradle(task: "assembleDebug") + gradle(task: "assembleProRelease") + gradle(task: "bundleProRelease") apk_path_debug="app/build/outputs/apk/debug/keymapper-" + version_name + "-debug.apk" apk_path_release="app/build/outputs/apk/release/keymapper-" + version_name + ".apk" From 7f630c26dfa0f0f0386021d10b6f26b570f4f27e Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 4 Dec 2024 22:00:47 +0100 Subject: [PATCH 109/118] fix: remove unused home_tab_titles string array in polish strings --- app/src/main/res/values-pt/strings.xml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 606dad7480..c272a09250 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -176,10 +176,6 @@ Ações Gatilho Digital - - @string/tab_keyevents - @string/tab_fingerprint - From 27273537cc065cd9cc1411377e46a1511e5616a5 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 4 Dec 2024 21:13:35 +0000 Subject: [PATCH 110/118] New Crowdin translations by GitHub Action --- app/src/main/res/values-ar/strings.xml | 2 ++ app/src/main/res/values-cs/strings.xml | 3 ++- app/src/main/res/values-de/strings.xml | 2 ++ app/src/main/res/values-es/strings.xml | 4 ++-- app/src/main/res/values-fr/strings.xml | 2 ++ app/src/main/res/values-hu/strings.xml | 2 ++ app/src/main/res/values-ka/strings.xml | 2 ++ app/src/main/res/values-ko/strings.xml | 2 ++ app/src/main/res/values-pl/strings.xml | 4 ++-- app/src/main/res/values-pt/strings.xml | 3 ++- app/src/main/res/values-ru/strings.xml | 3 ++- app/src/main/res/values-sk/strings.xml | 3 ++- app/src/main/res/values-tr/strings.xml | 2 ++ app/src/main/res/values-uk/strings.xml | 2 ++ app/src/main/res/values-vi/strings.xml | 4 ++-- app/src/main/res/values-zh-rCN/strings.xml | 3 ++- app/src/main/res/values-zh-rTW/strings.xml | 2 ++ fastlane/metadata/android/ar/full_description.txt | 10 ++++------ fastlane/metadata/android/cs_CZ/full_description.txt | 10 ++++------ fastlane/metadata/android/de_DE/full_description.txt | 10 ++++------ fastlane/metadata/android/es_ES/full_description.txt | 10 ++++------ fastlane/metadata/android/fr_FR/full_description.txt | 10 ++++------ fastlane/metadata/android/hu_HU/full_description.txt | 10 ++++------ fastlane/metadata/android/ka_GE/full_description.txt | 10 ++++------ fastlane/metadata/android/ko_KR/full_description.txt | 10 ++++------ fastlane/metadata/android/pl_PL/full_description.txt | 10 ++++------ fastlane/metadata/android/pt_BR/full_description.txt | 10 ++++------ fastlane/metadata/android/ru_RU/full_description.txt | 10 ++++------ fastlane/metadata/android/sk/full_description.txt | 10 ++++------ fastlane/metadata/android/tr_TR/full_description.txt | 10 ++++------ fastlane/metadata/android/uk_UA/full_description.txt | 10 ++++------ fastlane/metadata/android/vi_VN/full_description.txt | 10 ++++------ fastlane/metadata/android/zh_CN/full_description.txt | 10 ++++------ fastlane/metadata/android/zh_TW/full_description.txt | 10 ++++------ 34 files changed, 102 insertions(+), 113 deletions(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index d6e7f7cd6a..e9d1219a98 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -64,4 +64,6 @@ + + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7cf53a6497..92be005509 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -7,7 +7,6 @@ Povolit Otevřít ¯\\_(ツ)_/¯\n\nTady nic není! - Nahrajte spoušť! Přidat akci! ¯\\_(ツ)_/¯\n\nVytvořte klíčovou mapu! ¯\\_(ツ)_/¯\n\nNevybrali jste žádné akce pro tohoto zástupce! @@ -1011,4 +1010,6 @@ Pokud by měla být spárována nějaká WiFi síť, nechte ji prázdnou.Překladatel (česky) Překladač (španělština) + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 8b2b1737b0..aef0a460ed 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -63,4 +63,6 @@ + + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 250922d5b4..370ef81eb7 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -7,7 +7,6 @@ Activar Abrir ¯\\_(ツ)_/¯\n\nNada por aquí! - ¡Graba un disparador! ¡Añadir una acción! ¯\\_(ツ)_/¯\n\n¡Crea un mapeado! ¯\\_(ツ)_/¯\n\n¡No has escogido acciones para este atajo! @@ -161,7 +160,6 @@ Acciones Activador Huella digital - @@ -1031,4 +1029,6 @@ Traductor (Checo) Traductor (Español) + + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8b2b1737b0..aef0a460ed 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -63,4 +63,6 @@ + + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 87efd24782..39afe2afc1 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -64,4 +64,6 @@ + + diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 8b2b1737b0..aef0a460ed 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -63,4 +63,6 @@ + + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 8b2b1737b0..aef0a460ed 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -63,4 +63,6 @@ + + diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 53ed08557e..12cf32e91b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -7,7 +7,6 @@ Włącz Otwórz ¯\\_(ツ)_/¯\n\nNic tu nie ma! - Wprowadź wyzwalacz! Dodaj działanie! ¯\\_(ツ)_/¯\n\nUtwórz mapę klawiszy! ¯\\_(ツ)_/¯\n\nNie wybrano żadnych działań dla tego skrótu! @@ -176,7 +175,6 @@ Działania Wyzwalacz Odcisk palca - @@ -1071,4 +1069,6 @@ Tłumacz (czeski) Tłumacz (hiszpański) + + diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index c272a09250..c7dc6c31cd 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -7,7 +7,6 @@ Habilitar Abrir ¯\\_(ツ)_/¯\n\nNada encontrado! - Grave um gatilho! Adicionar ação! ¯\\_(ツ)_/¯\n\nCriar mapeamento! ¯\\_(ツ)_/¯\n\nNenhuma ação para este atalho! @@ -1069,4 +1068,6 @@ Tradutor (Checo) Tradutor (Espanhol) + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1b8166dc29..4d55cb73e7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -7,7 +7,6 @@ Включить Открыть ¯\\_(ツ)_/¯\n\n Здесь ничего! - Запишите событие! Добавьте действие! ¯\\_(ツ)_/¯\n\nСоздайте мэппинг! ¯\\_(ツ)_/¯\n\nВы не выбрали никаких действий для этого ярлыка! @@ -1031,4 +1030,6 @@ Переводчик (Чешский) Переводчик (Испанский) + + diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 7b4f7cc9f8..083daab72b 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -5,7 +5,6 @@ Zapnúť Otvoriť ¯\\_(ツ)_/¯\n\nNič tu nie je! - Nahrať spúšť! Pridať akciu! ̄\\_(ツ)_/ ̄\n\nVytvoriť mapu klávesov! ̄\\_(ツ)_/ ̄\n\nNevybrali ste pre tento odkaz žiadne akcie! @@ -867,4 +866,6 @@ Discord Licencia + + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 8b2b1737b0..aef0a460ed 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -63,4 +63,6 @@ + + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8b2b1737b0..aef0a460ed 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -63,4 +63,6 @@ + + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 35f554759e..bc2e1744cb 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -7,7 +7,6 @@ Cho phép Mở ¯\\_(ツ)_/¯\n\nKhông có gì ở đây! - Ghi thao tác kích hoạt! Thêm hành động! ¯\\_(ツ)_/¯\n\n Tạo một hành động! ¯\\_(ツ)_/¯\n\nBạn chưa chọn bất kỳ hành động nào cho phím tắt này! @@ -172,7 +171,6 @@ Hành động Kích hoạt Dấu vân tay - @@ -1049,4 +1047,6 @@ Người phiên dịch (tiếng Séc) Phiên dịch viên (tiếng Tây Ban Nha) + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 4aeb221369..eb53688fe5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -7,7 +7,6 @@ 启用 打开 ¯\\_(ツ)_/¯\n\n这里什么都没有! - 录制触发器! 添加动作! ¯\\_(ツ)_/¯\n\n创建一个按键映射! ¯\\_(ツ)_/¯\n\n你没有为此快捷方式选择任何动作! @@ -1070,4 +1069,6 @@ 译者(捷克语) 译者(西班牙语) + + diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8b2b1737b0..aef0a460ed 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -63,4 +63,6 @@ + + diff --git a/fastlane/metadata/android/ar/full_description.txt b/fastlane/metadata/android/ar/full_description.txt index aba234fb7b..fcbde9987d 100644 --- a/fastlane/metadata/android/ar/full_description.txt +++ b/fastlane/metadata/android/ar/full_description.txt @@ -1,9 +1,9 @@ ما الذي يمكن إعادة تعيينه؟ - * إيماءات بصمات الأصابع على الأجهزة المدعومة. * أزرار الصوت. - * أزرار التنقل. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * بلوتوث / لوحات المفاتيح السلكية. + * إيماءات بصمات الأصابع على الأجهزة المدعومة. * يجب أن تعمل الأزرار الموجودة على الأجهزة المتصلة الأخرى أيضا. يمكن إعادة تعيين أزرار الأجهزة فقط. @@ -12,12 +12,10 @@ يمكنك الجمع بين مفاتيح متعددة من جهاز معين أو أي جهاز لتشكيل "المشغل". يمكن أن يكون لكل مشغل إجراءات متعددة. يمكن ضبط المفاتيح للضغط عليها في نفس الوقت أو واحدة تلو الأخرى في تسلسل. يمكن إعادة تعيين المفاتيح عند الضغط عليها لفترة قصيرة أو الضغط عليها لفترة طويلة أو الضغط عليها مرتين. يمكن أن تحتوي خريطة المفاتيح على مجموعة من "القيود" بحيث يكون لها التأثير فقط في مواقف معينة. ما الذي لا يمكن إعادة تعيينه؟ - * زر الطاقة - * زر بيكسبي * أزرار الماوس * * دباد ، عصي الإبهام أو المشغلات على أجهزة التحكم في اللعبة -لا تعمل خرائط المفاتيح إذا كانت الشاشة متوقفة عن التشغيل. هذا مقيد في Android. لا يوجد شيء يمكن للمطور القيام به حيال ذلك. +Your key maps do not work if the screen is OFF. هذا مقيد في Android. لا يوجد شيء يمكن للمطور القيام به حيال ذلك. ما الذي يمكنني لإعادة تعيين مفاتيحي للقيام به؟ ستعمل بعض الإجراءات فقط على الأجهزة التي تم عمل روت لها وإصدارات Android محددة. @@ -27,7 +25,7 @@ الصلاحيات لا يلزمك منح جميع الأذونات حتى يعمل التطبيق. سيخبرك التطبيق ما إذا كان يجب منح إذن حتى تعمل الميزة. - * خدمة إمكانية الوصول: الشرط الأساسي لإعادة التعيين لأجل العمل. هناك حاجة إليه حتى يتمكن التطبيق من الاستماع إلى الأحداث الرئيسية وحظرها. + * خدمة إمكانية الوصول: الشرط الأساسي لإعادة التعيين لأجل العمل. It is needed so the app can listen to and block key events. * مسؤول الجهاز: لإيقاف تشغيل الشاشة عند استخدام الإجراء لإيقاف تشغيل الشاشة. * تعديل إعدادات النظام: لتغيير إعدادات السطوع والدوران. * الكاميرا: للتحكم في الكشاف. diff --git a/fastlane/metadata/android/cs_CZ/full_description.txt b/fastlane/metadata/android/cs_CZ/full_description.txt index 6591796b18..8f81c40a35 100644 --- a/fastlane/metadata/android/cs_CZ/full_description.txt +++ b/fastlane/metadata/android/cs_CZ/full_description.txt @@ -1,9 +1,9 @@ Co lze přemapovat? - * Gesta otisků prstů na podporovaných zařízeních. * Tlačítka hlasitosti. - * Navigační tlačítka. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Bluetooth/kabelová klávesnice. + * Gesta otisků prstů na podporovaných zařízeních. * Tlačítka na ostatních připojených zařízeních by měla také fungovat. POUZE HARDWARE tlačítka mohou být znovu mapována. @@ -12,12 +12,10 @@ POUZE HARDWARE tlačítka mohou být znovu mapována. Můžete kombinovat více klíčů z konkrétního zařízení nebo jakéhokoli zařízení a vytvořit "trigger". Každý spouštěč může mít více akcí. Klávesy lze nastavit tak, aby byly stisknuty současně nebo jednou za sebou. Tlačítka mohou být zmapována po krátkém stisknutí, dlouhém stisknutí nebo dvojitém stisknutí. Klíčová mapa může mít sadu "vazeb", takže má vliv pouze v určitých situacích. Co nelze přemapovat? - * Tlačítko napájení - * Tlačítko Bixby * Tlačítka myši * Dpad, paličky nebo spouštěče na herních ovladačích -Vaše klíčové mapy nefungují, pokud je obrazovka vypnutá. Toto je omezení v Android. Není nic, co by vývojář nemohl udělat. +Your key maps do not work if the screen is OFF. Toto je omezení v Android. Není nic, co by vývojář nemohl udělat. Co mohu přemapovat své klíče? Některé akce budou fungovat pouze na rootovaných zařízeních a konkrétních verzích Androidu. @@ -27,7 +25,7 @@ Existuje příliš mnoho funkcí, které zde můžete vypsaovat, takže se podí Skupiny uživatelů a dokumentů Není nutné udělovat všechna oprávnění pro fungování aplikace. Aplikace vám řekne, zda má být uděleno oprávnění pro funkčnost funkce. - * Služba přístupu: Základní požadavek pro přemapování do práce. Je to potřeba, aby aplikace mohla poslouchat a blokovat klíčové události. + * Služba přístupu: Základní požadavek pro přemapování do práce. It is needed so the app can listen to and block key events. * Admin zařízení: Při použití akce vypněte obrazovku. * Upravit nastavení systému: Chcete-li změnit nastavení jasu a rotace. * Fotoaparát: Pro ovládání svítilny. diff --git a/fastlane/metadata/android/de_DE/full_description.txt b/fastlane/metadata/android/de_DE/full_description.txt index 515ccecd74..13f13d37dd 100644 --- a/fastlane/metadata/android/de_DE/full_description.txt +++ b/fastlane/metadata/android/de_DE/full_description.txt @@ -1,9 +1,9 @@ What can be remapped? - * Fingerprint gestures on supported devices. * Volume buttons. - * Navigation buttons. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Bluetooth/wired keyboards. + * Fingerprint gestures on supported devices. * Buttons on other connected devices should also work. ONLY HARDWARE buttons can be remapped. @@ -12,12 +12,10 @@ There is NO GUARANTEE any of these buttons will work and this app is NOT designe You can combine multiple keys from a specific device or any device to form a "trigger". Each trigger can have multiple actions. The keys can be set to be pressed at the same time or one after another in a sequence. Keys can be remapped when they are short pressed, long pressed or double pressed. A keymap can have a set of "constraints" so it only has an effect in certain situations. What can’t be remapped? - * Power button - * Bixby button * Mouse buttons * Dpad, thumb sticks or triggers on game controllers -Your key maps don't work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. +Your key maps do not work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. What can I remap my keys to do? Some actions will only work on rooted devices and specific Android versions. @@ -27,7 +25,7 @@ There are too many features to list here so check out the full list here: https: Permissions You don't have to grant all the permissions for the app to work. The app will tell you if a permission needs to be granted for a feature to work. - * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block keyevents. + * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block key events. * Device Admin: To turn the screen off when using the action to turn off the screen. * Modify System Settings: To change the brightness and rotation settings. * Camera: To control the flashlight. diff --git a/fastlane/metadata/android/es_ES/full_description.txt b/fastlane/metadata/android/es_ES/full_description.txt index 40b79cf500..5181a46cc0 100644 --- a/fastlane/metadata/android/es_ES/full_description.txt +++ b/fastlane/metadata/android/es_ES/full_description.txt @@ -1,9 +1,9 @@ ¿Qué se puede reasignar? - * Gestos con la huella dactilar en los dispositivos compatibles. * Botones de volumen. - * Botones de navegación. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Teclados Bluetooth/con cable. + * Gestos con la huella dactilar en los dispositivos compatibles. * Los botones de otros dispositivos conectados también deberían funcionar. Sólo se pueden reasignar los botones de HARDWARE. @@ -12,12 +12,10 @@ No hay garantía de que ninguno de estos botones funcione y esta aplicación NO Puedes combinar varias teclas de un dispositivo específico o de cualquier dispositivo para formar un "activador". Cada activador puede tener múltiples acciones. Las teclas pueden configurarse para ser pulsadas al mismo tiempo o una tras otra en una secuencia. Las teclas se pueden reasignar cuando se presionan brevemente, se presionan prolongadamente o se presionan dos veces. Un mapa de teclas puede tener un conjunto de "restricciones" para que sólo tenga efecto en determinadas situaciones. ¿Qué no es posible remapear? - * Botón de encendido - * Botón Bixby * Botones del ratón * Dpad, thumb sticks o gatillos en los mandos de los juegos -Sus mapas de teclas no funcionan si la pantalla está apagada. Esta es una limitación de Android. No hay nada que pueda hacer el desarrollador. +Your key maps do not work if the screen is OFF. Esta es una limitación de Android. No hay nada que pueda hacer el desarrollador. ¿Qué puedo hacer con mis teclas? Algunas acciones sólo funcionarán en dispositivos rooteados y en versiones específicas de Android. @@ -27,7 +25,7 @@ Hay demasiadas características para enumerarlas, así que echa un vistazo a la Permisos No tienes que conceder todos los permisos para que la aplicación funcione. La aplicación te dirá si es necesario conceder un permiso para que una característica funcione. - * Servicio de accesibilidad: Requisito básico para que funcione la reasignación. Es necesario para que la aplicación pueda escuchar y bloquear los keyyevents. + * Servicio de accesibilidad: Requisito básico para que funcione la reasignación. It is needed so the app can listen to and block key events. * Administración del dispositivo: Para apagar la pantalla cuando se utiliza la acción de apagar la pantalla. * Modificar los ajustes del sistema: Para cambiar los ajustes de brillo y rotación. * Cámara: Para controlar la linterna. diff --git a/fastlane/metadata/android/fr_FR/full_description.txt b/fastlane/metadata/android/fr_FR/full_description.txt index 515ccecd74..13f13d37dd 100644 --- a/fastlane/metadata/android/fr_FR/full_description.txt +++ b/fastlane/metadata/android/fr_FR/full_description.txt @@ -1,9 +1,9 @@ What can be remapped? - * Fingerprint gestures on supported devices. * Volume buttons. - * Navigation buttons. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Bluetooth/wired keyboards. + * Fingerprint gestures on supported devices. * Buttons on other connected devices should also work. ONLY HARDWARE buttons can be remapped. @@ -12,12 +12,10 @@ There is NO GUARANTEE any of these buttons will work and this app is NOT designe You can combine multiple keys from a specific device or any device to form a "trigger". Each trigger can have multiple actions. The keys can be set to be pressed at the same time or one after another in a sequence. Keys can be remapped when they are short pressed, long pressed or double pressed. A keymap can have a set of "constraints" so it only has an effect in certain situations. What can’t be remapped? - * Power button - * Bixby button * Mouse buttons * Dpad, thumb sticks or triggers on game controllers -Your key maps don't work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. +Your key maps do not work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. What can I remap my keys to do? Some actions will only work on rooted devices and specific Android versions. @@ -27,7 +25,7 @@ There are too many features to list here so check out the full list here: https: Permissions You don't have to grant all the permissions for the app to work. The app will tell you if a permission needs to be granted for a feature to work. - * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block keyevents. + * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block key events. * Device Admin: To turn the screen off when using the action to turn off the screen. * Modify System Settings: To change the brightness and rotation settings. * Camera: To control the flashlight. diff --git a/fastlane/metadata/android/hu_HU/full_description.txt b/fastlane/metadata/android/hu_HU/full_description.txt index caee3e7d7d..8fd2ebd525 100644 --- a/fastlane/metadata/android/hu_HU/full_description.txt +++ b/fastlane/metadata/android/hu_HU/full_description.txt @@ -1,9 +1,9 @@ Mit lehet átállítani? - * Ujjlenyomat-gesztusok a támogatott eszközökön. * Hangerőszabályzó gombok. - * Navigációs gombok. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Bluetooth/vezetékes billentyűzeteket. + * Ujjlenyomat-gesztusok a támogatott eszközökön. * Más csatlakoztatott eszközök gombjainak is működniük kell. Kizárólag a HARDVERes gombokat lehet átképezni. @@ -12,12 +12,10 @@ NINCS GARANCIA, hogy bármelyik gomb működni fog, és ezt az alkalmazást NEM Egy adott eszköz vagy bármely eszköz több billentyűjét kombinálhatod aktiválásként. Minden egyes aktiváláshoz több művelet is tartozhat. A billentyűk egyszerre vagy egymás után, sorrendben nyomhatók le. A billentyűk rövid, hosszú vagy dupla megnyomás esetén átállíthatóak. A billentyű-térképnek lehet egy sor "korlátozása", így csak bizonyos helyzetekben van hatása. Mit nem lehet átállítani? - * Bekapcsoló gomb - * Bixby gomb * Egér gombok * Dpad, hüvelykujjas botok vagy ravaszok a játékvezérlőkön -A billentyűs térképek nem működnek, ha a képernyő ki van kapcsolva. Ez az Android egyik korlátozása. A fejlesztő semmit sem tehet ez ellen. +Your key maps do not work if the screen is OFF. Ez az Android egyik korlátozása. A fejlesztő semmit sem tehet ez ellen. Mit tudok a billentyűimnek beállítani? Egyes műveletek csak rootolt eszközökön és bizonyos Android-verziókon működnek. @@ -27,7 +25,7 @@ Túl sok funkció van ahhoz, hogy itt felsoroljuk, ezért a teljes listát itt t Engedélyek Nem kell minden engedélyt megadnod ahhoz, hogy az alkalmazás működjön. Az alkalmazás megmondja, ha egy funkció működéséhez engedélyt kell adni. - * Akadálymentesítési szolgáltatás: Alapvető követelmény az átállításhoz. Erre azért van szükség, hogy az alkalmazás figyelni és blokkolni tudja a billentyűlenyomásokat. + * Akadálymentesítési szolgáltatás: Alapvető követelmény az átállításhoz. It is needed so the app can listen to and block key events. * Adminisztrátor: A képernyő kikapcsolása a képernyő kikapcsolására szolgáló művelet használatakor. * Rendszerbeállítások módosítása: A fényerő és a forgatási beállítások megváltoztatásához. * Kamera: A zseblámpa vezérléséhez. diff --git a/fastlane/metadata/android/ka_GE/full_description.txt b/fastlane/metadata/android/ka_GE/full_description.txt index 515ccecd74..13f13d37dd 100644 --- a/fastlane/metadata/android/ka_GE/full_description.txt +++ b/fastlane/metadata/android/ka_GE/full_description.txt @@ -1,9 +1,9 @@ What can be remapped? - * Fingerprint gestures on supported devices. * Volume buttons. - * Navigation buttons. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Bluetooth/wired keyboards. + * Fingerprint gestures on supported devices. * Buttons on other connected devices should also work. ONLY HARDWARE buttons can be remapped. @@ -12,12 +12,10 @@ There is NO GUARANTEE any of these buttons will work and this app is NOT designe You can combine multiple keys from a specific device or any device to form a "trigger". Each trigger can have multiple actions. The keys can be set to be pressed at the same time or one after another in a sequence. Keys can be remapped when they are short pressed, long pressed or double pressed. A keymap can have a set of "constraints" so it only has an effect in certain situations. What can’t be remapped? - * Power button - * Bixby button * Mouse buttons * Dpad, thumb sticks or triggers on game controllers -Your key maps don't work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. +Your key maps do not work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. What can I remap my keys to do? Some actions will only work on rooted devices and specific Android versions. @@ -27,7 +25,7 @@ There are too many features to list here so check out the full list here: https: Permissions You don't have to grant all the permissions for the app to work. The app will tell you if a permission needs to be granted for a feature to work. - * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block keyevents. + * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block key events. * Device Admin: To turn the screen off when using the action to turn off the screen. * Modify System Settings: To change the brightness and rotation settings. * Camera: To control the flashlight. diff --git a/fastlane/metadata/android/ko_KR/full_description.txt b/fastlane/metadata/android/ko_KR/full_description.txt index 515ccecd74..13f13d37dd 100644 --- a/fastlane/metadata/android/ko_KR/full_description.txt +++ b/fastlane/metadata/android/ko_KR/full_description.txt @@ -1,9 +1,9 @@ What can be remapped? - * Fingerprint gestures on supported devices. * Volume buttons. - * Navigation buttons. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Bluetooth/wired keyboards. + * Fingerprint gestures on supported devices. * Buttons on other connected devices should also work. ONLY HARDWARE buttons can be remapped. @@ -12,12 +12,10 @@ There is NO GUARANTEE any of these buttons will work and this app is NOT designe You can combine multiple keys from a specific device or any device to form a "trigger". Each trigger can have multiple actions. The keys can be set to be pressed at the same time or one after another in a sequence. Keys can be remapped when they are short pressed, long pressed or double pressed. A keymap can have a set of "constraints" so it only has an effect in certain situations. What can’t be remapped? - * Power button - * Bixby button * Mouse buttons * Dpad, thumb sticks or triggers on game controllers -Your key maps don't work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. +Your key maps do not work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. What can I remap my keys to do? Some actions will only work on rooted devices and specific Android versions. @@ -27,7 +25,7 @@ There are too many features to list here so check out the full list here: https: Permissions You don't have to grant all the permissions for the app to work. The app will tell you if a permission needs to be granted for a feature to work. - * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block keyevents. + * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block key events. * Device Admin: To turn the screen off when using the action to turn off the screen. * Modify System Settings: To change the brightness and rotation settings. * Camera: To control the flashlight. diff --git a/fastlane/metadata/android/pl_PL/full_description.txt b/fastlane/metadata/android/pl_PL/full_description.txt index 506a022419..2b2b73c63a 100644 --- a/fastlane/metadata/android/pl_PL/full_description.txt +++ b/fastlane/metadata/android/pl_PL/full_description.txt @@ -1,9 +1,9 @@ Funkcje jakich przycisków można zmienić? - * Gesty czytnika linii papilarnych na obsługiwanych urządzeniach. * Przyciski głośności. - * Przyciski nawigacji. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Klawiatury przewodowe i Bluetooth. + * Gesty czytnika linii papilarnych na obsługiwanych urządzeniach. * Przyciski na innych podłączonych urządzeniach również powinny działać. TYLKO FIZYCZNE przyciski mogą mieć zmienione funkcje. @@ -12,12 +12,10 @@ NIE MA GWARANCJI, że Twoje przyciski mogą być używane, a także ta aplikacja Możesz połączyć wiele przycisków z określonego lub dowolnego urządzenia, aby utworzyć "wyzwalacz". Każdy wyzwalacz może mieć wiele czynności. Przyciski można ustawić tak, aby były naciskane jednocześnie lub jeden po drugim w kolejności. Przyciski mogą być wykorzystane, gdy są wciśnięte krótko, długo lub podwójnie. Zmiana funkcji może mieć zestaw "ograniczeń", aby działała tylko w określonych sytuacjach. Funkcje jakich przycisków nie można zmieniać? - * Przycisk zasilania - * Przycisk Bixby * Przyciski myszy * Krzyżak, gałki albo spusty na kontrolerach gier -Twoje zmiany funkcji nie będą działać, gdy ekran jest WYŁĄCZONY. To jest ograniczenie w systemie Android. Programista nic nie może na to poradzić. +Your key maps do not work if the screen is OFF. To jest ograniczenie w systemie Android. Programista nic nie może na to poradzić. Jakie funkcje można przypisać do przycisków? Niektóre czynności będą działać tylko na zrootowanych urządzeniach i określonych wersjach Androida. @@ -27,7 +25,7 @@ Mamy zbyt wiele funkcji, aby wszystko się tu zmieściło, więc zapoznaj się z Uprawnienia Nie musisz przyznawać wszystkich uprawnień, aby aplikacja działała. Aplikacja poinformuje Cię, czy do działania funkcji wymagane jest określone uprawnienie. - * Usługa ułatwień dostępu: Podstawowe wymaganie dotyczące zmiany funkcji przycisków. Jest to potrzebne, aby aplikacja mogła nasłuchiwać i blokować zdarzenia przycisków. + * Usługa ułatwień dostępu: Podstawowe wymaganie dotyczące zmiany funkcji przycisków. It is needed so the app can listen to and block key events. * Zarządzanie urządzeniem: aby wyłączyć ekran podczas korzystania z czynności wyłączenia ekranu. * Modyfikowanie ustawień systemu: Aby zmienić ustawienia jasności i obracania. * Aparat: Aby sterować latarką. diff --git a/fastlane/metadata/android/pt_BR/full_description.txt b/fastlane/metadata/android/pt_BR/full_description.txt index 28ff1d8e2c..0a8a31bb4a 100644 --- a/fastlane/metadata/android/pt_BR/full_description.txt +++ b/fastlane/metadata/android/pt_BR/full_description.txt @@ -1,9 +1,9 @@ O que pode ser remapeado? - * Gestos de impressão digital em dispositivos suportados. * Botões de volume. - * Botões de navegação. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Teclados Bluetooth/com fio. + * Gestos de impressão digital em dispositivos suportados. * Botões em outros dispositivos conectados também devem funcionar. SOMENTE botões de HARDWARE podem ser remapeados. @@ -12,12 +12,10 @@ NÃO HÁ GARANTIA de que qualquer um desses botões funcionará e este aplicativ Você pode combinar várias teclas de um dispositivo específico ou de qualquer dispositivo para formar um "gatilho". Cada gatilho pode ter múltiplas ações. As teclas podem ser configuradas para serem pressionadas ao mesmo tempo ou uma após a outra em sequência. As teclas podem ser remapeadas quando pressionadas brevemente, longamente ou duas vezes. Um mapa de teclas pode ter um conjunto de "restrições" para que ele só tenha efeito em determinadas situações. O que não pode ser remapeado? - * Botão de energia - * Botão Bixby * Botões do mouse * Dpad, joysticks ou gatilhos em controles de jogos -Os mapas de teclas não funcionam se a tela estiver desligada. Esta é uma limitação do Android. Não há nada que o desenvolvedor possa fazer. +Your key maps do not work if the screen is OFF. Esta é uma limitação do Android. Não há nada que o desenvolvedor possa fazer. O que posso fazer para remapear minhas chaves? Algumas ações só funcionarão em dispositivos com root e em versões específicas do Android. @@ -27,7 +25,7 @@ Há muitos recursos para listar aqui, então confira a lista completa aqui: http Permissões Você não precisa conceder todas as permissões para que o aplicativo funcione. O aplicativo informará se é necessário conceder uma permissão para que um recurso funcione. - * Serviço de Acessibilidade: Requisito básico para o remapeamento para o trabalho. É necessário para que o aplicativo possa escutar e bloquear eventos-chave. + * Serviço de Acessibilidade: Requisito básico para o remapeamento para o trabalho. It is needed so the app can listen to and block key events. * Administrador do dispositivo: para desligar a tela ao usar a ação para desligar a tela. * Modificar configurações do sistema: para alterar as configurações de brilho e rotação. * Câmera: Para controlar a lanterna. diff --git a/fastlane/metadata/android/ru_RU/full_description.txt b/fastlane/metadata/android/ru_RU/full_description.txt index ed0b6074cc..5fd8fd474b 100644 --- a/fastlane/metadata/android/ru_RU/full_description.txt +++ b/fastlane/metadata/android/ru_RU/full_description.txt @@ -1,9 +1,9 @@ Что можно переназначить? - * Жесты отпечатка пальца на поддерживаемых устройствах. * Кнопки громкости. - * Кнопки навигации. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Проводные и Bluetooth клавиатуры. + * Жесты отпечатка пальца на поддерживаемых устройствах. * Кнопки на других подключенных устройствах также должны работать. Переназначены могут быть ТОЛЬКО ФИЗИЧЕСКИЕ кнопки. @@ -12,12 +12,10 @@ Вы можете совместить нажатие нескольких кнопок на определенном устройстве или нескольких устройствах для создания "события". У каждого события может быть несколько действий. Можно назначить событие на одновременное или последовательное нажатие клавиш. Клавиши можно переназначить при их коротком, длительном или двойном нажатии. Переназначение может иметь набор "ограничений" для того чтобы срабатывать только в определенных ситуациях. Что НЕ может быть переназначено: - * Кнопка питания - * Кнопка Bixby * Кнопки мыши * Dpad, джойстики или триггеры на игровых контроллерах -Переназначения клавиш не работают когда экран вашего устройства выключен. Это ограничение самого Android. К сожалению с этим ничего нельзя сделать. +Your key maps do not work if the screen is OFF. Это ограничение самого Android. К сожалению с этим ничего нельзя сделать. Каким образом можно переназначить клавиши? Некоторые действия будут работать только на устройствах с Root правами и/или на конкретных версиях Android. @@ -27,7 +25,7 @@ Разрешения Вам не нужно предоставлять все разрешения для работы приложения. Приложение сообщит, нужно ли вам разрешение для работы функции. - * Служба спец.возможностей: Базовое требование для работы переназначения клавиш. Это необходимо, чтобы приложение могло слушать и блокировать события нажатий. + * Служба спец.возможностей: Базовое требование для работы переназначения клавиш. It is needed so the app can listen to and block key events. * Администратор устройства: Чтобы выключить экран при использовании действия для выключения экрана. * Изменение системных настроек: для изменения яркости и параметров вращения. * Камера: Для управления фонариком. diff --git a/fastlane/metadata/android/sk/full_description.txt b/fastlane/metadata/android/sk/full_description.txt index 515ccecd74..13f13d37dd 100644 --- a/fastlane/metadata/android/sk/full_description.txt +++ b/fastlane/metadata/android/sk/full_description.txt @@ -1,9 +1,9 @@ What can be remapped? - * Fingerprint gestures on supported devices. * Volume buttons. - * Navigation buttons. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Bluetooth/wired keyboards. + * Fingerprint gestures on supported devices. * Buttons on other connected devices should also work. ONLY HARDWARE buttons can be remapped. @@ -12,12 +12,10 @@ There is NO GUARANTEE any of these buttons will work and this app is NOT designe You can combine multiple keys from a specific device or any device to form a "trigger". Each trigger can have multiple actions. The keys can be set to be pressed at the same time or one after another in a sequence. Keys can be remapped when they are short pressed, long pressed or double pressed. A keymap can have a set of "constraints" so it only has an effect in certain situations. What can’t be remapped? - * Power button - * Bixby button * Mouse buttons * Dpad, thumb sticks or triggers on game controllers -Your key maps don't work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. +Your key maps do not work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. What can I remap my keys to do? Some actions will only work on rooted devices and specific Android versions. @@ -27,7 +25,7 @@ There are too many features to list here so check out the full list here: https: Permissions You don't have to grant all the permissions for the app to work. The app will tell you if a permission needs to be granted for a feature to work. - * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block keyevents. + * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block key events. * Device Admin: To turn the screen off when using the action to turn off the screen. * Modify System Settings: To change the brightness and rotation settings. * Camera: To control the flashlight. diff --git a/fastlane/metadata/android/tr_TR/full_description.txt b/fastlane/metadata/android/tr_TR/full_description.txt index 515ccecd74..13f13d37dd 100644 --- a/fastlane/metadata/android/tr_TR/full_description.txt +++ b/fastlane/metadata/android/tr_TR/full_description.txt @@ -1,9 +1,9 @@ What can be remapped? - * Fingerprint gestures on supported devices. * Volume buttons. - * Navigation buttons. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Bluetooth/wired keyboards. + * Fingerprint gestures on supported devices. * Buttons on other connected devices should also work. ONLY HARDWARE buttons can be remapped. @@ -12,12 +12,10 @@ There is NO GUARANTEE any of these buttons will work and this app is NOT designe You can combine multiple keys from a specific device or any device to form a "trigger". Each trigger can have multiple actions. The keys can be set to be pressed at the same time or one after another in a sequence. Keys can be remapped when they are short pressed, long pressed or double pressed. A keymap can have a set of "constraints" so it only has an effect in certain situations. What can’t be remapped? - * Power button - * Bixby button * Mouse buttons * Dpad, thumb sticks or triggers on game controllers -Your key maps don't work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. +Your key maps do not work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. What can I remap my keys to do? Some actions will only work on rooted devices and specific Android versions. @@ -27,7 +25,7 @@ There are too many features to list here so check out the full list here: https: Permissions You don't have to grant all the permissions for the app to work. The app will tell you if a permission needs to be granted for a feature to work. - * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block keyevents. + * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block key events. * Device Admin: To turn the screen off when using the action to turn off the screen. * Modify System Settings: To change the brightness and rotation settings. * Camera: To control the flashlight. diff --git a/fastlane/metadata/android/uk_UA/full_description.txt b/fastlane/metadata/android/uk_UA/full_description.txt index 515ccecd74..13f13d37dd 100644 --- a/fastlane/metadata/android/uk_UA/full_description.txt +++ b/fastlane/metadata/android/uk_UA/full_description.txt @@ -1,9 +1,9 @@ What can be remapped? - * Fingerprint gestures on supported devices. * Volume buttons. - * Navigation buttons. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Bluetooth/wired keyboards. + * Fingerprint gestures on supported devices. * Buttons on other connected devices should also work. ONLY HARDWARE buttons can be remapped. @@ -12,12 +12,10 @@ There is NO GUARANTEE any of these buttons will work and this app is NOT designe You can combine multiple keys from a specific device or any device to form a "trigger". Each trigger can have multiple actions. The keys can be set to be pressed at the same time or one after another in a sequence. Keys can be remapped when they are short pressed, long pressed or double pressed. A keymap can have a set of "constraints" so it only has an effect in certain situations. What can’t be remapped? - * Power button - * Bixby button * Mouse buttons * Dpad, thumb sticks or triggers on game controllers -Your key maps don't work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. +Your key maps do not work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. What can I remap my keys to do? Some actions will only work on rooted devices and specific Android versions. @@ -27,7 +25,7 @@ There are too many features to list here so check out the full list here: https: Permissions You don't have to grant all the permissions for the app to work. The app will tell you if a permission needs to be granted for a feature to work. - * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block keyevents. + * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block key events. * Device Admin: To turn the screen off when using the action to turn off the screen. * Modify System Settings: To change the brightness and rotation settings. * Camera: To control the flashlight. diff --git a/fastlane/metadata/android/vi_VN/full_description.txt b/fastlane/metadata/android/vi_VN/full_description.txt index 459f5b0cb3..a3b5ab41a9 100644 --- a/fastlane/metadata/android/vi_VN/full_description.txt +++ b/fastlane/metadata/android/vi_VN/full_description.txt @@ -1,9 +1,9 @@ Những gì có thể được ánh xạ lại? - * Cử chỉ vân tay trên các thiết bị được hỗ trợ. * Nút âm lượng. - * Các nút điều hướng. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Bàn phím Bluetooth/có dây. + * Cử chỉ vân tay trên các thiết bị được hỗ trợ. * Các nút trên các thiết bị được kết nối khác cũng sẽ hoạt động. CHỈ có thể ánh xạ lại các nút PHẦN CỨNG. @@ -12,12 +12,10 @@ KHÔNG CÓ ĐẢM BẢO bất kỳ nút nào trong số này sẽ hoạt động Bạn có thể kết hợp nhiều phím từ một thiết bị cụ thể hoặc bất kỳ thiết bị nào để tạo thành một "trình kích hoạt". Mỗi trigger có thể có nhiều hành động. Các phím có thể được cài đặt để nhấn cùng lúc hoặc lần lượt theo trình tự. Các phím có thể được ánh xạ lại khi chúng được nhấn nhanh, nhấn lâu hoặc nhấn đúp. Sơ đồ bàn phím có thể có một tập hợp các "ràng buộc" nên nó chỉ có tác dụng trong một số trường hợp nhất định. Những gì không thể được ánh xạ lại? - * Nút nguồn - * Nút Bixby * Nút chuột * Dpad, gậy ngón tay cái hoặc trình kích hoạt trên bộ điều khiển trò chơi -Bản đồ chính của bạn không hoạt động nếu màn hình TẮT. Đây là một hạn chế trong Android. Dev không thể làm gì được. +Your key maps do not work if the screen is OFF. Đây là một hạn chế trong Android. Dev không thể làm gì được. Tôi có thể sắp xếp lại chìa khóa của mình để làm gì? Một số hành động sẽ chỉ hoạt động trên các thiết bị đã root và các phiên bản Android cụ thể. @@ -27,7 +25,7 @@ Có quá nhiều tính năng để liệt kê ở đây, vì vậy hãy xem danh Quyền Bạn không cần phải cấp tất cả các quyền để ứng dụng hoạt động. Ứng dụng sẽ cho bạn biết liệu có cần cấp quyền để một tính năng hoạt động hay không. - * Dịch vụ trợ năng: Yêu cầu cơ bản để ánh xạ lại hoạt động. Nó cần thiết để ứng dụng có thể nghe và chặn các sự kiện quan trọng. + * Dịch vụ trợ năng: Yêu cầu cơ bản để ánh xạ lại hoạt động. It is needed so the app can listen to and block key events. * Quản trị thiết bị: Để tắt màn hình khi sử dụng thao tác tắt màn hình. * Sửa đổi cài đặt hệ thống: Để thay đổi cài đặt độ sáng và xoay. * Camera: Để điều khiển đèn pin. diff --git a/fastlane/metadata/android/zh_CN/full_description.txt b/fastlane/metadata/android/zh_CN/full_description.txt index 31d83ace6d..7d61ff0bb3 100644 --- a/fastlane/metadata/android/zh_CN/full_description.txt +++ b/fastlane/metadata/android/zh_CN/full_description.txt @@ -1,9 +1,9 @@ 什么能被重新映射? - * 在支持的设备上的指纹手势。 * 音量键。 - * 导航按钮。 + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * 蓝牙/有线键盘。 + * 在支持的设备上的指纹手势。 * 其他已连接的设备的按钮也应该起作用。 只有实体按键能重新映射。 @@ -12,12 +12,10 @@ 你可以组合来自特定设备或多个设备的多个按键来构成“触发器”。 每个触发器都可以有多个动作。 可以将按键设置为同时按下或依次按下。 按键可以在它们被短按、长按或双击时被重新映射。 按键映射可以有一组“约束,因此它仅在某些情况下有效。 什么不可以重新映射? - * 电源键 - * Bixby键 * 鼠标键 * 游戏手柄上的Dpad、类比摇杆和触发器 -如果屏幕关闭,你的按键映射将不起作用。 这是Android中的一个限制。 开发者无能为力。 +Your key maps do not work if the screen is OFF. 这是Android中的一个限制。 开发者无能为力。 我重新映射我的按键能些做什么? 某些动作只在已root的设备和特定的Android版本上起作用。 @@ -27,7 +25,7 @@ 权限 你无需授予所有权限来让这个应用工作。 如果一个功能需要授予权限才能工作,这个应用将告诉你。 - * 无障碍服务: 让重新映射工作的基本要求。 应用需要它才能监听和阻止按键事件。 + * 无障碍服务: 让重新映射工作的基本要求。 It is needed so the app can listen to and block key events. * 设备管理员: 用于在使用关闭屏幕的动作时关闭屏幕。 * 修改系统设置: 用于更改亮度和旋转设置。 * 相机: 用于控制闪光灯。 diff --git a/fastlane/metadata/android/zh_TW/full_description.txt b/fastlane/metadata/android/zh_TW/full_description.txt index 515ccecd74..13f13d37dd 100644 --- a/fastlane/metadata/android/zh_TW/full_description.txt +++ b/fastlane/metadata/android/zh_TW/full_description.txt @@ -1,9 +1,9 @@ What can be remapped? - * Fingerprint gestures on supported devices. * Volume buttons. - * Navigation buttons. + * Power button via Google Assistant, Pixel Active Edge, Double press Bixby button. * Bluetooth/wired keyboards. + * Fingerprint gestures on supported devices. * Buttons on other connected devices should also work. ONLY HARDWARE buttons can be remapped. @@ -12,12 +12,10 @@ There is NO GUARANTEE any of these buttons will work and this app is NOT designe You can combine multiple keys from a specific device or any device to form a "trigger". Each trigger can have multiple actions. The keys can be set to be pressed at the same time or one after another in a sequence. Keys can be remapped when they are short pressed, long pressed or double pressed. A keymap can have a set of "constraints" so it only has an effect in certain situations. What can’t be remapped? - * Power button - * Bixby button * Mouse buttons * Dpad, thumb sticks or triggers on game controllers -Your key maps don't work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. +Your key maps do not work if the screen is OFF. This is a limitation in Android. There is nothing the dev can do. What can I remap my keys to do? Some actions will only work on rooted devices and specific Android versions. @@ -27,7 +25,7 @@ There are too many features to list here so check out the full list here: https: Permissions You don't have to grant all the permissions for the app to work. The app will tell you if a permission needs to be granted for a feature to work. - * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block keyevents. + * Accessibility Service: Basic requirement for remapping to work. It is needed so the app can listen to and block key events. * Device Admin: To turn the screen off when using the action to turn off the screen. * Modify System Settings: To change the brightness and rotation settings. * Camera: To control the flashlight. From 525641a86d3974620a52e084eb749d85f5c68e63 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 4 Dec 2024 22:17:19 +0100 Subject: [PATCH 111/118] chore: delete production github actions since Pro builds for the play store have a closed source module --- .github/workflows/production.yml | 81 -------------------------------- 1 file changed, 81 deletions(-) delete mode 100644 .github/workflows/production.yml diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml deleted file mode 100644 index 9173a48fde..0000000000 --- a/.github/workflows/production.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Production - build and release app - -on: - push: - branches: - - 'master' - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: true - -jobs: - test: - name: Run unit tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: set up JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'oracle' - java-version: 17 - cache: 'gradle' - - - name: Setup Android SDK - uses: android-actions/setup-android@v2 - - - name: Unit tests - run: bash ./gradlew testDebugUnitTest - - apk: - name: Build and release to production - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: set up JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'oracle' - java-version: 17 - cache: 'gradle' - - - name: Setup Android SDK - uses: android-actions/setup-android@v2 - - - name: set up Ruby for fastlane - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.3' - - - name: Install bundle - run: bundle install - - - name: Create keystore - env: - KEYSTORE: ${{ secrets.KEYSTORE }} - run: echo "$KEYSTORE" | base64 --decode > app/keystore.jks - - - name: Create Google Play service account key - env: - GOOGLE_PLAY_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT }} - run: echo "$GOOGLE_PLAY_SERVICE_ACCOUNT" | base64 --decode > app/play-service-account-key.json - - - name: Build apk with fastlane - run: bundle exec fastlane prod github_token:${{ secrets.GITHUB_TOKEN }} - env: - KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_STORE_PASSWORD }} - KEY_PASSWORD: ${{ secrets.KEYSTORE_KEY_PASSWORD }} \ No newline at end of file From 1100a34c0738d57e6c4b32d3fa068329111c8e96 Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 4 Dec 2024 22:24:52 +0100 Subject: [PATCH 112/118] update whats-new.txt --- app/src/main/assets/whats-new.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/assets/whats-new.txt b/app/src/main/assets/whats-new.txt index cce097e7c6..1a48756871 100644 --- a/app/src/main/assets/whats-new.txt +++ b/app/src/main/assets/whats-new.txt @@ -1 +1,3 @@ -Support for Android 14 and many bug fixes. See the changelog. \ No newline at end of file +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. \ No newline at end of file From 8e02a62918e978538fb853d92f92722f9df79cbe Mon Sep 17 00:00:00 2001 From: sds100 Date: Wed, 4 Dec 2024 22:48:48 +0100 Subject: [PATCH 113/118] fix: use correct path for CI testing builds --- .github/workflows/testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 6e707b4fbf..2ced70cf71 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -126,7 +126,7 @@ jobs: env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} with: - args: app/build/outputs/apk/ci/${{ env.APK_NAME }}.apk + args: app/build/outputs/apk/free/ci/${{ env.APK_NAME }}.apk - name: Report build status to Discord uses: sarisia/actions-status-discord@v1 From de6792303819f7c4ca1cbd0caa523771c2d4c931 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 8 Dec 2024 15:32:21 +0100 Subject: [PATCH 114/118] #1274 fix: after tapping the notification, do not navigate to the advanced triggers bottom sheet after a configuration change --- .../java/io/github/sds100/keymapper/ActivityViewModel.kt | 1 + .../java/io/github/sds100/keymapper/BaseMainActivity.kt | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/app/src/main/java/io/github/sds100/keymapper/ActivityViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/ActivityViewModel.kt index 0de03bff3b..8fefdddbe4 100644 --- a/app/src/main/java/io/github/sds100/keymapper/ActivityViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/ActivityViewModel.kt @@ -21,6 +21,7 @@ class ActivityViewModel( PopupViewModel by PopupViewModelImpl(), NavigationViewModel by NavigationViewModelImpl() { + var handledActivityLaunchIntent: Boolean = false var previousNightMode: Int? = null fun onCantFindAccessibilitySettings() { diff --git a/app/src/main/java/io/github/sds100/keymapper/BaseMainActivity.kt b/app/src/main/java/io/github/sds100/keymapper/BaseMainActivity.kt index 039f1cade4..efc0b158db 100644 --- a/app/src/main/java/io/github/sds100/keymapper/BaseMainActivity.kt +++ b/app/src/main/java/io/github/sds100/keymapper/BaseMainActivity.kt @@ -68,6 +68,10 @@ abstract class BaseMainActivity : AppCompatActivity() { // Must launch when the activity is resumed // so the nav controller can be found launchRepeatOnLifecycle(Lifecycle.State.RESUMED) { + if (viewModel.handledActivityLaunchIntent) { + return@launchRepeatOnLifecycle + } + when (intent.action) { ACTION_SHOW_ACCESSIBILITY_SETTINGS_NOT_FOUND_DIALOG -> { viewModel.onCantFindAccessibilitySettings() @@ -82,6 +86,8 @@ abstract class BaseMainActivity : AppCompatActivity() { ) } } + + viewModel.handledActivityLaunchIntent = true } } From 2f3958ae93888a4f36bfac9b5aa0b6ca4e4fe437 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 8 Dec 2024 16:33:28 +0100 Subject: [PATCH 115/118] chore: write changelog for 2.7.0 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a455836e2f..916d7059af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [2.7.0](https://github.com/sds100/KeyMapper/releases/tag/v2.7.0) + +#### 8 December 2024 + +## Added + +- #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. + +## Bug fixes + +- #1307 Key Mapper doesn't execute the correct app shortcut action if you created multiple from the same app. + ## [2.6.2](https://github.com/sds100/KeyMapper/releases/tag/v2.6.2) #### 9 September 2024 From 296a1ec5ffe5aefc74304e6f7ba11267cf87b98a Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sun, 8 Dec 2024 15:34:11 +0000 Subject: [PATCH 116/118] New Crowdin translations by GitHub Action --- app/src/main/res/values-zh-rCN/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index eb53688fe5..25073c5956 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2,7 +2,7 @@ 松开你的按键! 未选择动作 - Key Mapper 需要使用无障碍服务,以便它可以检测并更改您在应用程序之外时按下的按钮的操作。只有在您启用了无障碍服务后,您的按键映射才会起作用。必须打开无障碍服务才能创建触发器。 + 键映射器需要使用无障碍服务,以便它可以检测并更改您在应用程序之外时按下的按钮的操作。只有在您启用了无障碍服务后,您的按键映射才会起作用。必须打开无障碍服务才能创建触发器。 已选择 %d 个 启用 打开 @@ -41,7 +41,7 @@ 显示隐藏的应用 修改器 切换 - 重要!!!这些坐标只有当你的显示方向与屏幕截图相同时才是正确的! 此操作将取消您在屏幕上所做的任何触摸或手势。\n\n如果你寻找您屏幕上某点的坐标需要帮助,就采取屏幕截图的方法,然后在屏幕截图上点击您想让此动作按下的地方。 + 重要的提示!! 仅当你的屏幕与截图的方向相同时,这些坐标才是正确的! 此操作将取消你在屏幕上执行的任何触摸或手势。\n\n如果您需要帮助查找屏幕上某个点的坐标,请截屏后点击截图上您想让此动作按下的地方。 注意:在使用 “缩小” 时,X和Y是结束坐标,在使用 “放大” 时,X和Y是起始坐标。 未知设备名称! 点击动作以修复! From 01d29fdacc21846a99be336fe550aeb088bc2e3c Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 8 Dec 2024 16:44:48 +0100 Subject: [PATCH 117/118] chore: update changelog for 2.7.0 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 916d7059af..8bb18a10d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,12 @@ ## Added - #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 Vietnamese translations. ## Bug fixes -- #1307 Key Mapper doesn't execute the correct app shortcut action if you created multiple from the same app. +- #1222 #1307 Key Mapper doesn't execute the correct app shortcut action if you created multiple from the same app. +- #1328 Single-character non-ASCII TEXT_BLOCK input crashes the service ## [2.6.2](https://github.com/sds100/KeyMapper/releases/tag/v2.6.2) From 5f8469dbd71b9de53c305cd00f3995acb24a85d3 Mon Sep 17 00:00:00 2001 From: sds100 Date: Sun, 8 Dec 2024 17:04:00 +0100 Subject: [PATCH 118/118] chore: make releasing to production manual in fastlane --- fastlane/Fastfile | 42 +++++++++++++++++++++++++++++------------- fastlane/README.md | 2 +- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index ddf5ef0a2b..52259dcc03 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -23,37 +23,53 @@ lane :testing do end desc "Create and deploy production release" -lane :prod do |options| +lane :prod 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") whats_new = File.read("../app/src/main/assets/whats-new.txt") File.write("metadata/android/en-US/changelogs/" + version_code + ".txt", whats_new) + gradle(task: "testDebugUnitTest") + + github_token = prompt( + text: "Github token: ", + secure_text: true + ) + + ENV["KEYSTORE_PASSWORD"] = prompt( + text: "Key store password: ", + secure_text: true + ) + + ENV["KEY_PASSWORD"] = prompt( + text: "Key password: ", + secure_text: true + ) + # Do not release a debug build for pro version. # gradle(task: "assembleDebug") gradle(task: "assembleProRelease") gradle(task: "bundleProRelease") - apk_path_debug="app/build/outputs/apk/debug/keymapper-" + version_name + "-debug.apk" - apk_path_release="app/build/outputs/apk/release/keymapper-" + version_name + ".apk" - - supply( - aab: "./app/build/outputs/bundle/release/app-release.aab", - track: "beta", - release_status: "draft", - skip_upload_apk: true - ) + apk_path_release="app/build/outputs/apk/pro/release/keymapper-" + version_name + ".apk" github_release = set_github_release( repository_name: "keymapperorg/KeyMapper", - api_bearer: options[:github_token], + api_bearer: github_token, name: version_name, tag_name: "v" + version_name, description: whats_new, commitish: "master", - upload_assets: [apk_path_debug, apk_path_release], + upload_assets: [apk_path_release], is_draft: false, is_prerelease: false ) -end \ No newline at end of file + + supply( + aab: "app/build/outputs/bundle/proRelease/app-pro-release.aab", + track: "beta", + release_status: "draft", + skip_upload_apk: true + ) +end diff --git a/fastlane/README.md b/fastlane/README.md index e799eb9f9e..19f837294e 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -19,7 +19,7 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do [bundle exec] fastlane testing ``` -Create and deploy testing release +Create testing release ### prod