diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index c3d0cecbad..caccddbd8f 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -102,14 +102,14 @@ jobs: ${{ runner.os }}-gradle- - name: set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'oracle' java-version: 21 cache: 'gradle' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + uses: android-actions/setup-android@v4 with: ndk-version: "27.2.12479018" diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index ab8c9d4025..0ac8ac3b14 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -114,14 +114,14 @@ jobs: value: ${{ github.run_number }} - name: set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'oracle' java-version: 21 cache: 'gradle' - name: Setup Android SDK - uses: android-actions/setup-android@v2 + uses: android-actions/setup-android@v4 with: ndk-version: "27.2.12479018" diff --git a/.gitignore b/.gitignore index 9d7b825226..7da1df27a3 100644 --- a/.gitignore +++ b/.gitignore @@ -97,4 +97,5 @@ app/.env evdev/build evdev/.cxx -evdev/src/main/rust/*/target \ No newline at end of file +evdev/src/main/rust/*/target +.kotlin/* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e3b99839c..9b2592977e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,40 @@ -## [4.4.1](https://github.com/sds100/KeyMapper/releases/tag/v4.4.1) +## [4.5.0](https://github.com/sds100/KeyMapper/releases/tag/v4.5.0) #### TO BE RELEASED ## Added +- #1341 new constraint for when a notification from a specific app, or with a specific title or text, is posted. +- #1501 new action to cycle through your enabled keyboards. +- #2250 redesigned the key map options screen with in-app guides for triggering and enabling/disabling a key map by intent. +- #701 new action options bottom sheet design. +- #701 you can now give custom names to actions, to make it easier to identify key maps. +- #701 redesigned the action list with expandable action cards that show each option and error. +- #1465 actions can be turned off individually without deleting them. +- #1479 you can now duplicate an action from the action list. +- #701 the delay before the next action is now set with a button between actions instead of in the action options. +- Redesigned the action options with grouped repeat, burst, hold down and delay sections. +- #752 Constraints can be organised into groups that each have their own AND/OR mode, and the groups are combined with AND or OR. +- #752 Constraint groups can be given a custom name. +- #752 Any constraint can be inverted. +- #752 Redesigned the constraints screen with expandable constraint group cards. +- #2139 color code disabled/enabled groups. +- #1987 groups are red if any key maps inside have errors. +- #2246 adjusted the trigger screen design to match the action and constraint screens. +- #2249 make key maps more compact in the home screen. +- #2259 all key maps can now be triggered by intent or shortcut without needing to enable it first. +- #982 new action to vibrate the device with a custom duration or a predefined haptic effect (Click, Double Click, Heavy Click, Tick). +- #982 the "vibrate on trigger" key map option can now also be customised with a predefined haptic effect instead of just a duration, via a new settings button next to the Vibrate option. - #2243 Redesigned What's New dialog. +## Fixed + +- Do not crash if permission not granted for in phone call constraint and trying to perform key map. +- #2258 remember saved position in key map list when changing them. Do not flash with loading indicator briefly as well. +- #2167 do not show errors for app not in foreground constraint when the app is uninstalled/disabled. +- #2262 request local network access permission for HTTP request action on Android 17+. +- #2261 show the app's saved name instead of a generic label or raw package name for app-related constraints and actions when the app has been uninstalled. + ## [4.4.0](https://github.com/sds100/KeyMapper/releases/tag/v4.4.0) #### 12 September 2026 diff --git a/CLAUDE.md b/CLAUDE.md index f902d959ab..2773c39798 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,8 +24,9 @@ Android app for custom key/gamepad remapping, macros, and on-screen buttons. Sup ./gradlew :base:testDebugUnitTest # single module # Kotlin lint -./gradlew ktlintCheck # check -./gradlew ktlintFormat # auto-fix +# First run format before doing check +./gradlew ktlintFormat +./gradlew ktlintCheck # Rust (run from evdev/src/main/rust/evdev_manager) cargo fmt --check diff --git a/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt b/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt index 849ad6f261..cd52d8fc97 100644 --- a/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt +++ b/app/src/main/java/io/github/sds100/keymapper/trigger/ConfigTriggerViewModel.kt @@ -16,6 +16,7 @@ import io.github.sds100.keymapper.base.trigger.TriggerSetupShortcut import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager import javax.inject.Inject import kotlinx.coroutines.launch @@ -32,6 +33,7 @@ class ConfigTriggerViewModel @Inject constructor( setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, onboardingTipDelegate: OnboardingTipDelegate, triggerSetupDelegate: TriggerSetupDelegate, + vibrateConfigDelegate: VibrateConfigDelegate, resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, @@ -46,6 +48,7 @@ class ConfigTriggerViewModel @Inject constructor( systemBridgeConnectionManager = systemBridgeConnectionManager, onboardingTipDelegate = onboardingTipDelegate, triggerSetupDelegate = triggerSetupDelegate, + vibrateConfigDelegate = vibrateConfigDelegate, resourceProvider = resourceProvider, navigationProvider = navigationProvider, dialogProvider = dialogProvider, diff --git a/app/version.properties b/app/version.properties index b1b9023e9e..3c584b5c89 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=4.4.1 -VERSION_CODE=265 +VERSION_NAME=4.5.0 +VERSION_CODE=269 diff --git a/base/build.gradle.kts b/base/build.gradle.kts index 8b0bf62100..bd8e2dbfe9 100644 --- a/base/build.gradle.kts +++ b/base/build.gradle.kts @@ -161,7 +161,6 @@ dependencies { testImplementation(libs.junit.params) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.core) - testImplementation(libs.mockito.inline) testDebugImplementation(libs.androidx.fragment.testing) // Dependencies for Android instrumented tests diff --git a/base/src/main/assets/whats-new-app.txt b/base/src/main/assets/whats-new-app.txt index 5e8434e331..6733687866 100644 --- a/base/src/main/assets/whats-new-app.txt +++ b/base/src/main/assets/whats-new-app.txt @@ -1,8 +1,7 @@ -+ Cycle keyboard language action | Switch between your keyboard's languages with a key press -+ Sort key maps by enabled/disabled -+ Custom seek amount for media step actions -+ Export/import key maps on Android TV -+ "Reduce app killing" tips for Xiaomi/Redmi/Poco devices -* Screen tap/swipe/pinch actions now scale to display resolution -- Fix some crashes when auto starting Expert Mode -- Many other bug fixes and improvements +! Group constraints | Organize constraints into groups with their own AND/OR logic +! Vibrate action | New action with custom duration or haptic effects like Click and Tick ++ • Cycle enabled keyboards action ++ • Notification constraint +* • Turn off, duplicate and rename actions +* • Redesigned action, constraint and trigger screens +- • Many other bug fixes and improvements diff --git a/base/src/main/assets/whats-new.txt b/base/src/main/assets/whats-new.txt index 75321d592d..7d19199139 100644 --- a/base/src/main/assets/whats-new.txt +++ b/base/src/main/assets/whats-new.txt @@ -1,11 +1,8 @@ -• "Reduce app killing" tips for Xiaomi/Redmi/Poco devices -• Sort key maps by enabled/disabled -• Cycle keyboard language action -• Custom seek amount for media step actions -• Export/import key maps on Android TV -• Fix some crashes when auto starting Expert Mode -• Fix shell commands failing with Windows line endings -• Screen tap/swipe/pinch actions now scale to display resolution +• Group constraints with their own AND/OR logic +• New vibrate action with custom duration or haptic effects +• Cycle enabled keyboards action +• Notification constraint +• Turn off, duplicate and rename actions • Many other bug fixes and improvements 📖 View the complete changelog at: http://keymapper.app/changelog diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt index dd04c88808..26e7098f8c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseMainNavHost.kt @@ -16,6 +16,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.toRoute import io.github.sds100.keymapper.base.actions.ChooseActionScreen import io.github.sds100.keymapper.base.actions.ChooseActionViewModel import io.github.sds100.keymapper.base.actions.ChooseSettingScreen @@ -25,10 +26,14 @@ import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementScreen import io.github.sds100.keymapper.base.actions.uielement.InteractUiElementViewModel import io.github.sds100.keymapper.base.constraints.ChooseConstraintScreen import io.github.sds100.keymapper.base.constraints.ChooseConstraintViewModel +import io.github.sds100.keymapper.base.constraints.ConfigNotificationConstraintScreen +import io.github.sds100.keymapper.base.constraints.ConfigNotificationConstraintViewModel import io.github.sds100.keymapper.base.debug.GetEventScreen import io.github.sds100.keymapper.base.expertmode.ExpertModeScreen import io.github.sds100.keymapper.base.expertmode.ExpertModeSetupScreen import io.github.sds100.keymapper.base.expertmode.xiaomi.XiaomiOptimizationScreen +import io.github.sds100.keymapper.base.keymaps.EnableByIntentScreen +import io.github.sds100.keymapper.base.keymaps.TriggerByIntentScreen import io.github.sds100.keymapper.base.logging.LogScreen import io.github.sds100.keymapper.base.onboarding.HandleAccessibilityServiceDialogs import io.github.sds100.keymapper.base.onboarding.SetupAccessibilityServiceDelegateImpl @@ -101,6 +106,15 @@ fun BaseMainNavHost( ) } + composable { + val viewModel: ConfigNotificationConstraintViewModel = hiltViewModel() + + ConfigNotificationConstraintScreen( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + ) + } + composable { val viewModel: ChooseActionViewModel = hiltViewModel() @@ -189,6 +203,26 @@ fun BaseMainNavHost( ) } + composable { backStackEntry -> + val destination = backStackEntry.toRoute() + + TriggerByIntentScreen( + modifier = Modifier.fillMaxSize(), + keyMapUid = destination.keyMapUid, + onBackClick = { navController.popBackStack() }, + ) + } + + composable { backStackEntry -> + val destination = backStackEntry.toRoute() + + EnableByIntentScreen( + modifier = Modifier.fillMaxSize(), + keyMapUid = destination.keyMapUid, + onBackClick = { navController.popBackStack() }, + ) + } + composableDestinations() } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt index cd89501ae5..db1df55647 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseSingletonHiltModule.kt @@ -45,7 +45,6 @@ import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeAsyncImpl import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface import io.github.sds100.keymapper.base.system.inputmethod.ToggleCompatibleImeUseCase import io.github.sds100.keymapper.base.system.inputmethod.ToggleCompatibleImeUseCaseImpl -import io.github.sds100.keymapper.base.system.notifications.AndroidNotificationAdapter import io.github.sds100.keymapper.base.system.notifications.ManageNotificationsUseCase import io.github.sds100.keymapper.base.system.notifications.ManageNotificationsUseCaseImpl import io.github.sds100.keymapper.base.trigger.RecordTriggerController @@ -63,16 +62,11 @@ import io.github.sds100.keymapper.common.utils.UuidGenerator import io.github.sds100.keymapper.system.accessibility.AccessibilityServiceAdapter import io.github.sds100.keymapper.system.inputmethod.KeyEventRelayServiceWrapper import io.github.sds100.keymapper.system.inputmethod.KeyEventRelayServiceWrapperImpl -import io.github.sds100.keymapper.system.notifications.NotificationAdapter import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class BaseSingletonHiltModule { - @Singleton - @Binds - abstract fun provideNotificationAdapter(impl: AndroidNotificationAdapter): NotificationAdapter - @Singleton @Binds abstract fun provideAccessibilityAdapter( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt b/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt index 7dbc421f2f..d664c6373d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/BaseViewModelHiltModule.kt @@ -61,6 +61,8 @@ import io.github.sds100.keymapper.base.trigger.SetupInputMethodUseCase import io.github.sds100.keymapper.base.trigger.SetupInputMethodUseCaseImpl import io.github.sds100.keymapper.base.trigger.TriggerSetupDelegate import io.github.sds100.keymapper.base.trigger.TriggerSetupDelegateImpl +import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate +import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegateImpl @Module @InstallIn(ViewModelComponent::class) @@ -197,6 +199,10 @@ abstract class BaseViewModelHiltModule { impl: FixKeyEventActionDelegateImpl, ): FixKeyEventActionDelegate + @Binds + @ViewModelScoped + abstract fun bindVibrateConfigDelegate(impl: VibrateConfigDelegateImpl): VibrateConfigDelegate + @Binds @ViewModelScoped abstract fun bindExpertModeSetupDelegate( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/IntentApi.kt b/base/src/main/java/io/github/sds100/keymapper/base/IntentApi.kt new file mode 100644 index 0000000000..aa47c3cd58 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/IntentApi.kt @@ -0,0 +1,10 @@ +package io.github.sds100.keymapper.base + +object IntentApi { + const val ACTION_TRIGGER_KEYMAP_BY_UID = + "io.github.sds100.keymapper.ACTION_TRIGGER_KEYMAP_BY_UID" + const val EXTRA_KEYMAP_UID = "io.github.sds100.keymapper.EXTRA_KEYMAP_UID" + + const val TRIGGER_RECEIVER_CLASS = + "io.github.sds100.keymapper.api.TriggerKeyMapsBroadcastReceiver" +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt index 73197e3388..ca467520b1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/Action.kt @@ -29,6 +29,16 @@ data class Action( val multiplier: Int? = null, val delayBeforeNextAction: Int? = null, + + /** + * A name set by the user that is shown instead of the generated title. + */ + val customName: String? = null, + + /** + * Disabled actions are not performed when the key map is triggered. + */ + val isEnabled: Boolean = true, ) object ActionEntityMapper { @@ -85,6 +95,11 @@ object ActionEntityMapper { .valueOrNull() ?.toIntOrNull() + val customName = entity.extras + .getData(ActionEntity.EXTRA_CUSTOM_NAME) + .valueOrNull() + ?.takeIf { it.isNotBlank() } + return Action( uid = entity.uid, data = data, @@ -98,6 +113,8 @@ object ActionEntityMapper { holdDownDuration = holdDownDuration, delayBeforeNextAction = delayBeforeNextAction, multiplier = multiplier, + customName = customName, + isEnabled = !entity.flags.hasFlag(ActionEntity.ACTION_FLAG_DISABLED), ) } @@ -118,6 +135,10 @@ object ActionEntityMapper { add(EntityExtra(ActionEntity.EXTRA_MULTIPLIER, action.multiplier.toString())) } + if (!action.customName.isNullOrBlank()) { + add(EntityExtra(ActionEntity.EXTRA_CUSTOM_NAME, action.customName)) + } + if (keyMap.isHoldingDownActionBeforeRepeatingAllowed(action) && action.holdDownDuration != null ) { @@ -185,6 +206,10 @@ object ActionEntityMapper { flags = flags.withFlag(ActionEntity.ACTION_FLAG_HOLD_DOWN) } + if (!action.isEnabled) { + flags = flags.withFlag(ActionEntity.ACTION_FLAG_DISABLED) + } + return@map ActionEntity( type = base.type, data = base.data, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 916eefff1b..ed244f3d63 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.actions import io.github.sds100.keymapper.base.actions.talkback.TalkBackGestureType +import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.NodeInteractionType import io.github.sds100.keymapper.common.utils.Orientation @@ -23,7 +24,7 @@ sealed class ActionData : Comparable { override fun compareTo(other: ActionData) = id.compareTo(other.id) @Serializable - data class App(val packageName: String) : ActionData() { + data class App(val packageName: String, val savedAppName: String? = null) : ActionData() { override val id: ActionId = ActionId.APP override fun compareTo(other: ActionData) = when (other) { @@ -33,8 +34,12 @@ sealed class ActionData : Comparable { } @Serializable - data class AppShortcut(val packageName: String?, val shortcutTitle: String, val uri: String) : - ActionData() { + data class AppShortcut( + val packageName: String?, + val shortcutTitle: String, + val uri: String, + val savedAppName: String? = null, + ) : ActionData() { override val id: ActionId = ActionId.APP_SHORTCUT override fun compareTo(other: ActionData) = when (other) { @@ -251,6 +256,11 @@ sealed class ActionData : Comparable { override val id = ActionId.CYCLE_KEYBOARD_LANGUAGE } + @Serializable + data object CycleKeyboard : ActionData() { + override val id = ActionId.CYCLE_KEYBOARD + } + @Serializable sealed class DoNotDisturb : ActionData() { @@ -331,6 +341,7 @@ sealed class ActionData : Comparable { @Serializable sealed class ControlMediaForApp : ActionData() { abstract val packageName: String + abstract val savedAppName: String? override fun compareTo(other: ActionData) = when (other) { is ControlMediaForApp -> compareValuesBy( @@ -344,42 +355,66 @@ sealed class ActionData : Comparable { } @Serializable - data class Pause(override val packageName: String) : ControlMediaForApp() { + data class Pause( + override val packageName: String, + override val savedAppName: String? = null, + ) : ControlMediaForApp() { override val id = ActionId.PAUSE_MEDIA_PACKAGE } @Serializable - data class Play(override val packageName: String) : ControlMediaForApp() { + data class Play( + override val packageName: String, + override val savedAppName: String? = null, + ) : ControlMediaForApp() { override val id = ActionId.PLAY_MEDIA_PACKAGE } @Serializable - data class PlayPause(override val packageName: String) : ControlMediaForApp() { + data class PlayPause( + override val packageName: String, + override val savedAppName: String? = null, + ) : ControlMediaForApp() { override val id = ActionId.PLAY_PAUSE_MEDIA_PACKAGE } @Serializable - data class NextTrack(override val packageName: String) : ControlMediaForApp() { + data class NextTrack( + override val packageName: String, + override val savedAppName: String? = null, + ) : ControlMediaForApp() { override val id = ActionId.NEXT_TRACK_PACKAGE } @Serializable - data class PreviousTrack(override val packageName: String) : ControlMediaForApp() { + data class PreviousTrack( + override val packageName: String, + override val savedAppName: String? = null, + ) : ControlMediaForApp() { override val id = ActionId.PREVIOUS_TRACK_PACKAGE } @Serializable - data class FastForward(override val packageName: String) : ControlMediaForApp() { + data class FastForward( + override val packageName: String, + override val savedAppName: String? = null, + ) : ControlMediaForApp() { override val id = ActionId.FAST_FORWARD_PACKAGE } @Serializable - data class Rewind(override val packageName: String) : ControlMediaForApp() { + data class Rewind( + override val packageName: String, + override val savedAppName: String? = null, + ) : ControlMediaForApp() { override val id = ActionId.REWIND_PACKAGE } @Serializable - data class Stop(override val packageName: String) : ControlMediaForApp() { + data class Stop( + override val packageName: String, + override val savedAppName: String? = null, + ) : ControlMediaForApp() { override val id = ActionId.STOP_MEDIA_PACKAGE } @@ -387,6 +422,7 @@ sealed class ActionData : Comparable { data class StepForward( override val packageName: String, val stepDurationMs: Long? = null, + override val savedAppName: String? = null, ) : ControlMediaForApp() { override val id = ActionId.STEP_FORWARD_PACKAGE } @@ -395,6 +431,7 @@ sealed class ActionData : Comparable { data class StepBackward( override val packageName: String, val stepDurationMs: Long? = null, + override val savedAppName: String? = null, ) : ControlMediaForApp() { override val id = ActionId.STEP_BACKWARD_PACKAGE } @@ -980,6 +1017,11 @@ sealed class ActionData : Comparable { } } + @Serializable + data class Vibrate(val effect: VibrateEffect) : ActionData() { + override val id: ActionId = ActionId.VIBRATE + } + @Serializable data object AnswerCall : ActionData() { override val id: ActionId = ActionId.ANSWER_PHONE_CALL diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt index 74ba3d8c49..90fc5e687f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapper.kt @@ -3,6 +3,7 @@ package io.github.sds100.keymapper.base.actions import android.util.Base64 import androidx.core.net.toUri import io.github.sds100.keymapper.base.actions.talkback.TalkBackGestureType +import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult @@ -73,10 +74,15 @@ object ActionDataEntityMapper { ActionEntity.Type.CREATE_NOTIFICATION -> ActionId.CREATE_NOTIFICATION ActionEntity.Type.TOAST -> ActionId.TOAST + + ActionEntity.Type.VIBRATE -> ActionId.VIBRATE } return when (actionId) { - ActionId.APP -> ActionData.App(packageName = entity.data) + ActionId.APP -> ActionData.App( + packageName = entity.data, + savedAppName = entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull(), + ) ActionId.APP_SHORTCUT -> { val packageName = @@ -90,6 +96,7 @@ object ActionDataEntityMapper { packageName = packageName, shortcutTitle = shortcutTitle, uri = entity.data, + savedAppName = entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull(), ) } @@ -448,36 +455,46 @@ object ActionDataEntityMapper { ActionEntity.EXTRA_STEP_MEDIA_DURATION, ).valueOrNull()?.toLongOrNull() + val savedAppName = entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull() + when (actionId) { ActionId.PAUSE_MEDIA_PACKAGE -> - ActionData.ControlMediaForApp.Pause(packageName) + ActionData.ControlMediaForApp.Pause(packageName, savedAppName) ActionId.PLAY_MEDIA_PACKAGE -> - ActionData.ControlMediaForApp.Play(packageName) + ActionData.ControlMediaForApp.Play(packageName, savedAppName) ActionId.PLAY_PAUSE_MEDIA_PACKAGE -> - ActionData.ControlMediaForApp.PlayPause(packageName) + ActionData.ControlMediaForApp.PlayPause(packageName, savedAppName) ActionId.NEXT_TRACK_PACKAGE -> - ActionData.ControlMediaForApp.NextTrack(packageName) + ActionData.ControlMediaForApp.NextTrack(packageName, savedAppName) ActionId.PREVIOUS_TRACK_PACKAGE -> - ActionData.ControlMediaForApp.PreviousTrack(packageName) + ActionData.ControlMediaForApp.PreviousTrack(packageName, savedAppName) ActionId.FAST_FORWARD_PACKAGE -> - ActionData.ControlMediaForApp.FastForward(packageName) + ActionData.ControlMediaForApp.FastForward(packageName, savedAppName) ActionId.REWIND_PACKAGE -> - ActionData.ControlMediaForApp.Rewind(packageName) + ActionData.ControlMediaForApp.Rewind(packageName, savedAppName) ActionId.STOP_MEDIA_PACKAGE -> - ActionData.ControlMediaForApp.Stop(packageName) + ActionData.ControlMediaForApp.Stop(packageName, savedAppName) ActionId.STEP_FORWARD_PACKAGE -> - ActionData.ControlMediaForApp.StepForward(packageName, stepDurationMs) + ActionData.ControlMediaForApp.StepForward( + packageName, + stepDurationMs, + savedAppName, + ) ActionId.STEP_BACKWARD_PACKAGE -> - ActionData.ControlMediaForApp.StepBackward(packageName, stepDurationMs) + ActionData.ControlMediaForApp.StepBackward( + packageName, + stepDurationMs, + savedAppName, + ) else -> throw Exception("don't know how to create system action for $actionId") } @@ -645,6 +662,8 @@ object ActionDataEntityMapper { ActionId.CYCLE_KEYBOARD_LANGUAGE -> ActionData.CycleKeyboardLanguage + ActionId.CYCLE_KEYBOARD -> ActionData.CycleKeyboard + ActionId.TEXT_CUT -> ActionData.CutText ActionId.TEXT_COPY -> ActionData.CopyText @@ -723,6 +742,39 @@ object ActionDataEntityMapper { ) } + ActionId.VIBRATE -> { + val mode = entity.extras.getData(ActionEntity.EXTRA_VIBRATE_MODE) + .valueOrNull() ?: return null + + val effect = when (mode) { + ActionEntity.VIBRATE_MODE_DURATION -> { + val durationMs = entity.extras.getData( + ActionEntity.EXTRA_VIBRATE_DURATION_MS, + ).valueOrNull()?.toLongOrNull() ?: return null + + VibrateEffect.CustomDuration(durationMs) + } + + ActionEntity.VIBRATE_MODE_PREDEFINED -> { + val typeString = entity.extras.getData( + ActionEntity.EXTRA_VIBRATE_EFFECT_TYPE, + ).valueOrNull() ?: return null + + val type = try { + VibrateEffect.PredefinedType.valueOf(typeString) + } catch (_: IllegalArgumentException) { + return null + } + + VibrateEffect.Predefined(type) + } + + else -> return null + } + + ActionData.Vibrate(effect = effect) + } + ActionId.ANSWER_PHONE_CALL -> ActionData.AnswerCall ActionId.END_PHONE_CALL -> ActionData.EndCall @@ -996,6 +1048,7 @@ object ActionDataEntityMapper { is ActionData.ModifySetting -> ActionEntity.Type.MODIFY_SETTING is ActionData.CreateNotification -> ActionEntity.Type.CREATE_NOTIFICATION is ActionData.Toast -> ActionEntity.Type.TOAST + is ActionData.Vibrate -> ActionEntity.Type.VIBRATE else -> ActionEntity.Type.SYSTEM_ACTION } @@ -1085,6 +1138,8 @@ object ActionDataEntityMapper { is ActionData.Toast -> data.message + is ActionData.Vibrate -> "" + is ActionData.HttpRequest -> SYSTEM_ACTION_ID_MAP[data.id]!! is ActionData.ControlMediaForApp.Rewind -> SYSTEM_ACTION_ID_MAP[data.id]!! @@ -1134,11 +1189,14 @@ object ActionDataEntityMapper { } }.toList() - is ActionData.App -> emptyList() + is ActionData.App -> listOfNotNull( + data.savedAppName?.let { EntityExtra(ActionEntity.EXTRA_APP_NAME, it) }, + ) is ActionData.AppShortcut -> sequence { yield(EntityExtra(ActionEntity.EXTRA_SHORTCUT_TITLE, data.shortcutTitle)) data.packageName?.let { yield(EntityExtra(ActionEntity.EXTRA_PACKAGE_NAME, it)) } + data.savedAppName?.let { yield(EntityExtra(ActionEntity.EXTRA_APP_NAME, it)) } }.toList() is ActionData.PhoneCall -> emptyList() @@ -1168,6 +1226,7 @@ object ActionDataEntityMapper { data.stepDurationMs?.let { add(EntityExtra(ActionEntity.EXTRA_STEP_MEDIA_DURATION, it.toString())) } + data.savedAppName?.let { add(EntityExtra(ActionEntity.EXTRA_APP_NAME, it)) } } is ActionData.ControlMediaForApp.StepBackward -> buildList { @@ -1175,10 +1234,12 @@ object ActionDataEntityMapper { data.stepDurationMs?.let { add(EntityExtra(ActionEntity.EXTRA_STEP_MEDIA_DURATION, it.toString())) } + data.savedAppName?.let { add(EntityExtra(ActionEntity.EXTRA_APP_NAME, it)) } } - is ActionData.ControlMediaForApp -> listOf( + is ActionData.ControlMediaForApp -> listOfNotNull( EntityExtra(ActionEntity.EXTRA_PACKAGE_NAME, data.packageName), + data.savedAppName?.let { EntityExtra(ActionEntity.EXTRA_APP_NAME, it) }, ) is ActionData.ControlMedia.StepForward -> buildList { @@ -1436,6 +1497,21 @@ object ActionDataEntityMapper { EntityExtra(ActionEntity.EXTRA_TOAST_DURATION, data.duration.name), ) + is ActionData.Vibrate -> when (val effect = data.effect) { + is VibrateEffect.CustomDuration -> listOf( + EntityExtra(ActionEntity.EXTRA_VIBRATE_MODE, ActionEntity.VIBRATE_MODE_DURATION), + EntityExtra( + ActionEntity.EXTRA_VIBRATE_DURATION_MS, + effect.durationMs.toString(), + ), + ) + + is VibrateEffect.Predefined -> listOf( + EntityExtra(ActionEntity.EXTRA_VIBRATE_MODE, ActionEntity.VIBRATE_MODE_PREDEFINED), + EntityExtra(ActionEntity.EXTRA_VIBRATE_EFFECT_TYPE, effect.predefinedType.name), + ) + } + is ActionData.TalkBackGesture -> listOf( EntityExtra(ActionEntity.EXTRA_TALKBACK_GESTURE_TYPE, data.gesture.name), ) @@ -1600,6 +1676,7 @@ object ActionDataEntityMapper { ActionId.SWITCH_KEYBOARD to "switch_keyboard", ActionId.CYCLE_KEYBOARD_LANGUAGE to "cycle_keyboard_language", + ActionId.CYCLE_KEYBOARD to "cycle_keyboard", ActionId.TOGGLE_AIRPLANE_MODE to "toggle_airplane_mode", ActionId.ENABLE_AIRPLANE_MODE to "enable_airplane_mode", diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt index 6acbd9374d..df9ceb752e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionErrorSnapshot.kt @@ -176,13 +176,13 @@ class LazyActionErrorSnapshot( when (action) { is ActionData.App -> { - return getAppError(action.packageName) + return getAppError(action.packageName, action.savedAppName) } is ActionData.AppShortcut -> { action.packageName ?: return null - return getAppError(action.packageName) + return getAppError(action.packageName, action.savedAppName) } is ActionData.Sound.SoundFile -> { @@ -272,14 +272,14 @@ class LazyActionErrorSnapshot( return null } - private fun getAppError(packageName: String): KMError? { + private fun getAppError(packageName: String, appName: String?): KMError? { if (isAppEnabledCache.contains(packageName) && isAppInstalledCache.contains(packageName)) { if (isAppEnabledCache[packageName] == false) { - return KMError.AppDisabled(packageName) + return KMError.AppDisabled(packageName, appName) } if (isAppInstalledCache[packageName] == false) { - return KMError.AppDisabled(packageName) + return KMError.AppDisabled(packageName, appName) } return null @@ -292,12 +292,12 @@ class LazyActionErrorSnapshot( isAppEnabledCache[packageName] = isEnabled if (!isEnabled) { - return KMError.AppDisabled(packageName) + return KMError.AppDisabled(packageName, appName) } } if (!isAppInstalled) { - return KMError.AppNotFound(packageName) + return KMError.AppNotFound(packageName, appName) } return null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt index 3dd5c08179..c892fbffc3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionId.kt @@ -130,6 +130,7 @@ enum class ActionId { SWITCH_KEYBOARD, CYCLE_KEYBOARD_LANGUAGE, + CYCLE_KEYBOARD, TOGGLE_AIRPLANE_MODE, ENABLE_AIRPLANE_MODE, @@ -151,6 +152,7 @@ enum class ActionId { DISMISS_ALL_NOTIFICATIONS, CREATE_NOTIFICATION, TOAST, + VIBRATE, ANSWER_PHONE_CALL, END_PHONE_CALL, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt index c8493d9604..80be591ffb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItem.kt @@ -1,206 +1,390 @@ package io.github.sds100.keymapper.base.actions -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandIn +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ClearAll +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.ArrowDownward -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.rounded.DragHandle -import androidx.compose.material.icons.rounded.PlayCircleOutline -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.FilledTonalButton +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.KeyboardDoubleArrowRight +import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.TouchApp import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.CustomAccessibilityAction -import androidx.compose.ui.semantics.customActions -import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.google.accompanist.drawablepainter.rememberDrawablePainter import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.LinkType +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.CompactErrorButton import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.compose.DragDropState +import io.github.sds100.keymapper.base.utils.ui.compose.EXPAND_ANIMATION_DURATION +import io.github.sds100.keymapper.base.utils.ui.compose.ExpandableDraggableCard +import io.github.sds100.keymapper.base.utils.ui.compose.horizontalFadingEdges import io.github.sds100.keymapper.base.utils.ui.drawable +/** + * The Material 3 alpha for disabled content. + */ +private const val DISABLED_ALPHA = 0.38f + +/** + * The summary fades out in the first half of the animation so it is invisible before the shrink + * clips it. When expanding, the space is made first and the text fades in during the second half. + */ +private val summaryEnterTransition: EnterTransition = + expandVertically( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top, + ) + fadeIn( + tween( + durationMillis = EXPAND_ANIMATION_DURATION / 2, + delayMillis = EXPAND_ANIMATION_DURATION / 2, + easing = FastOutSlowInEasing, + ), + ) + +private val summaryExitTransition: ExitTransition = + fadeOut(tween(EXPAND_ANIMATION_DURATION / 2, easing = FastOutSlowInEasing)) + + shrinkVertically( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.Top, + ) + +/** + * Animate both width and height so the title row does not jump when the rename button appears. + */ +private val renameButtonEnterTransition: EnterTransition = + fadeIn(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) + + expandIn( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + expandFrom = Alignment.CenterStart, + ) + +private val renameButtonExitTransition: ExitTransition = + fadeOut(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) + + shrinkOut( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.CenterStart, + ) + @Composable fun ActionListItem( modifier: Modifier = Modifier, model: ActionListItemModel, index: Int, + isExpanded: Boolean, isDraggingEnabled: Boolean = false, isDragging: Boolean, isReorderingEnabled: Boolean, dragDropState: DragDropState? = null, + onExpandedChange: (Boolean) -> Unit = {}, onEditClick: () -> Unit = {}, onRemoveClick: () -> Unit = {}, onFixClick: () -> Unit = {}, onTestClick: () -> Unit = {}, + onDuplicateClick: () -> Unit = {}, + onRenameClick: () -> Unit = {}, + onEnabledChange: (Boolean) -> Unit = {}, onMoveUp: (() -> Unit)? = null, onMoveDown: (() -> Unit)? = null, ) { - val draggableState = rememberDraggableState { - dragDropState?.onDrag(Offset(0f, it)) + // Only grey out a disabled action when it is collapsed so the expanded options stay readable. + val contentAlpha by animateFloatAsState( + targetValue = if (model.isEnabled || isExpanded) 1f else DISABLED_ALPHA, + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + ) + + ExpandableDraggableCard( + modifier = modifier, + key = model.id, + isExpanded = isExpanded, + isDragging = isDragging, + isReorderingEnabled = isReorderingEnabled, + isDraggingEnabled = isDraggingEnabled, + dragDropState = dragDropState, + dragHandleContentDescription = stringResource(R.string.drag_handle_for, model.title), + expandContentDescription = stringResource(R.string.action_list_item_expand), + collapseContentDescription = stringResource(R.string.action_list_item_collapse), + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha), + onExpandedChange = onExpandedChange, + onMoveUp = onMoveUp, + onMoveDown = onMoveDown, + headerContent = { expanded -> + ActionIcon(modifier = Modifier.size(20.dp), icon = model.icon) + + Spacer(Modifier.width(8.dp)) + + HeaderText( + modifier = Modifier.weight(1f).animateContentSize().padding(vertical = 8.dp), + model = model, + isExpanded = expanded, + onRenameClick = onRenameClick, + ) + }, + expandedContent = { + ExpandedContent( + modifier = Modifier.padding( + start = 16.dp, + end = 8.dp, + bottom = 8.dp, + top = 8.dp, + ), + model = model, + onEditClick = onEditClick, + onRemoveClick = onRemoveClick, + onFixClick = onFixClick, + onTestClick = onTestClick, + onDuplicateClick = onDuplicateClick, + onEnabledChange = onEnabledChange, + ) + }, + ) +} + +@Composable +private fun ActionIcon(modifier: Modifier = Modifier, icon: ComposeIconInfo) { + when (icon) { + is ComposeIconInfo.Vector -> Icon( + modifier = modifier, + imageVector = icon.imageVector, + contentDescription = null, + ) + + is ComposeIconInfo.Drawable -> { + val painter = rememberDrawablePainter(icon.drawable) + Icon( + modifier = modifier, + painter = painter, + contentDescription = null, + tint = Color.Unspecified, + ) + } } +} - val moveUpLabel = stringResource(R.string.accessibility_action_move_up) - val moveDownLabel = stringResource(R.string.accessibility_action_move_down) +@Composable +private fun HeaderText( + modifier: Modifier = Modifier, + model: ActionListItemModel, + isExpanded: Boolean, + onRenameClick: () -> Unit, +) { + Column(modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = Modifier + .weight(1f, fill = false), + text = model.title, + style = MaterialTheme.typography.bodyMedium, + fontStyle = if (model.isCustomName) FontStyle.Italic else FontStyle.Normal, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) - Column(modifier = modifier.fillMaxWidth()) { - ElevatedCard( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .height(IntrinsicSize.Min) - .padding(start = 16.dp, end = 16.dp) - .semantics { - if (isReorderingEnabled) { - customActions = buildList { - onMoveUp?.let { action -> - add( - CustomAccessibilityAction(moveUpLabel) { - action() - true - }, - ) - } - onMoveDown?.let { action -> - add( - CustomAccessibilityAction(moveDownLabel) { - action() - true - }, - ) - } - } + AnimatedVisibility( + visible = isExpanded, + enter = renameButtonEnterTransition, + exit = renameButtonExitTransition, + ) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + IconButton(onClick = onRenameClick) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Rounded.Edit, + contentDescription = stringResource(R.string.action_list_item_rename), + ) } - }, - colors = CardDefaults.elevatedCardColors( - containerColor = if (isDragging) { - MaterialTheme.colorScheme.surfaceContainerHighest - } else { - MaterialTheme.colorScheme.surfaceContainer - }, - ), + } + } + } + + AnimatedVisibility( + visible = !isExpanded, + enter = summaryEnterTransition, + exit = summaryExitTransition, ) { - Row( - modifier = Modifier.fillMaxSize(), - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(Modifier.width(8.dp)) - - if (isReorderingEnabled) { - Icon( - modifier = Modifier - .size(24.dp) - .draggable( - state = draggableState, - enabled = isDraggingEnabled, - orientation = Orientation.Vertical, - startDragImmediately = true, - onDragStarted = { offset -> - dragDropState?.onDragStart(index, offset) - }, - onDragStopped = { dragDropState?.onDragInterrupted() }, - ), - imageVector = Icons.Rounded.DragHandle, - contentDescription = stringResource(R.string.drag_handle_for, model.text), - tint = MaterialTheme.colorScheme.onSurface, + Column { + if (model.summary != null) { + Text( + text = model.summary, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } - Spacer(Modifier.width(8.dp)) + if (model.error != null && model.isEnabled) { + Text( + text = model.error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} - if (model.error == null) { - when (model.icon) { - is ComposeIconInfo.Vector -> Icon( - modifier = Modifier.size(24.dp), - imageVector = model.icon.imageVector, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - ) +@Composable +private fun ExpandedContent( + modifier: Modifier = Modifier, + model: ActionListItemModel, + onEditClick: () -> Unit, + onRemoveClick: () -> Unit, + onFixClick: () -> Unit, + onTestClick: () -> Unit, + onDuplicateClick: () -> Unit, + onEnabledChange: (Boolean) -> Unit, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (model.error != null) { + Row( + modifier = Modifier.padding(end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = model.error, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + + if (model.isErrorFixable) { + Spacer(Modifier.width(8.dp)) - is ComposeIconInfo.Drawable -> { - val painter = rememberDrawablePainter(model.icon.drawable) - Icon( - modifier = Modifier.size(24.dp), - painter = painter, - contentDescription = null, - tint = Color.Unspecified, - ) - } + CompactErrorButton(onClick = onFixClick) { + Text(stringResource(R.string.button_fix)) } } + } + } - val primaryText = model.text + if (model.showRepeat) { + OptionRow( + modifier = Modifier + .fillMaxWidth(), + icon = Icons.Rounded.Repeat, + text = model.repeatText ?: stringResource(R.string.action_list_no_repeat), + isSet = model.repeatText != null, + ) + } - Spacer(Modifier.width(8.dp)) + OptionRow( + modifier = Modifier + .fillMaxWidth(), + icon = Icons.Rounded.KeyboardDoubleArrowRight, + text = model.burstText ?: stringResource(R.string.action_list_no_burst), + isSet = model.burstText != null, + ) - TextColumn( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp), - primaryText = primaryText, - secondaryText = model.secondaryText, - errorText = model.error, - ) + if (model.showHoldDown) { + OptionRow( + modifier = Modifier + .fillMaxWidth(), + icon = Icons.Rounded.TouchApp, + text = model.holdDownText ?: stringResource(R.string.action_list_no_hold_down), + isSet = model.holdDownText != null, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.switch_enabled), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.width(16.dp)) + + Switch(checked = model.isEnabled, onCheckedChange = onEnabledChange) + + Spacer(Modifier.width(16.dp)) + + val scrollState = rememberScrollState() + + // Scroll to the end initially so the delete button is always visible without + // needing to scroll. + LaunchedEffect(Unit) { + scrollState.scrollTo(scrollState.maxValue) + } + + Row( + modifier = Modifier + .weight(1f) + .horizontalFadingEdges(scrollState) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.End, + ) { CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 16.dp, ) { - if (model.error != null && model.isErrorFixable) { - FilledTonalButton( - modifier = Modifier.padding(start = 8.dp, end = 8.dp), - onClick = onFixClick, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, + IconButton(onClick = onDuplicateClick) { + Icon( + imageVector = Icons.Outlined.ContentCopy, + contentDescription = stringResource( + R.string.action_list_item_duplicate, ), - ) { - Text( - text = stringResource(R.string.button_fix), - ) - } + tint = MaterialTheme.colorScheme.onSurface, + ) } - if (model.error == null) { - IconButton(onClick = onTestClick) { - Icon( - imageVector = Icons.Rounded.PlayCircleOutline, - contentDescription = stringResource(R.string.action_list_item_test), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp), - ) - } + IconButton(onClick = onTestClick) { + Icon( + imageVector = Icons.Outlined.PlayArrow, + contentDescription = stringResource(R.string.action_list_item_test), + tint = MaterialTheme.colorScheme.onSurface, + ) } IconButton(onClick = onEditClick) { @@ -208,135 +392,213 @@ fun ActionListItem( imageVector = Icons.Outlined.Settings, contentDescription = stringResource(R.string.action_list_item_edit), tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp), ) } IconButton(onClick = onRemoveClick) { Icon( - imageVector = Icons.Rounded.Clear, + imageVector = Icons.Outlined.Delete, contentDescription = stringResource(R.string.action_list_item_remove), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.error, ) } } } } - - if (model.linkType == LinkType.HIDDEN) { - // Important! Show an empty spacer so the height of the card remains constant - // while dragging. If the height changes while dragging it can lead to janky - // behavior. - Spacer(Modifier.height(32.dp)) - } else { - Spacer(Modifier.height(4.dp)) - - Icon( - imageVector = when (model.linkType) { - LinkType.ARROW -> Icons.Rounded.ArrowDownward - LinkType.PLUS -> Icons.Rounded.Add - LinkType.HIDDEN -> Icons.Rounded.Add - }, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .size(24.dp) - .align(Alignment.CenterHorizontally), - ) - Spacer(Modifier.height(4.dp)) - } } } @Composable -private fun TextColumn( +private fun OptionRow( modifier: Modifier = Modifier, - primaryText: String, - secondaryText: String? = null, - errorText: String? = null, + icon: ImageVector, + text: String, + isSet: Boolean, ) { - Column( + val color = if (isSet) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + } + + Row( modifier = modifier, + verticalAlignment = Alignment.CenterVertically, ) { + Icon( + modifier = Modifier.size(16.dp), + imageVector = icon, + contentDescription = null, + tint = color, + ) + + Spacer(Modifier.width(16.dp)) + Text( - text = primaryText, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + text = text, + style = MaterialTheme.typography.labelSmall, + color = color, ) - if (secondaryText != null) { - Text( - text = secondaryText, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - if (errorText != null) { - Text( - text = errorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } } } @Preview @Composable -private fun NoDragPreview() { - ActionListItem( - model = ActionListItemModel( - id = "id", - text = "Dismiss most recent notification", - secondaryText = "Repeat until released", - error = "Denied notification access permission", - isErrorFixable = true, - icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), - ), - isDragging = false, - isReorderingEnabled = false, - index = 0, - ) +private fun CollapsedErrorPreview() { + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "id", + title = "Dismiss most recent notification", + summary = "Repeat until released", + error = "Denied notification access permission", + isErrorFixable = true, + icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), + ), + isExpanded = false, + isDragging = false, + isReorderingEnabled = true, + index = 0, + ) + } } @Preview @Composable -private fun NoDragOneLinePreview() { - ActionListItem( - model = ActionListItemModel( - id = "id", - text = "Clear all", - secondaryText = null, - error = null, - isErrorFixable = true, - icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), - ), - isDragging = false, - isReorderingEnabled = false, - index = 0, - ) +private fun CollapsedErrorDisabledPreview() { + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "id", + title = "Dismiss most recent notification", + summary = "Repeat until released", + error = "Denied notification access permission", + isErrorFixable = true, + icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), + isEnabled = false, + ), + isExpanded = false, + isDragging = false, + isReorderingEnabled = true, + index = 0, + ) + } +} + +@Preview +@Composable +private fun CollapsedOneLinePreview() { + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "id", + title = "Clear all", + icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), + ), + isExpanded = false, + isDragging = false, + isReorderingEnabled = false, + index = 0, + ) + } +} + +@PreviewLightDark +@Composable +private fun ExpandedPreview() { + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "id", + title = "Open magnifier", + isCustomName = true, + error = "A Key Mapper keyboard must be enabled!", + isErrorFixable = true, + showRepeat = true, + repeatText = "Repeat 5x after 400ms every 50ms until pressed again", + showHoldDown = true, + burstText = null, + holdDownText = "Hold down for 1000ms", + icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), + ), + isExpanded = true, + isDragging = false, + isReorderingEnabled = true, + index = 0, + ) + } +} + +@Preview(widthDp = 300) +@Composable +private fun ExpandedPreviewSmall() { + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "id", + title = "Open magnifier", + isCustomName = true, + error = "A Key Mapper keyboard must be enabled!", + isErrorFixable = true, + showRepeat = true, + repeatText = "Repeat 5x after 400ms every 50ms until pressed again", + showHoldDown = true, + burstText = null, + holdDownText = "Hold down for 1000ms", + icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), + ), + isExpanded = true, + isDragging = false, + isReorderingEnabled = true, + index = 0, + ) + } } @Preview @Composable -private fun DragDrawablePreview() { +private fun ExpandedNoErrorPreview() { + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "id", + title = "Open magnifier", + isCustomName = true, + error = null, + isErrorFixable = true, + showRepeat = true, + repeatText = "Repeat 5x after 400ms every 50ms until pressed again", + showHoldDown = true, + burstText = null, + holdDownText = "Hold down for 1000ms", + icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), + ), + isExpanded = true, + isDragging = false, + isReorderingEnabled = true, + index = 0, + ) + } +} + +@Preview +@Composable +private fun DisabledDrawablePreview() { val drawable = LocalContext.current.drawable(R.mipmap.ic_launcher_round) - ActionListItem( - model = ActionListItemModel( - id = "id", - text = "Dismiss most recent notification", - secondaryText = "Repeat until released", - error = null, - isErrorFixable = true, - icon = ComposeIconInfo.Drawable(drawable), - ), - isDragging = false, - isReorderingEnabled = true, - index = 0, - ) + KeyMapperTheme { + ActionListItem( + model = ActionListItemModel( + id = "id", + title = "Dismiss most recent notification", + summary = "Repeat until released", + isEnabled = false, + icon = ComposeIconInfo.Drawable(drawable), + ), + isExpanded = false, + isDragging = false, + isReorderingEnabled = true, + index = 0, + ) + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItemModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItemModel.kt new file mode 100644 index 0000000000..633b9c249c --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionListItemModel.kt @@ -0,0 +1,38 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo + +data class ActionListItemModel( + val id: String, + val icon: ComposeIconInfo, + val title: String, + val isExpanded: Boolean = false, + val isCustomName: Boolean = false, + val isEnabled: Boolean = true, + /** + * A summary of the options that is shown when the item is collapsed. + */ + val summary: String? = null, + val error: String? = null, + val isErrorFixable: Boolean = true, + val showRepeat: Boolean = false, + /** + * Null if the action does not repeat. + */ + val repeatText: String? = null, + /** + * Null if the action is not performed in a burst. + */ + val burstText: String? = null, + val showHoldDown: Boolean = false, + /** + * Null if the action is not held down. + */ + val holdDownText: String? = null, + /** + * Whether to show the chip for setting the delay before the next action. A delay after the + * last action does nothing. + */ + val showDelayChip: Boolean = false, + val delayBeforeNextAction: Int? = null, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt index f156be2ceb..df5d5eb93d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsBottomSheet.kt @@ -9,32 +9,43 @@ 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.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.HelpOutline +import androidx.compose.material.icons.rounded.Abc +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.KeyboardDoubleArrowRight +import androidx.compose.material.icons.rounded.Repeat +import androidx.compose.material.icons.rounded.TouchApp import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R @@ -42,9 +53,10 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.utils.ui.SliderMaximums import io.github.sds100.keymapper.base.utils.ui.SliderMinimums import io.github.sds100.keymapper.base.utils.ui.SliderStepSizes -import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +import io.github.sds100.keymapper.base.utils.ui.compose.TextFieldDialog import io.github.sds100.keymapper.base.utils.ui.compose.openUriSafe import kotlinx.coroutines.launch @@ -68,21 +80,53 @@ fun ActionOptionsBottomSheet( val ctx = LocalContext.current val helpUrl = stringResource(R.string.url_keymap_action_options_guide) val scope = rememberCoroutineScope() - - @Suppress("ktlint:standard:max-line-length") + var showCustomNameDialog by rememberSaveable { mutableStateOf(false) } + + if (showCustomNameDialog) { + TextFieldDialog( + title = stringResource(R.string.action_options_custom_name_dialog_title), + submitButtonText = stringResource(R.string.pos_save), + initialText = state.title, + onSubmitClick = { newText -> + callback.onCustomNameChanged(newText) + null + }, + onDismissRequest = { showCustomNameDialog = false }, + ) + } Column(modifier = Modifier.verticalScroll(rememberScrollState())) { Spacer(modifier = Modifier.height(12.dp)) + Box(modifier = Modifier.fillMaxWidth()) { - Text( - modifier = Modifier.align(Alignment.Center), - textAlign = TextAlign.Center, - text = stringResource(R.string.action_options_title), - style = MaterialTheme.typography.headlineMedium, - ) + Row( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 56.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f, fill = false), + textAlign = TextAlign.Center, + text = state.title, + style = MaterialTheme.typography.headlineMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false, + ) + + IconButton(onClick = { showCustomNameDialog = true }) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = stringResource( + R.string.action_options_custom_name_dialog_title, + ), + ) + } + } IconButton( modifier = Modifier - .align(Alignment.TopEnd) + .align(Alignment.CenterEnd) .padding(horizontal = 8.dp), onClick = { uriHandler.openUriSafe(ctx, helpUrl) }, ) { @@ -93,190 +137,325 @@ fun ActionOptionsBottomSheet( } } + Spacer(modifier = Modifier.height(16.dp)) + + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = state.actionTypeIcon, + text = stringResource(R.string.action_options_type_header, state.actionTypeTitle), + ) + Spacer(modifier = Modifier.height(8.dp)) - Row( + FlowRow( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { if (state.showEditButton) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = callback::onEditClick, - ) { - Text(stringResource(R.string.button_edit_action)) + FilledTonalButton(onClick = callback::onEditClick) { + Text(stringResource(R.string.action_options_customize)) } - Spacer(Modifier.width(16.dp)) } - OutlinedButton(modifier = Modifier.weight(1f), onClick = callback::onReplaceClick) { - Text(stringResource(R.string.button_replace_action)) + FilledTonalButton(onClick = callback::onReplaceClick) { + Text(stringResource(R.string.action_options_swap)) } } if (state.showRepeat) { - Spacer(Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) + RepeatOptions(state = state, callback = callback) + } - CheckBoxText( - modifier = Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - text = stringResource(R.string.flag_repeat_actions), - isChecked = state.isRepeatChecked, - onCheckedChange = callback::onRepeatCheckedChange, - ) + Spacer(modifier = Modifier.height(16.dp)) + BurstOptions(state = state, callback = callback) + + if (state.showHoldDown) { + Spacer(modifier = Modifier.height(16.dp)) + HoldDownOptions(state = state, callback = callback) } - if (state.showRepeatRateWarning) { - Spacer(Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.End, + ) { + Button( + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, ) { - Spacer(Modifier.width(16.dp)) Icon( - Icons.Rounded.Warning, + modifier = Modifier.size(ButtonDefaults.IconSize), + imageVector = Icons.Rounded.Check, contentDescription = null, - tint = MaterialTheme.colorScheme.error, - ) - Spacer(Modifier.width(8.dp)) - Text( - modifier = Modifier.weight(1f), - text = stringResource(R.string.action_repeat_rate_warning), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.labelLarge, ) - Spacer(Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.button_done)) } } - if (state.showRepeatRate) { - Spacer(Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} - SliderOptionText( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - title = stringResource(R.string.extra_label_repeat_rate), - defaultValue = state.defaultRepeatRate.toFloat(), - value = state.repeatRate.toFloat(), - valueText = { "${it.toInt()} ms" }, - onValueChange = { callback.onRepeatRateChanged(it.toInt()) }, - valueRange = 0f..SliderMaximums.ACTION_REPEAT_RATE.toFloat(), - stepSize = SliderStepSizes.ACTION_REPEAT_RATE, - ) - } +@Composable +private fun RepeatOptions(state: ActionOptionsState, callback: ActionOptionsBottomSheetCallback) { + Column { + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.Repeat, + text = stringResource(R.string.action_options_repeat_header), + ) - if (state.showRepeatLimit) { - Spacer(Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - val noLimitString = stringResource(R.string.button_slider_repeat_no_limit) + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(R.string.action_options_repeat_description), + style = MaterialTheme.typography.bodyMedium, + ) - SliderOptionText( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - title = stringResource(R.string.extra_label_repeat_limit), - defaultValue = state.defaultRepeatLimit.toFloat(), - value = state.repeatLimit.toFloat(), - valueText = { value -> - if (value.toInt() == Int.MAX_VALUE) { - noLimitString - } else { - "${value.toInt()}x" - } - }, - onValueChange = { callback.onRepeatLimitChanged(it.toInt()) }, - valueRange = 1f..SliderMaximums.ACTION_REPEAT_LIMIT.toFloat(), - stepSize = SliderStepSizes.ACTION_REPEAT_LIMIT, - ) - } + val selectedMode: RepeatMode? = if (state.isRepeatChecked) { + state.repeatMode + } else { + null + } - if (state.showRepeatDelay) { - Spacer(Modifier.height(8.dp)) + RadioButtonText( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_dont_repeat), + isSelected = selectedMode == null, + onSelected = { callback.onSelectRepeatMode(null) }, + ) - SliderOptionText( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - title = stringResource(R.string.extra_label_repeat_delay), - defaultValue = state.defaultRepeatDelay.toFloat(), - value = state.repeatDelay.toFloat(), - valueText = { "${it.toInt()} ms" }, - onValueChange = { callback.onRepeatDelayChanged(it.toInt()) }, - valueRange = 0f..SliderMaximums.ACTION_REPEAT_DELAY.toFloat(), - stepSize = SliderStepSizes.ACTION_REPEAT_DELAY, - ) - } + RadioButtonText( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_repeat_limit_reached), + isSelected = selectedMode == RepeatMode.LIMIT_REACHED, + isEnabled = state.allowedRepeatModes.contains(RepeatMode.LIMIT_REACHED), + onSelected = { callback.onSelectRepeatMode(RepeatMode.LIMIT_REACHED) }, + ) + + val isUntilReleasedAllowed = + state.allowedRepeatModes.contains(RepeatMode.TRIGGER_RELEASED) + + RadioButtonText( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_repeat_until_released), + isSelected = selectedMode == RepeatMode.TRIGGER_RELEASED, + isEnabled = isUntilReleasedAllowed, + onSelected = { callback.onSelectRepeatMode(RepeatMode.TRIGGER_RELEASED) }, + ) + + if (!isUntilReleasedAllowed) { + Text( + // Align with the text of the radio button. + modifier = Modifier.padding(start = 48.dp, end = 16.dp), + text = stringResource(R.string.action_options_repeat_until_released_unavailable), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium, + ) + } + + RadioButtonText( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_repeat_until_pressed_again), + isSelected = selectedMode == RepeatMode.TRIGGER_PRESSED_AGAIN, + isEnabled = state.allowedRepeatModes.contains(RepeatMode.TRIGGER_PRESSED_AGAIN), + onSelected = { callback.onSelectRepeatMode(RepeatMode.TRIGGER_PRESSED_AGAIN) }, + ) - if (state.allowedRepeatModes.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) + if (state.showRepeatRateWarning) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Rounded.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.width(8.dp)) Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = stringResource(R.string.stop_repeating_dot_dot_dot), - style = MaterialTheme.typography.titleSmall, + text = stringResource(R.string.action_repeat_rate_warning), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.labelMedium, ) + } + } - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (state.allowedRepeatModes.contains(RepeatMode.TRIGGER_RELEASED)) { - RadioButtonText( - isSelected = state.repeatMode == RepeatMode.TRIGGER_RELEASED, - text = stringResource(R.string.stop_repeating_when_trigger_released), - onSelected = { - callback.onSelectRepeatMode(RepeatMode.TRIGGER_RELEASED) - }, - ) - } + if (state.showRepeatDelay) { + Spacer(modifier = Modifier.height(8.dp)) - if (state.allowedRepeatModes.contains(RepeatMode.TRIGGER_PRESSED_AGAIN)) { - RadioButtonText( - isSelected = state.repeatMode == RepeatMode.TRIGGER_PRESSED_AGAIN, - text = stringResource(R.string.stop_repeating_trigger_pressed_again), - onSelected = { - callback.onSelectRepeatMode(RepeatMode.TRIGGER_PRESSED_AGAIN) - }, - ) - } + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.action_options_repeat_delay), + defaultValue = state.defaultRepeatDelay.toFloat(), + value = state.repeatDelay.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { callback.onRepeatDelayChanged(it.toInt()) }, + valueRange = 0f..SliderMaximums.ACTION_REPEAT_DELAY.toFloat(), + stepSize = SliderStepSizes.ACTION_REPEAT_DELAY, + ) + } - if (state.allowedRepeatModes.contains(RepeatMode.LIMIT_REACHED)) { - RadioButtonText( - isSelected = state.repeatMode == RepeatMode.LIMIT_REACHED, - text = stringResource(R.string.stop_repeating_limit_reached), - onSelected = { callback.onSelectRepeatMode(RepeatMode.LIMIT_REACHED) }, - ) + if (state.showRepeatRate) { + Spacer(modifier = Modifier.height(8.dp)) + + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.action_options_repeat_rate), + defaultValue = state.defaultRepeatRate.toFloat(), + value = state.repeatRate.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { callback.onRepeatRateChanged(it.toInt()) }, + valueRange = 0f..SliderMaximums.ACTION_REPEAT_RATE.toFloat(), + stepSize = SliderStepSizes.ACTION_REPEAT_RATE, + ) + } + + if (state.showRepeatLimit) { + Spacer(modifier = Modifier.height(8.dp)) + + val noLimitString = stringResource(R.string.button_slider_repeat_no_limit) + + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.action_options_repeat_limit), + defaultValue = state.defaultRepeatLimit.toFloat(), + value = state.repeatLimit.toFloat(), + valueText = { value -> + if (value.toInt() == Int.MAX_VALUE) { + noLimitString + } else { + "${value.toInt()}x" } + }, + onValueChange = { callback.onRepeatLimitChanged(it.toInt()) }, + valueRange = 1f..SliderMaximums.ACTION_REPEAT_LIMIT.toFloat(), + stepSize = SliderStepSizes.ACTION_REPEAT_LIMIT, + ) + } + } +} - Spacer(Modifier.width(8.dp)) - } - } +@Composable +private fun BurstOptions(state: ActionOptionsState, callback: ActionOptionsBottomSheetCallback) { + Column { + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.KeyboardDoubleArrowRight, + text = stringResource(R.string.action_options_burst_header), + ) - if (state.showRepeat) { - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - } + Spacer(modifier = Modifier.height(8.dp)) - if (state.showHoldDown) { - Spacer(Modifier.height(8.dp)) + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = if (state.showRepeat && state.isRepeatChecked) { + stringResource(R.string.action_options_burst_description_repeat) + } else { + stringResource(R.string.action_options_burst_description) + }, + style = MaterialTheme.typography.bodyMedium, + ) - CheckBoxText( - modifier = Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - text = stringResource(R.string.flag_hold_down), - isChecked = state.isHoldDownChecked, - onCheckedChange = callback::onHoldDownCheckedChange, - ) - } + Spacer(modifier = Modifier.height(8.dp)) + + val multiplierMin = SliderMinimums.ACTION_MULTIPLIER.toFloat() + val multiplierMax = SliderMaximums.ACTION_MULTIPLIER.toFloat() + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = stringResource(R.string.action_options_burst_size), + defaultValue = state.defaultMultiplier.toFloat(), + value = state.multiplier.toFloat(), + valueText = { "${it.toInt()}x" }, + onValueChange = { callback.onMultiplierChanged(it.toInt()) }, + valueRange = multiplierMin..multiplierMax, + stepSize = SliderStepSizes.ACTION_MULTIPLIER, + ) + } +} + +@Composable +private fun HoldDownOptions(state: ActionOptionsState, callback: ActionOptionsBottomSheetCallback) { + val isRepeating = state.showRepeat && state.isRepeatChecked + + Column { + OptionsHeaderRow( + modifier = Modifier.padding(horizontal = 16.dp), + icon = Icons.Rounded.TouchApp, + text = stringResource(R.string.action_options_hold_down_header), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = if (isRepeating) { + stringResource(R.string.action_options_hold_down_description_repeat) + } else { + stringResource(R.string.action_options_hold_down_description) + }, + style = MaterialTheme.typography.bodyMedium, + ) + + val selectedMode: HoldDownMode? = if (state.isHoldDownChecked) { + state.holdDownMode + } else { + null + } + + RadioButtonText( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_dont_hold_down), + isSelected = selectedMode == null, + onSelected = { callback.onSelectHoldDownMode(null) }, + ) + + if (isRepeating) { + // When repeating, the action is held down for a period of time before each repeat. + RadioButtonText( + modifier = Modifier + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_hold_down_period), + isSelected = selectedMode != null, + onSelected = { callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_RELEASED) }, + ) if (state.showHoldDownDuration) { - Spacer(Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) val holdDownDurationMin = SliderMinimums.ACTION_HOLD_DOWN_DURATION.toFloat() val holdDownDurationMax = SliderMaximums.ACTION_HOLD_DOWN_DURATION.toFloat() @@ -293,123 +472,26 @@ fun ActionOptionsBottomSheet( stepSize = SliderStepSizes.ACTION_HOLD_DOWN_DURATION, ) } - - if (state.showHoldDownMode) { - Spacer(Modifier.height(8.dp)) - - Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = stringResource(R.string.hold_down_until_trigger_is_dot_dot_dot), - style = MaterialTheme.typography.titleSmall, - ) - - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - RadioButtonText( - isSelected = state.holdDownMode == HoldDownMode.TRIGGER_RELEASED, - text = stringResource(R.string.stop_holding_down_when_trigger_released), - onSelected = { - callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_RELEASED) - }, - ) - - RadioButtonText( - isSelected = state.holdDownMode == HoldDownMode.TRIGGER_PRESSED_AGAIN, - text = stringResource(R.string.stop_holding_down_trigger_pressed_again), - onSelected = { - callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_PRESSED_AGAIN) - }, - ) - - Spacer(Modifier.width(8.dp)) - } - } - - if (state.showHoldDown) { - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - } - - if (state.showDelayBeforeNextAction) { - Spacer(Modifier.height(8.dp)) - - val delayBeforeNextActionMin = - SliderMinimums.DELAY_BEFORE_NEXT_ACTION.toFloat() - val delayBeforeNextActionMax = - SliderMaximums.DELAY_BEFORE_NEXT_ACTION.toFloat() - SliderOptionText( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - title = stringResource(R.string.extra_label_delay_before_next_action), - defaultValue = state.defaultDelayBeforeNextAction.toFloat(), - value = state.delayBeforeNextAction.toFloat(), - valueText = { "${it.toInt()} ms" }, - onValueChange = { callback.onDelayBeforeNextActionChanged(it.toInt()) }, - valueRange = delayBeforeNextActionMin..delayBeforeNextActionMax, - stepSize = SliderStepSizes.DELAY_BEFORE_NEXT_ACTION, - ) - } - - Spacer(Modifier.height(8.dp)) - - val actionMultiplierMin = SliderMinimums.ACTION_MULTIPLIER.toFloat() - val actionMultiplierMax = SliderMaximums.ACTION_MULTIPLIER.toFloat() - SliderOptionText( + } else { + RadioButtonText( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - title = if (state.showRepeat && state.isRepeatChecked) { - stringResource(R.string.extra_label_action_multiplier_with_repeat) - } else { - stringResource(R.string.extra_label_action_multiplier) - }, - defaultValue = state.defaultMultiplier.toFloat(), - value = state.multiplier.toFloat(), - valueText = { "${it.toInt()}x" }, - onValueChange = { callback.onMultiplierChanged(it.toInt()) }, - valueRange = actionMultiplierMin..actionMultiplierMax, - stepSize = SliderStepSizes.ACTION_MULTIPLIER, + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_hold_down_until_released), + isSelected = selectedMode == HoldDownMode.TRIGGER_RELEASED, + onSelected = { callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_RELEASED) }, ) - Spacer(Modifier.height(8.dp)) - - HorizontalDivider() - - Spacer(Modifier.height(8.dp)) - - Row( + RadioButtonText( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Spacer(Modifier.weight(0.5f)) - Spacer(Modifier.width(16.dp)) - - FilledTonalButton( - modifier = Modifier.weight(0.5f), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - ), - onClick = { - scope.launch { - sheetState.hide() - onDismissRequest() - } - }, - ) { - Spacer(Modifier.width(16.dp)) - Text(stringResource(R.string.button_done)) - Spacer(Modifier.width(16.dp)) - } - } - - Spacer(Modifier.height(16.dp)) + .padding(horizontal = 8.dp) + .fillMaxWidth(), + text = stringResource(R.string.action_options_hold_down_until_pressed_again), + isSelected = selectedMode == HoldDownMode.TRIGGER_PRESSED_AGAIN, + onSelected = { + callback.onSelectHoldDownMode(HoldDownMode.TRIGGER_PRESSED_AGAIN) + }, + ) } } } @@ -417,20 +499,68 @@ fun ActionOptionsBottomSheet( interface ActionOptionsBottomSheetCallback { fun onEditClick() = run { } fun onReplaceClick() = run { } - fun onRepeatCheckedChange(checked: Boolean) = run { } - fun onSelectRepeatMode(repeatMode: RepeatMode) = run { } + fun onCustomNameChanged(name: String) = run { } + + /** + * @param repeatMode null if the action should not repeat. + */ + fun onSelectRepeatMode(repeatMode: RepeatMode?) = run { } fun onRepeatRateChanged(rate: Int) = run { } fun onRepeatLimitChanged(limit: Int) = run { } fun onRepeatDelayChanged(delay: Int) = run { } - fun onHoldDownCheckedChange(checked: Boolean) = run { } + + /** + * @param holdDownMode null if the action should not be held down. + */ + fun onSelectHoldDownMode(holdDownMode: HoldDownMode?) = run { } fun onHoldDownDurationChanged(duration: Int) = run { } - fun onSelectHoldDownMode(holdDownMode: HoldDownMode) = run { } - fun onDelayBeforeNextActionChanged(delay: Int) = run { } fun onMultiplierChanged(multiplier: Int) = run { } } +private val previewState = ActionOptionsState( + title = "Input KEYCODE_0", + actionTypeTitle = "Input key event", + actionTypeIcon = Icons.Rounded.Abc, + + showEditButton = true, + showRepeat = true, + isRepeatChecked = true, + showRepeatRateWarning = false, + + showRepeatRate = true, + repeatRate = 400, + defaultRepeatRate = 500, + + showRepeatDelay = true, + repeatDelay = 400, + defaultRepeatDelay = 400, + + showRepeatLimit = true, + repeatLimit = Int.MAX_VALUE, + defaultRepeatLimit = Int.MAX_VALUE, + + allowedRepeatModes = setOf( + RepeatMode.TRIGGER_RELEASED, + RepeatMode.LIMIT_REACHED, + RepeatMode.TRIGGER_PRESSED_AGAIN, + ), + repeatMode = RepeatMode.TRIGGER_PRESSED_AGAIN, + + showHoldDown = true, + isHoldDownChecked = true, + + showHoldDownDuration = true, + holdDownDuration = 400, + defaultHoldDownDuration = 400, + + holdDownMode = HoldDownMode.TRIGGER_RELEASED, + + multiplier = 4, + defaultMultiplier = 1, +) + @OptIn(ExperimentalMaterial3Api::class) -@Preview(heightDp = 1000, showSystemUi = true) +@Preview(heightDp = 1600, showSystemUi = true) @Composable private fun Preview() { KeyMapperTheme { @@ -443,57 +573,16 @@ private fun Preview() { ActionOptionsBottomSheet( sheetState = sheetState, - state = ActionOptionsState( - showEditButton = true, - showRepeat = true, - isRepeatChecked = true, - showRepeatRateWarning = true, - - showRepeatRate = true, - repeatRate = 400, - defaultRepeatRate = 500, - - showRepeatDelay = true, - repeatDelay = 400, - defaultRepeatDelay = 400, - - showRepeatLimit = true, - repeatLimit = Int.MAX_VALUE, - defaultRepeatLimit = Int.MAX_VALUE, - - allowedRepeatModes = setOf( - RepeatMode.TRIGGER_RELEASED, - RepeatMode.LIMIT_REACHED, - RepeatMode.TRIGGER_PRESSED_AGAIN, - ), - repeatMode = RepeatMode.TRIGGER_RELEASED, - - showHoldDown = true, - isHoldDownChecked = false, - - showHoldDownDuration = true, - holdDownDuration = 400, - defaultHoldDownDuration = 400, - - showHoldDownMode = true, - holdDownMode = HoldDownMode.TRIGGER_PRESSED_AGAIN, - - showDelayBeforeNextAction = true, - delayBeforeNextAction = 10000, - defaultDelayBeforeNextAction = 5000, - - multiplier = 4, - defaultMultiplier = 1, - ), + state = previewState, callback = object : ActionOptionsBottomSheetCallback {}, ) } } @OptIn(ExperimentalMaterial3Api::class) -@Preview(showSystemUi = true) +@Preview(heightDp = 1600, showSystemUi = true) @Composable -private fun PreviewNoEditButton() { +private fun PreviewUntilReleasedUnavailable() { KeyMapperTheme { val sheetState = SheetState( skipPartiallyExpanded = true, @@ -504,47 +593,42 @@ private fun PreviewNoEditButton() { ActionOptionsBottomSheet( sheetState = sheetState, - state = ActionOptionsState( + state = previewState.copy( showEditButton = false, - showRepeat = true, - isRepeatChecked = true, showRepeatRateWarning = true, - - showRepeatRate = true, - repeatRate = 400, - defaultRepeatRate = 500, - - showRepeatDelay = true, - repeatDelay = 400, - defaultRepeatDelay = 400, - - showRepeatLimit = true, - repeatLimit = 10, - defaultRepeatLimit = Int.MAX_VALUE, - allowedRepeatModes = setOf( - RepeatMode.TRIGGER_RELEASED, RepeatMode.LIMIT_REACHED, RepeatMode.TRIGGER_PRESSED_AGAIN, ), - repeatMode = RepeatMode.TRIGGER_RELEASED, - - showHoldDown = true, isHoldDownChecked = false, + ), + callback = object : ActionOptionsBottomSheetCallback {}, + ) + } +} - showHoldDownDuration = true, - holdDownDuration = 400, - defaultHoldDownDuration = 400, +@OptIn(ExperimentalMaterial3Api::class) +@Preview(heightDp = 1600, showSystemUi = true) +@Composable +private fun PreviewNotRepeating() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + skipHiddenState = true, + ) - showHoldDownMode = true, + ActionOptionsBottomSheet( + sheetState = sheetState, + state = previewState.copy( + isRepeatChecked = false, + showRepeatRate = false, + showRepeatDelay = false, + showRepeatLimit = false, + showHoldDownDuration = false, holdDownMode = HoldDownMode.TRIGGER_PRESSED_AGAIN, - - showDelayBeforeNextAction = true, - delayBeforeNextAction = 10000, - defaultDelayBeforeNextAction = 5000, - - multiplier = 4, - defaultMultiplier = 1, ), callback = object : ActionOptionsBottomSheetCallback {}, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsState.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsState.kt new file mode 100644 index 0000000000..525005f0ef --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionOptionsState.kt @@ -0,0 +1,42 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.ui.graphics.vector.ImageVector + +data class ActionOptionsState( + val title: String, + val actionTypeTitle: String, + val actionTypeIcon: ImageVector, + + val showEditButton: Boolean, + + val showRepeat: Boolean, + val isRepeatChecked: Boolean, + + val showRepeatRate: Boolean, + val showRepeatRateWarning: Boolean, + val repeatRate: Int, + val defaultRepeatRate: Int, + + val showRepeatDelay: Boolean, + val repeatDelay: Int, + val defaultRepeatDelay: Int, + + val showRepeatLimit: Boolean, + val repeatLimit: Int, + val defaultRepeatLimit: Int, + + val allowedRepeatModes: Set, + val repeatMode: RepeatMode, + + val showHoldDown: Boolean, + val isHoldDownChecked: Boolean, + + val showHoldDownDuration: Boolean, + val holdDownDuration: Int, + val defaultHoldDownDuration: Int, + + val holdDownMode: HoldDownMode, + + val multiplier: Int, + val defaultMultiplier: Int, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt index cebf056bd6..6e9e1f0306 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt @@ -10,11 +10,13 @@ import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.utils.DndModeStrings import io.github.sds100.keymapper.base.utils.KeyCodeStrings import io.github.sds100.keymapper.base.utils.RingerModeStrings +import io.github.sds100.keymapper.base.utils.VibrateEffectStrings import io.github.sds100.keymapper.base.utils.VolumeStreamStrings import io.github.sds100.keymapper.base.utils.ui.IconInfo import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.TintType import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.InputDeviceUtils import io.github.sds100.keymapper.common.utils.Orientation @@ -22,6 +24,7 @@ import io.github.sds100.keymapper.common.utils.PinchScreenType import io.github.sds100.keymapper.common.utils.handle import io.github.sds100.keymapper.common.utils.hasFlag import io.github.sds100.keymapper.common.utils.toPercentString +import io.github.sds100.keymapper.common.utils.valueIfFailure import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.intents.IntentTarget @@ -31,12 +34,22 @@ class ActionUiHelper( ) : ResourceProvider by resourceProvider, DisplayActionUseCase by displayActionUseCase { + /** + * @return the custom name of the action if it has one, otherwise the generated title. + */ + fun getTitle(action: Action, showDeviceDescriptors: Boolean): String { + return action.customName?.takeIf { it.isNotBlank() } + ?: getTitle(action.data, showDeviceDescriptors) + } + fun getTitle(action: ActionData, showDeviceDescriptors: Boolean): String = when (action) { - is ActionData.App -> - getAppName(action.packageName).handle( - onSuccess = { getString(R.string.description_open_app, it) }, - onError = { getString(R.string.description_open_app, action.packageName) }, - ) + is ActionData.App -> { + val name = getAppName(action.packageName).valueIfFailure { + action.savedAppName ?: action.packageName + } + + getString(R.string.description_open_app, name) + } is ActionData.AppShortcut -> action.shortcutTitle @@ -204,95 +217,61 @@ class ActionUiHelper( } } - is ActionData.ControlMediaForApp -> - getAppName(action.packageName).handle( - onSuccess = { appName -> - if (action is ActionData.ControlMediaForApp.StepForward && - action.stepDurationMs != null - ) { - getString( - R.string.action_step_forward_media_package_with_duration_formatted, - arrayOf(appName, (action.stepDurationMs / 1000).toInt()), - ) - } else if (action is ActionData.ControlMediaForApp.StepBackward && - action.stepDurationMs != null - ) { - getString( - R.string.action_step_backward_media_package_with_duration_formatted, - arrayOf(appName, (action.stepDurationMs / 1000).toInt()), - ) - } else { - val resId = when (action) { - is ActionData.ControlMediaForApp.Play -> - R.string.action_play_media_package_formatted - - is ActionData.ControlMediaForApp.FastForward -> - R.string.action_fast_forward_package_formatted - - is ActionData.ControlMediaForApp.NextTrack -> - R.string.action_next_track_package_formatted - - is ActionData.ControlMediaForApp.Pause -> - R.string.action_pause_media_package_formatted - - is ActionData.ControlMediaForApp.PlayPause -> - R.string.action_play_pause_media_package_formatted - - is ActionData.ControlMediaForApp.PreviousTrack -> - R.string.action_previous_track_package_formatted - - is ActionData.ControlMediaForApp.Rewind -> - R.string.action_rewind_package_formatted - - is ActionData.ControlMediaForApp.Stop -> - R.string.action_stop_media_package_formatted - - is ActionData.ControlMediaForApp.StepForward -> - R.string.action_step_forward_media_package_formatted - - is ActionData.ControlMediaForApp.StepBackward -> - R.string.action_step_backward_media_package_formatted - } + is ActionData.ControlMediaForApp -> { + val appName = getAppName(action.packageName).valueIfFailure { + action.savedAppName ?: action.packageName + } - getString(resId, appName) - } - }, - onError = { - val resId = when (action) { - is ActionData.ControlMediaForApp.Play -> - R.string.action_play_media_package + if (action is ActionData.ControlMediaForApp.StepForward && + action.stepDurationMs != null + ) { + getString( + R.string.action_step_forward_media_package_with_duration_formatted, + arrayOf(appName, (action.stepDurationMs / 1000).toInt()), + ) + } else if (action is ActionData.ControlMediaForApp.StepBackward && + action.stepDurationMs != null + ) { + getString( + R.string.action_step_backward_media_package_with_duration_formatted, + arrayOf(appName, (action.stepDurationMs / 1000).toInt()), + ) + } else { + val resId = when (action) { + is ActionData.ControlMediaForApp.Play -> + R.string.action_play_media_package_formatted - is ActionData.ControlMediaForApp.FastForward -> - R.string.action_fast_forward_package + is ActionData.ControlMediaForApp.FastForward -> + R.string.action_fast_forward_package_formatted - is ActionData.ControlMediaForApp.NextTrack -> - R.string.action_next_track_package + is ActionData.ControlMediaForApp.NextTrack -> + R.string.action_next_track_package_formatted - is ActionData.ControlMediaForApp.Pause -> - R.string.action_pause_media_package + is ActionData.ControlMediaForApp.Pause -> + R.string.action_pause_media_package_formatted - is ActionData.ControlMediaForApp.PlayPause -> - R.string.action_play_pause_media_package + is ActionData.ControlMediaForApp.PlayPause -> + R.string.action_play_pause_media_package_formatted - is ActionData.ControlMediaForApp.PreviousTrack -> - R.string.action_previous_track_package + is ActionData.ControlMediaForApp.PreviousTrack -> + R.string.action_previous_track_package_formatted - is ActionData.ControlMediaForApp.Rewind -> - R.string.action_rewind_package + is ActionData.ControlMediaForApp.Rewind -> + R.string.action_rewind_package_formatted - is ActionData.ControlMediaForApp.Stop -> - R.string.action_stop_media_package + is ActionData.ControlMediaForApp.Stop -> + R.string.action_stop_media_package_formatted - is ActionData.ControlMediaForApp.StepForward -> - R.string.action_step_forward_media_package + is ActionData.ControlMediaForApp.StepForward -> + R.string.action_step_forward_media_package_formatted - is ActionData.ControlMediaForApp.StepBackward -> - R.string.action_step_backward_media_package - } + is ActionData.ControlMediaForApp.StepBackward -> + R.string.action_step_backward_media_package_formatted + } - getString(resId) - }, - ) + getString(resId, appName) + } + } is ActionData.Flashlight -> { when (action) { @@ -712,18 +691,24 @@ class ActionUiHelper( R.string.action_expand_notification_drawer, ) - ActionData.StatusBar.ExpandQuickSettings -> getString(R.string.action_expand_quick_settings) + ActionData.StatusBar.ExpandQuickSettings -> getString( + R.string.action_expand_quick_settings, + ) ActionData.StatusBar.ToggleNotifications -> getString( R.string.action_toggle_notification_drawer, ) - ActionData.StatusBar.ToggleQuickSettings -> getString(R.string.action_toggle_quick_settings) + ActionData.StatusBar.ToggleQuickSettings -> getString( + R.string.action_toggle_quick_settings, + ) ActionData.ToggleKeyboard -> getString(R.string.action_toggle_keyboard) ActionData.CycleKeyboardLanguage -> getString(R.string.action_cycle_keyboard_language) + ActionData.CycleKeyboard -> getString(R.string.action_cycle_keyboard) + ActionData.ToggleSplitScreen -> getString(R.string.action_toggle_split_screen) ActionData.VoiceAssistant -> getString(R.string.action_open_assistant) @@ -734,7 +719,9 @@ class ActionUiHelper( ActionData.Wifi.Toggle -> getString(R.string.action_toggle_wifi) - ActionData.DismissAllNotifications -> getString(R.string.action_dismiss_all_notifications) + ActionData.DismissAllNotifications -> getString( + R.string.action_dismiss_all_notifications, + ) ActionData.DismissLastNotification -> getString( R.string.action_dismiss_most_recent_notification, @@ -813,6 +800,24 @@ class ActionUiHelper( } } + is ActionData.Vibrate -> { + when (val effect = action.effect) { + is VibrateEffect.CustomDuration -> { + getString( + R.string.action_vibrate_description_duration, + effect.durationMs.toString(), + ) + } + + is VibrateEffect.Predefined -> { + getString( + R.string.action_vibrate_description_predefined, + getString(VibrateEffectStrings.getLabel(effect.predefinedType)), + ) + } + } + } + is ActionData.TalkBackGesture -> { val actionLabel = getString(TalkBackGestureStrings.getActionLabel(action.gesture)) getString(R.string.action_talkback_gesture_formatted, actionLabel) @@ -891,76 +896,116 @@ class ActionUiHelper( } fun getOptionLabels(keyMap: KeyMap, action: Action) = buildList { - if (keyMap.isRepeatingActionsAllowed() && action.repeat) { - val repeatDescription = buildString { - append(getString(R.string.flag_repeat_build_description_start)) + getRepeatDescription(keyMap, action)?.let { add(it) } - val repeatLimit = when { - action.repeatLimit != null -> action.repeatLimit + if (keyMap.isHoldingDownActionAllowed(action) && + action.holdDown && + !action.stopHoldDownWhenTriggerPressedAgain + ) { + add(getString(R.string.flag_hold_down)) + } - action.repeatMode == RepeatMode.LIMIT_REACHED -> 1 + if (keyMap.isHoldingDownActionAllowed(action) && + action.holdDown && + action.stopHoldDownWhenTriggerPressedAgain + ) { + add(getString(R.string.flag_hold_down_until_pressed_again)) + } + } - // and is null - else -> null - } + /** + * @return null if the action does not repeat. + */ + fun getRepeatDescription(keyMap: KeyMap, action: Action): String? { + if (!keyMap.isRepeatingActionsAllowed() || !action.repeat) { + return null + } - if (repeatLimit != null) { - append(" ") - append(getString(R.string.flag_repeat_build_description_limit, repeatLimit)) - } + return buildString { + append(getString(R.string.flag_repeat_build_description_start)) - if (action.repeatRate != null) { - append(" ") - append( - getString( - R.string.flag_repeat_build_description_repeat_rate, - action.repeatRate, - ), - ) - } + val repeatLimit = when { + action.repeatLimit != null -> action.repeatLimit - if (action.repeatDelay != null) { - append(" ") - append( - getString( - R.string.flag_repeat_build_description_repeat_delay, - action.repeatDelay, - ), - ) - } + action.repeatMode == RepeatMode.LIMIT_REACHED -> 1 + // and is null + else -> null + } + + if (repeatLimit != null) { append(" ") + append(getString(R.string.flag_repeat_build_description_limit, repeatLimit)) + } - when (action.repeatMode) { - RepeatMode.TRIGGER_RELEASED -> { - append(getString(R.string.flag_repeat_build_description_until_released)) - } + if (action.repeatRate != null) { + append(" ") + append( + getString( + R.string.flag_repeat_build_description_repeat_rate, + action.repeatRate, + ), + ) + } - RepeatMode.TRIGGER_PRESSED_AGAIN -> { - append( - getString(R.string.flag_repeat_build_description_until_pressed_again), - ) - } + if (action.repeatDelay != null) { + append(" ") + append( + getString( + R.string.flag_repeat_build_description_repeat_delay, + action.repeatDelay, + ), + ) + } + + append(" ") - else -> Unit + when (action.repeatMode) { + RepeatMode.TRIGGER_RELEASED -> { + append(getString(R.string.flag_repeat_build_description_until_released)) } + + RepeatMode.TRIGGER_PRESSED_AGAIN -> { + append( + getString(R.string.flag_repeat_build_description_until_pressed_again), + ) + } + + else -> Unit } + } + } + + /** + * @return null if the action is not performed in a burst. + */ + fun getBurstDescription(action: Action): String? { + val multiplier = action.multiplier ?: return null - add(repeatDescription) + if (multiplier <= 1) { + return null } - if (keyMap.isHoldingDownActionAllowed(action) && - action.holdDown && - !action.stopHoldDownWhenTriggerPressedAgain - ) { - add(getString(R.string.flag_hold_down)) + return getString(R.string.action_list_burst, multiplier) + } + + /** + * @return null if the action is not held down. + */ + fun getHoldDownDescription(keyMap: KeyMap, action: Action): String? { + if (!keyMap.isHoldingDownActionAllowed(action) || !action.holdDown) { + return null } - if (keyMap.isHoldingDownActionAllowed(action) && - action.holdDown && - action.stopHoldDownWhenTriggerPressedAgain - ) { - add(getString(R.string.flag_hold_down_until_pressed_again)) + return when { + keyMap.isHoldingDownActionBeforeRepeatingAllowed(action) && + action.holdDownDuration != null -> + getString(R.string.action_list_hold_down_duration, action.holdDownDuration) + + action.stopHoldDownWhenTriggerPressedAgain -> + getString(R.string.flag_hold_down_until_pressed_again) + + else -> getString(R.string.flag_hold_down) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt index 6f464f5e54..bda0aae9dc 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt @@ -68,6 +68,7 @@ import androidx.compose.material.icons.outlined.StopCircle import androidx.compose.material.icons.outlined.Swipe import androidx.compose.material.icons.outlined.TouchApp import androidx.compose.material.icons.outlined.VerticalSplit +import androidx.compose.material.icons.outlined.Vibration import androidx.compose.material.icons.outlined.ViewArray import androidx.compose.material.icons.outlined.WifiTethering import androidx.compose.material.icons.outlined.WifiTetheringOff @@ -231,6 +232,7 @@ object ActionUtils { ActionId.PERFORM_IME_ACTION -> ActionCategory.KEYBOARD ActionId.SWITCH_KEYBOARD -> ActionCategory.KEYBOARD ActionId.CYCLE_KEYBOARD_LANGUAGE -> ActionCategory.KEYBOARD + ActionId.CYCLE_KEYBOARD -> ActionCategory.KEYBOARD ActionId.LOCK_DEVICE -> ActionCategory.INTERFACE ActionId.POWER_ON_OFF_DEVICE -> ActionCategory.INTERFACE ActionId.SECURE_LOCK_DEVICE -> ActionCategory.INTERFACE @@ -244,6 +246,7 @@ object ActionUtils { ActionId.DISMISS_ALL_NOTIFICATIONS -> ActionCategory.NOTIFICATIONS ActionId.CREATE_NOTIFICATION -> ActionCategory.NOTIFICATIONS ActionId.TOAST -> ActionCategory.NOTIFICATIONS + ActionId.VIBRATE -> ActionCategory.NOTIFICATIONS ActionId.DEVICE_CONTROLS -> ActionCategory.APPS ActionId.INTERACT_UI_ELEMENT -> ActionCategory.APPS ActionId.FORCE_STOP_APP -> ActionCategory.APPS @@ -439,6 +442,8 @@ object ActionUtils { ActionId.CYCLE_KEYBOARD_LANGUAGE -> R.string.action_cycle_keyboard_language + ActionId.CYCLE_KEYBOARD -> R.string.action_cycle_keyboard + ActionId.TOGGLE_AIRPLANE_MODE -> R.string.action_toggle_airplane_mode ActionId.ENABLE_AIRPLANE_MODE -> R.string.action_enable_airplane_mode @@ -498,6 +503,8 @@ object ActionUtils { ActionId.TOAST -> R.string.action_toast + ActionId.VIBRATE -> R.string.action_vibrate + ActionId.ANSWER_PHONE_CALL -> R.string.action_answer_call ActionId.END_PHONE_CALL -> R.string.action_end_call @@ -912,6 +919,10 @@ object ActionUtils { ActionId.CYCLE_KEYBOARD_LANGUAGE -> return listOf(Permission.WRITE_SECURE_SETTINGS) + ActionId.CYCLE_KEYBOARD -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return listOf(Permission.WRITE_SECURE_SETTINGS) + } + ActionId.TOGGLE_AIRPLANE_MODE, ActionId.ENABLE_AIRPLANE_MODE, ActionId.DISABLE_AIRPLANE_MODE, @@ -963,6 +974,12 @@ object ActionUtils { // Permissions handled based on setting type at runtime ActionId.MODIFY_SETTING -> return emptyList() + ActionId.HTTP_REQUEST -> if (Build.VERSION.SDK_INT >= + Build.VERSION_CODES.CINNAMON_BUN + ) { + return listOf(Permission.ACCESS_LOCAL_NETWORK) + } + else -> return emptyList() } @@ -1062,6 +1079,7 @@ object ActionUtils { ActionId.PERFORM_IME_ACTION -> Icons.Outlined.Keyboard ActionId.SWITCH_KEYBOARD -> Icons.Outlined.Keyboard ActionId.CYCLE_KEYBOARD_LANGUAGE -> Icons.Outlined.Keyboard + ActionId.CYCLE_KEYBOARD -> Icons.Outlined.Keyboard ActionId.TOGGLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive ActionId.ENABLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeActive ActionId.DISABLE_AIRPLANE_MODE -> Icons.Outlined.AirplanemodeInactive @@ -1093,6 +1111,7 @@ object ActionUtils { ActionId.DISMISS_ALL_NOTIFICATIONS -> Icons.Outlined.ClearAll ActionId.CREATE_NOTIFICATION -> Icons.AutoMirrored.Outlined.Message ActionId.TOAST -> Icons.AutoMirrored.Outlined.Message + ActionId.VIBRATE -> Icons.Outlined.Vibration ActionId.ANSWER_PHONE_CALL -> Icons.Outlined.Call ActionId.END_PHONE_CALL -> Icons.Outlined.CallEnd ActionId.DEVICE_CONTROLS -> KeyMapperIcons.HomeIotDevice @@ -1155,6 +1174,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.ShellCommand, is ActionData.CreateNotification, is ActionData.Toast, + is ActionData.Vibrate, is ActionData.InteractUiElement, is ActionData.MoveCursor, is ActionData.ModifySetting, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt index 9781d71951..499d51fa94 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionsScreen.kt @@ -8,18 +8,25 @@ 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.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowDownward import androidx.compose.material.icons.rounded.FlashlightOn +import androidx.compose.material.icons.rounded.MoreTime import androidx.compose.material.icons.rounded.Pinch +import androidx.compose.material.icons.rounded.Timelapse import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -27,11 +34,14 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -44,14 +54,25 @@ import io.github.sds100.keymapper.base.keymaps.ShortcutModel import io.github.sds100.keymapper.base.keymaps.ShortcutRow import io.github.sds100.keymapper.base.onboarding.OnboardingTipModel import io.github.sds100.keymapper.base.onboarding.TipCard -import io.github.sds100.keymapper.base.utils.ui.LinkType +import io.github.sds100.keymapper.base.utils.ui.SliderMaximums +import io.github.sds100.keymapper.base.utils.ui.SliderMinimums +import io.github.sds100.keymapper.base.utils.ui.SliderStepSizes import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.utils.ui.compose.CustomDialog import io.github.sds100.keymapper.base.utils.ui.compose.DraggableItem +import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText +import io.github.sds100.keymapper.base.utils.ui.compose.TextFieldDialog import io.github.sds100.keymapper.base.utils.ui.compose.rememberDragDropState import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.camera.CameraLens import kotlinx.coroutines.flow.update +/** + * The height of the row between action cards. The last card has a spacer of the same height so + * the height of the items stays constant while dragging. + */ +private val linkRowHeight = 48.dp + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewModel) { @@ -95,15 +116,11 @@ fun ActionsScreen(modifier: Modifier = Modifier, viewModel: ConfigActionsViewMod modifier = modifier, state = state, tipModel = actionTipModel, + callback = viewModel, onRemoveClick = viewModel::onRemoveClick, - onEditClick = viewModel::onEditClick, - onMoveAction = viewModel::onMoveAction, - onFixErrorClick = viewModel::onFixError, - onClickShortcut = viewModel::onClickShortcut, - onTestClick = viewModel::onTestClick, onAddClick = viewModel::onAddActionClick, - onActionTipDismiss = viewModel::onActionTipDismissClick, - onTipButtonClick = viewModel::onTipButtonClick, + onDelayChange = viewModel::onDelayChanged, + onRenameAction = viewModel::onRenameAction, ) } @@ -112,22 +129,21 @@ private fun ActionsScreen( modifier: Modifier = Modifier, state: State, tipModel: OnboardingTipModel? = null, + callback: ActionListCallback = object : ActionListCallback {}, onAddClick: () -> Unit = {}, onRemoveClick: (String) -> Unit = {}, - onEditClick: (String) -> Unit = {}, - onMoveAction: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, - onFixErrorClick: (String) -> Unit = {}, - onTestClick: (String) -> Unit = {}, - onClickShortcut: (ActionData) -> Unit = {}, - onActionTipDismiss: () -> Unit = {}, - onTipButtonClick: (String) -> Unit = {}, + onDelayChange: (String, Int) -> Unit = { _, _ -> }, + onRenameAction: (String, String) -> Unit = { _, _ -> }, ) { - var showDeleteDialog by rememberSaveable { mutableStateOf(false) } var actionToDelete by rememberSaveable { mutableStateOf(null) } + var actionToRename by rememberSaveable { mutableStateOf(null) } + var actionToSetDelay by rememberSaveable { mutableStateOf(null) } + + val actions = ((state as? State.Data)?.data as? ConfigActionsState.Loaded)?.actions.orEmpty() - if (showDeleteDialog && actionToDelete != null) { + if (actionToDelete != null) { AlertDialog( - onDismissRequest = { showDeleteDialog = false }, + onDismissRequest = { actionToDelete = null }, title = { Text(stringResource(R.string.action_list_delete_dialog_title)) }, @@ -136,20 +152,50 @@ private fun ActionsScreen( TextButton( onClick = { onRemoveClick(actionToDelete!!) - showDeleteDialog = false + actionToDelete = null }, ) { Text(stringResource(R.string.action_list_delete_yes)) } }, dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { + TextButton(onClick = { actionToDelete = null }) { Text(stringResource(R.string.action_list_delete_cancel)) } }, ) } + val renameModel = actions.find { it.id == actionToRename } + + if (renameModel != null) { + TextFieldDialog( + title = stringResource(R.string.action_options_custom_name_dialog_title), + submitButtonText = stringResource(R.string.pos_save), + initialText = renameModel.title, + hint = renameModel.title, + canBeEmpty = true, + onSubmitClick = { newText -> + onRenameAction(renameModel.id, newText) + null + }, + onDismissRequest = { actionToRename = null }, + ) + } + + val delayModel = actions.find { it.id == actionToSetDelay } + + if (delayModel != null) { + DelayBeforeNextActionDialog( + initialDelay = delayModel.delayBeforeNextAction, + onSaveClick = { delay -> + onDelayChange(delayModel.id, delay) + actionToSetDelay = null + }, + onDismissRequest = { actionToSetDelay = null }, + ) + } + when (state) { State.Loading -> Loading() @@ -159,17 +205,10 @@ private fun ActionsScreen( modifier = Modifier.weight(1f), state = state.data, tipModel = tipModel, - onRemoveClick = { - actionToDelete = it - showDeleteDialog = true - }, - onEditClick = onEditClick, - onFixErrorClick = onFixErrorClick, - onMove = onMoveAction, - onClickShortcut = onClickShortcut, - onTestClick = onTestClick, - onActionTipDismiss, - onTipButtonClick, + callback = callback, + onRemoveClick = { actionToDelete = it }, + onDelayClick = { actionToSetDelay = it }, + onRenameClick = { actionToRename = it }, ) FilledTonalButton( @@ -196,36 +235,40 @@ private fun Loading(modifier: Modifier = Modifier) { } } +interface ActionListCallback { + fun onEditClick(id: String) = run { } + fun onFixErrorClick(id: String) = run { } + fun onMove(fromIndex: Int, toIndex: Int) = run { } + fun onClickShortcut(data: ActionData) = run { } + fun onTestClick(id: String) = run { } + fun onDuplicateClick(id: String) = run { } + fun onActionTipDismiss() = run { } + fun onTipButtonClick(id: String) = run { } + fun onEnabledChange(id: String, enabled: Boolean) = run { } + fun onExpandedChange(id: String, expanded: Boolean) = run {} +} + @Composable private fun ActionList( modifier: Modifier = Modifier, state: ConfigActionsState, tipModel: OnboardingTipModel?, + callback: ActionListCallback, onRemoveClick: (String) -> Unit, - onEditClick: (String) -> Unit, - onFixErrorClick: (String) -> Unit, - onMove: (fromIndex: Int, toIndex: Int) -> Unit, - onClickShortcut: (ActionData) -> Unit, - onTestClick: (String) -> Unit, - onActionTipDismiss: () -> Unit, - onTipButtonClick: (String) -> Unit, + onDelayClick: (String) -> Unit, + onRenameClick: (String) -> Unit, ) { val lazyListState = rememberLazyListState() - val dragDropState = if (state is ConfigActionsState.Loaded) { - rememberDragDropState( - lazyListState = lazyListState, - onMove = onMove, - // Do not drag and drop the row of shortcuts - ignoreLastItems = if (state.shortcuts.isEmpty()) { - 0 - } else { - 1 - }, - ) - } else { - null - } + val actions = (state as? ConfigActionsState.Loaded)?.actions.orEmpty() + val actionIds = remember(actions) { actions.map { it.id } } + + // Only the actions can be dragged. Not the tip or the row of shortcuts. + val dragDropState = rememberDragDropState( + lazyListState = lazyListState, + keys = actionIds, + onMove = callback::onMove, + ) // Use dragContainer rather than .draggable() modifier because that causes // dragging the first item to be always be dropped in the next position. @@ -248,12 +291,12 @@ private fun ActionList( title = tip.title, message = tip.message, isDismissable = tip.isDismissable, - onDismiss = onActionTipDismiss, + onDismiss = callback::onActionTipDismiss, buttonText = tip.buttonText, - onButtonClick = { onTipButtonClick(tip.id) }, + onButtonClick = { callback.onTipButtonClick(tip.id) }, ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) } } @@ -271,40 +314,67 @@ private fun ActionList( } is ConfigActionsState.Loaded -> { + val orderedActions = dragDropState.ordered(state.actions) { it.id } + itemsIndexed( - state.actions, + orderedActions, key = { _, item -> item.id }, contentType = { _, _ -> "action" }, ) { index, model -> + DraggableItem( - dragDropState = dragDropState!!, - index = index, + dragDropState = dragDropState, + key = model.id, ) { isDragging -> - ActionListItem( - modifier = Modifier.fillMaxWidth(), - model = model, - index = index, - isDraggingEnabled = state.actions.size > 1, - isDragging = isDragging, - isReorderingEnabled = state.isReorderingEnabled, - dragDropState = dragDropState, - onEditClick = { onEditClick(model.id) }, - onRemoveClick = { onRemoveClick(model.id) }, - onFixClick = { onFixErrorClick(model.id) }, - onTestClick = { onTestClick(model.id) }, - onMoveUp = if (state.isReorderingEnabled && index > 0) { - { onMove(index, index - 1) } - } else { - null - }, - onMoveDown = if (state.isReorderingEnabled && - index < state.actions.size - 1 - ) { - { onMove(index, index + 1) } - } else { - null - }, - ) + Column { + ActionListItem( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + model = model, + index = index, + isExpanded = model.isExpanded, + isDraggingEnabled = orderedActions.size > 1, + isDragging = isDragging, + isReorderingEnabled = state.isReorderingEnabled, + dragDropState = dragDropState, + onExpandedChange = { callback.onExpandedChange(model.id, it) }, + onEditClick = { callback.onEditClick(model.id) }, + onRemoveClick = { onRemoveClick(model.id) }, + onFixClick = { callback.onFixErrorClick(model.id) }, + onTestClick = { callback.onTestClick(model.id) }, + onDuplicateClick = { callback.onDuplicateClick(model.id) }, + onRenameClick = { onRenameClick(model.id) }, + onEnabledChange = { callback.onEnabledChange(model.id, it) }, + onMoveUp = if (state.isReorderingEnabled && index > 0) { + { callback.onMove(index, index - 1) } + } else { + null + }, + onMoveDown = if (state.isReorderingEnabled && + index < orderedActions.size - 1 + ) { + { callback.onMove(index, index + 1) } + } else { + null + }, + ) + + if (model.showDelayChip) { + ActionLinkRow( + isEnabled = model.isEnabled, + delayBeforeNextAction = model.delayBeforeNextAction, + onDelayClick = { onDelayClick(model.id) }, + ) + } else if (index != orderedActions.lastIndex) { + // Important! Keep the height of the item constant while dragging. + // If the height changes while dragging it can lead to janky + // behavior. + // + // But do not show a gap at the bottom of the list. + Spacer(Modifier.height(linkRowHeight)) + } + } } } } @@ -313,6 +383,8 @@ private fun ActionList( if (state.shortcuts.isNotEmpty()) { item(key = "shortcuts", contentType = "shortcuts") { Column(horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(16.dp)) + Text( text = stringResource(R.string.recently_used_actions), style = MaterialTheme.typography.titleSmall, @@ -325,7 +397,7 @@ private fun ActionList( .fillMaxWidth() .padding(horizontal = 32.dp), shortcuts = state.shortcuts, - onClick = { onClickShortcut(it) }, + onClick = { callback.onClickShortcut(it) }, ) } } @@ -333,6 +405,132 @@ private fun ActionList( } } +@Composable +private fun ActionLinkRow( + modifier: Modifier = Modifier, + isEnabled: Boolean, + delayBeforeNextAction: Int?, + onDelayClick: () -> Unit, +) { + val colors = if (isEnabled) { + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + leadingIconContentColor = MaterialTheme.colorScheme.onSurface, + ) + } else { + AssistChipDefaults.assistChipColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + leadingIconContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + labelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + } + + Box( + modifier = modifier + .fillMaxWidth() + .height(linkRowHeight) + .padding(horizontal = 16.dp), + ) { + Icon( + modifier = Modifier + .size(24.dp) + .align(Alignment.Center), + imageVector = Icons.Rounded.ArrowDownward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + + AssistChip( + modifier = Modifier.align(Alignment.CenterEnd), + onClick = onDelayClick, + label = { + if (delayBeforeNextAction == null) { + Text(stringResource(R.string.action_list_add_delay)) + } else { + if (delayBeforeNextAction < 1000) { + Text(stringResource(R.string.action_title_wait_ms, delayBeforeNextAction)) + } else { + val seconds = delayBeforeNextAction / 1000f + val secondsText = if (seconds % 1f == 0f) { + seconds.toInt().toString() + } else { + String.format(LocalLocale.current.platformLocale, "%.1f", seconds) + } + + Text( + stringResource( + R.string.action_title_wait_secs, + secondsText, + ), + ) + } + } + }, + leadingIcon = { + Icon( + modifier = Modifier.size(AssistChipDefaults.IconSize), + imageVector = if (delayBeforeNextAction == null) { + Icons.Rounded.MoreTime + } else { + Icons.Rounded.Timelapse + }, + contentDescription = null, + ) + }, + colors = colors, + border = null, + ) + } +} + +@Composable +private fun DelayBeforeNextActionDialog( + initialDelay: Int?, + onSaveClick: (Int) -> Unit, + onDismissRequest: () -> Unit, +) { + var delay by rememberSaveable { + mutableIntStateOf(initialDelay ?: SliderStepSizes.DELAY_BEFORE_NEXT_ACTION) + } + + CustomDialog( + title = stringResource(R.string.action_options_delay_header), + confirmButton = { + TextButton(onClick = { onSaveClick(delay) }) { + Text(stringResource(R.string.pos_save)) + } + }, + dismissButton = { + if (initialDelay == null) { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.neg_cancel)) + } + } else { + TextButton(onClick = { onSaveClick(0) }) { + Text(stringResource(R.string.action_list_delay_remove)) + } + } + }, + onDismissRequest = onDismissRequest, + ) { + val delayMin = SliderMinimums.DELAY_BEFORE_NEXT_ACTION.toFloat() + val delayMax = SliderMaximums.DELAY_BEFORE_NEXT_ACTION.toFloat() + + SliderOptionText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + title = null, + defaultValue = delayMin, + value = delay.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { delay = it.toInt() }, + valueRange = delayMin..delayMax, + stepSize = SliderStepSizes.DELAY_BEFORE_NEXT_ACTION, + ) + } +} + @Preview @Composable private fun EmptyPreview() { @@ -391,19 +589,25 @@ private fun LoadedPreview() { ActionListItemModel( id = "1", icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn), - text = "Toggle Back flashlight", - secondaryText = "Repeat until released", + title = "Toggle Back flashlight", + summary = "Repeat 5x until pressed again • Hold down", error = "Flashlight not found", isErrorFixable = true, - linkType = LinkType.ARROW, + showDelayChip = true, ), ActionListItemModel( id = "2", + icon = ComposeIconInfo.Vector(Icons.Rounded.Pinch), + title = "Open magnifier", + isCustomName = true, + showDelayChip = true, + delayBeforeNextAction = 100, + isEnabled = false, + ), + ActionListItemModel( + id = "3", icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn), - text = "Toggle Back flashlight", - secondaryText = "Repeat until released", - error = null, - isErrorFixable = true, + title = "Toggle Back flashlight", ), ), shortcuts = setOf( @@ -427,3 +631,15 @@ private fun LoadedPreview() { ) } } + +@Preview(showSystemUi = true) +@Composable +private fun DelayDialogPreview() { + KeyMapperTheme { + DelayBeforeNextActionDialog( + initialDelay = 400, + onSaveClick = {}, + onDismissRequest = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt index 0915c11e8b..0e5a33371a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionScreen.kt @@ -60,6 +60,7 @@ fun HandleActionBottomSheets(delegate: CreateActionDelegate) { ModifySettingActionBottomSheet(delegate) CreateNotificationActionBottomSheet(delegate) ToastActionBottomSheet(delegate) + VibrateActionBottomSheet(delegate) StepMediaActionBottomSheet(delegate) PickTalkBackGestureDialog(delegate) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionViewModel.kt index df76275e5f..1421b9e235 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ChooseActionViewModel.kt @@ -15,6 +15,7 @@ import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemGroup import io.github.sds100.keymapper.base.utils.ui.compose.SimpleListItemModel import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.SystemError import io.github.sds100.keymapper.system.permissions.Permission @@ -33,6 +34,7 @@ import kotlinx.serialization.json.Json @HiltViewModel class ChooseActionViewModel @Inject constructor( private val useCase: CreateActionUseCase, + private val vibrateConfigDelegate: VibrateConfigDelegate, resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, @@ -60,7 +62,7 @@ class ChooseActionViewModel @Inject constructor( } val createActionDelegate = - CreateActionDelegate(viewModelScope, useCase, this, this, this) + CreateActionDelegate(viewModelScope, useCase, this, this, this, vibrateConfigDelegate) private val allGroupedListItems: List by lazy { buildListGroups() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt index 96aee5ec15..2fb3bd8548 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCase.kt @@ -8,11 +8,13 @@ import io.github.sds100.keymapper.base.keymaps.GetDefaultKeyMapOptionsUseCase import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.dataOrNull import io.github.sds100.keymapper.common.utils.moveElement import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import io.github.sds100.keymapper.system.inputevents.KeyEventUtils import java.util.LinkedList +import java.util.UUID import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -40,31 +42,33 @@ class ConfigActionsUseCaseImpl @Inject constructor( .map(::getActionShortcuts) .map { it.take(5) } - override fun addAction(data: ActionData) { - state.update { keyMap -> - val newActionList = keyMap.actionList.toMutableList().apply { - add(createAction(keyMap, data)) - } + override fun addAction(data: ActionData): Action? { + val newKeyMap = state.update { keyMap -> + val action = createAction(keyMap, data) - preferenceRepository.update( - Keys.recentlyUsedActions, - { old -> - val oldList: List = if (old == null) { - emptyList() - } else { - Json.decodeFromString>(old) - } - - val newShortcuts = LinkedList(oldList) - .also { it.addFirst(data) } - .distinct() - - Json.encodeToString(newShortcuts) - }, - ) + val newActionList = keyMap.actionList.plus(action) keyMap.copy(actionList = newActionList) } + + preferenceRepository.update( + Keys.recentlyUsedActions, + { old -> + val oldList: List = if (old == null) { + emptyList() + } else { + Json.decodeFromString>(old) + } + + val newShortcuts = LinkedList(oldList) + .also { it.addFirst(data) } + .distinct() + + Json.encodeToString(newShortcuts) + }, + ) + + return newKeyMap.dataOrNull()?.actionList?.last() } override fun moveAction(fromIndex: Int, toIndex: Int) { @@ -83,6 +87,20 @@ class ConfigActionsUseCaseImpl @Inject constructor( } } + override fun duplicateAction(uid: String) { + updateActionList { actionList -> + val index = actionList.indexOfFirst { it.uid == uid } + + if (index == -1) { + return@updateActionList actionList + } + + val duplicate = actionList[index].copy(uid = UUID.randomUUID().toString()) + + actionList.toMutableList().apply { add(index + 1, duplicate) } + } + } + override fun setActionData(uid: String, data: ActionData) { updateActionList { actionList -> actionList.map { action -> @@ -183,6 +201,16 @@ class ConfigActionsUseCaseImpl @Inject constructor( } } + override fun setActionCustomName(uid: String, customName: String?) { + setActionOption(uid) { action -> + action.copy(customName = customName?.trim()?.takeIf { it.isNotEmpty() }) + } + } + + override fun setActionEnabled(uid: String, enabled: Boolean) { + setActionOption(uid) { action -> action.copy(isEnabled = enabled) } + } + private suspend fun getActionShortcuts(json: String?): List { if (json == null) { return emptyList() @@ -222,12 +250,18 @@ class ConfigActionsUseCaseImpl @Inject constructor( repeat = true } + // Actions repeat until the trigger is released by default so do not repeat if + // the release of the trigger can not be detected. + if (!keyMap.isRepeatUntilReleasedAllowed()) { + repeat = false + } + if (data is ActionData.AnswerCall) { - configConstraints.addConstraint(ConstraintData.PhoneRinging) + configConstraints.addConstraint(groupUid = null, ConstraintData.PhoneRinging) } if (data is ActionData.EndCall) { - configConstraints.addConstraint(ConstraintData.InPhoneCall) + configConstraints.addConstraint(groupUid = null, ConstraintData.InPhoneCall) } return Action( @@ -261,14 +295,20 @@ class ConfigActionsUseCaseImpl @Inject constructor( interface ConfigActionsUseCase : GetDefaultKeyMapOptionsUseCase { val keyMap: StateFlow> - fun addAction(data: ActionData) + /** + * @return the new action, or null if no key map was set. + */ + fun addAction(data: ActionData): Action? fun moveAction(fromIndex: Int, toIndex: Int) fun removeAction(uid: String) + fun duplicateAction(uid: String) val recentlyUsedActions: Flow> fun setActionData(uid: String, data: ActionData) fun setActionMultiplier(uid: String, multiplier: Int) fun setDelayBeforeNextAction(uid: String, delay: Int) + fun setActionCustomName(uid: String, customName: String?) + fun setActionEnabled(uid: String, enabled: Boolean) fun setActionRepeatRate(uid: String, repeatRate: Int) fun setActionRepeatLimit(uid: String, repeatLimit: Int) fun setActionStopRepeatingWhenTriggerPressedAgain(uid: String) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt index b606478b92..b384eb3da4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/ConfigActionsViewModel.kt @@ -18,10 +18,10 @@ import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.DialogProvider -import io.github.sds100.keymapper.base.utils.ui.LinkType import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.ViewModelHelper import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate import io.github.sds100.keymapper.common.utils.AccessibilityServiceError import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.State @@ -53,12 +53,14 @@ class ConfigActionsViewModel @Inject constructor( private val onboardingUseCase: OnboardingUseCase, setupAccessibilityServiceDelegate: SetupAccessibilityServiceDelegate, fixKeyEventActionDelegate: FixKeyEventActionDelegate, - onboardingTipDelegate: OnboardingTipDelegate, + private val onboardingTipDelegate: OnboardingTipDelegate, + private val vibrateConfigDelegate: VibrateConfigDelegate, resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, ) : ViewModel(), ActionOptionsBottomSheetCallback, + ActionListCallback, SetupAccessibilityServiceDelegate by setupAccessibilityServiceDelegate, ResourceProvider by resourceProvider, DialogProvider by dialogProvider, @@ -67,7 +69,7 @@ class ConfigActionsViewModel @Inject constructor( OnboardingTipDelegate by onboardingTipDelegate { val createActionDelegate = - CreateActionDelegate(viewModelScope, createAction, this, this, this) + CreateActionDelegate(viewModelScope, createAction, this, this, this, vibrateConfigDelegate) private val uiHelper = ActionUiHelper(displayAction, resourceProvider) private val _state = MutableStateFlow>(State.Loading) @@ -78,6 +80,9 @@ class ConfigActionsViewModel @Inject constructor( actions.map(::buildShortcut).toSet() }.stateIn(viewModelScope, SharingStarted.Lazily, emptySet()) + private val expandedActions: MutableStateFlow> = MutableStateFlow(emptySet()) + private val knownActions: MutableSet = mutableSetOf() + val actionOptionsUid = MutableStateFlow(null) val actionOptionsState: StateFlow = combine(config.keyMap, actionOptionsUid, transform = ::buildOptionsState) @@ -89,14 +94,40 @@ class ConfigActionsViewModel @Inject constructor( displayAction.actionErrorSnapshot.stateIn(viewModelScope, SharingStarted.Lazily, null) init { + viewModelScope.launch { + config.keyMap + .map { state -> state.dataOrNull()?.actionList } + .filterNotNull() + .collect { actionList -> + val errorSnapshot = displayAction.actionErrorSnapshot.first() + val newExpandedActions = mutableSetOf() + + for (action in actionList) { + // Only expand an action automatically if it is the first time it has + // been seen. + if (!knownActions.contains(action.uid) && + errorSnapshot.getError(action.data) != null + ) { + newExpandedActions.add(action.uid) + } + } + + // Expand all the actions at once and not one-by-one + expandedActions.update { set -> set.plus(newExpandedActions) } + + knownActions.addAll(actionList.map { it.uid }) + } + } + combine( config.keyMap, shortcuts, displayAction.showDeviceDescriptors, actionErrorSnapshot.filterNotNull(), - ) { keyMapState, shortcuts, showDeviceDescriptors, errorSnapshot -> + expandedActions, + ) { keyMapState, shortcuts, showDeviceDescriptors, errorSnapshot, expandedActions -> _state.value = keyMapState.mapData { keyMap -> - buildState(keyMap, shortcuts, errorSnapshot, showDeviceDescriptors) + buildState(keyMap, shortcuts, errorSnapshot, showDeviceDescriptors, expandedActions) } }.launchIn(viewModelScope) @@ -113,13 +144,17 @@ class ConfigActionsViewModel @Inject constructor( return config.keyMap.first().dataOrNull()?.actionList?.singleOrNull { it.uid == uid }?.data } - fun onClickShortcut(action: ActionData) { + override fun onClickShortcut(data: ActionData) { viewModelScope.launch { - config.addAction(action) + val action = config.addAction(data) + + if (action != null) { + expandedActions.update { set -> set.plus(action.uid) } + } } } - fun onFixError(actionUid: String) { + override fun onFixErrorClick(actionUid: String) { viewModelScope.launch { val actionData = getActionData(actionUid) ?: return@launch val error = @@ -160,14 +195,18 @@ class ConfigActionsViewModel @Inject constructor( viewModelScope.launch { val actionData = navigate("add_action", NavDestination.ChooseAction) ?: return@launch - config.addAction(actionData) + val action = config.addAction(actionData) + + if (action != null) { + expandedActions.update { set -> set.plus(action.uid) } + } // Never show the tap target to add an action again. onboardingUseCase.completedTapTarget(OnboardingTapTarget.CHOOSE_ACTION) } } - fun onMoveAction(fromIndex: Int, toIndex: Int) { + override fun onMove(fromIndex: Int, toIndex: Int) { config.moveAction(fromIndex, toIndex) } @@ -175,17 +214,21 @@ class ConfigActionsViewModel @Inject constructor( config.removeAction(actionUid) } - fun onEditClick(actionUid: String) { + override fun onEditClick(actionUid: String) { actionOptionsUid.value = actionUid } - fun onTestClick(actionUid: String) { + override fun onTestClick(actionUid: String) { viewModelScope.launch { val actionData = getActionData(actionUid) ?: return@launch attemptTestAction(actionData) } } + override fun onDuplicateClick(actionUid: String) { + config.duplicateAction(actionUid) + } + override fun onEditClick() { val actionUid = actionOptionsUid.value ?: return viewModelScope.launch { @@ -213,10 +256,6 @@ class ConfigActionsViewModel @Inject constructor( } } - override fun onRepeatCheckedChange(checked: Boolean) { - actionOptionsUid.value?.let { uid -> config.setActionRepeatEnabled(uid, checked) } - } - override fun onRepeatLimitChanged(limit: Int) { actionOptionsUid.value?.let { uid -> config.setActionRepeatLimit(uid, limit) } } @@ -229,43 +268,87 @@ class ConfigActionsViewModel @Inject constructor( actionOptionsUid.value?.let { uid -> config.setActionRepeatDelay(uid, delay) } } - override fun onHoldDownCheckedChange(checked: Boolean) { - actionOptionsUid.value?.let { uid -> config.setActionHoldDownEnabled(uid, checked) } - } - override fun onHoldDownDurationChanged(duration: Int) { actionOptionsUid.value?.let { uid -> config.setActionHoldDownDuration(uid, duration) } } - override fun onSelectHoldDownMode(holdDownMode: HoldDownMode) { - actionOptionsUid.value?.let { uid -> - config.setActionStopHoldingDownWhenTriggerPressedAgain( - uid, - holdDownMode == HoldDownMode.TRIGGER_PRESSED_AGAIN, - ) + override fun onSelectHoldDownMode(holdDownMode: HoldDownMode?) { + val uid = actionOptionsUid.value ?: return + + if (holdDownMode == null) { + config.setActionHoldDownEnabled(uid, false) + return } + + config.setActionHoldDownEnabled(uid, true) + config.setActionStopHoldingDownWhenTriggerPressedAgain( + uid, + holdDownMode == HoldDownMode.TRIGGER_PRESSED_AGAIN, + ) + } + + fun onDelayChanged(actionUid: String, delay: Int) { + config.setDelayBeforeNextAction(actionUid, delay) } - override fun onDelayBeforeNextActionChanged(delay: Int) { - actionOptionsUid.value?.let { uid -> config.setDelayBeforeNextAction(uid, delay) } + override fun onEnabledChange(actionUid: String, enabled: Boolean) { + config.setActionEnabled(actionUid, enabled) + } + + override fun onActionTipDismiss() { + onActionTipDismissClick() + } + + override fun onTipButtonClick(id: String) { + onboardingTipDelegate.onTipButtonClick(id) } override fun onMultiplierChanged(multiplier: Int) { actionOptionsUid.value?.let { uid -> config.setActionMultiplier(uid, multiplier) } } - override fun onSelectRepeatMode(repeatMode: RepeatMode) { - actionOptionsUid.value?.let { uid -> - when (repeatMode) { - RepeatMode.TRIGGER_RELEASED -> config.setActionStopRepeatingWhenTriggerReleased( - uid, - ) + override fun onSelectRepeatMode(repeatMode: RepeatMode?) { + val uid = actionOptionsUid.value ?: return + + if (repeatMode == null) { + config.setActionRepeatEnabled(uid, false) + return + } - RepeatMode.LIMIT_REACHED -> config.setActionStopRepeatingWhenLimitReached(uid) + config.setActionRepeatEnabled(uid, true) - RepeatMode.TRIGGER_PRESSED_AGAIN -> - config.setActionStopRepeatingWhenTriggerPressedAgain(uid) - } + when (repeatMode) { + RepeatMode.TRIGGER_RELEASED -> config.setActionStopRepeatingWhenTriggerReleased(uid) + + RepeatMode.LIMIT_REACHED -> config.setActionStopRepeatingWhenLimitReached(uid) + + RepeatMode.TRIGGER_PRESSED_AGAIN -> + config.setActionStopRepeatingWhenTriggerPressedAgain(uid) + } + } + + override fun onCustomNameChanged(name: String) { + val uid = actionOptionsUid.value ?: return + onRenameAction(uid, name) + } + + override fun onExpandedChange(id: String, expanded: Boolean) { + if (expanded) { + expandedActions.update { set -> set.plus(id) } + } else { + expandedActions.update { set -> set.minus(id) } + } + } + + fun onRenameAction(uid: String, name: String) { + viewModelScope.launch { + val actionData = getActionData(uid) ?: return@launch + val showDeviceDescriptors = displayAction.showDeviceDescriptors.first() + val generatedTitle = uiHelper.getTitle(actionData, showDeviceDescriptors) + + // Submitting the generated title removes the custom name. + val customName = name.trim().takeIf { it.isNotEmpty() && it != generatedTitle } + config.setActionCustomName(uid, customName) } } @@ -290,13 +373,14 @@ class ConfigActionsViewModel @Inject constructor( shortcuts: Set>, errorSnapshot: ActionErrorSnapshot, showDeviceDescriptors: Boolean, + expandedActions: Set, ): ConfigActionsState { if (keyMap.actionList.isEmpty()) { return ConfigActionsState.Empty(shortcuts = shortcuts) } val actions = - createListItems(keyMap, showDeviceDescriptors, errorSnapshot) + createListItems(keyMap, showDeviceDescriptors, errorSnapshot, expandedActions) return ConfigActionsState.Loaded( actions = actions, @@ -309,63 +393,41 @@ class ConfigActionsViewModel @Inject constructor( keyMap: KeyMap, showDeviceDescriptors: Boolean, errorSnapshot: ActionErrorSnapshot, + expandedActions: Set, ): List { val actionErrors = errorSnapshot.getErrors(keyMap.actionList.map { it.data }) - return keyMap.actionList.mapIndexed { index, action -> - - val title: String = if (action.multiplier != null && action.multiplier > 1) { - val multiplier = action.multiplier - "${multiplier}x ${uiHelper.getTitle(action.data, showDeviceDescriptors)}" - } else { - uiHelper.getTitle(action.data, showDeviceDescriptors) - } + val midDot = getString(R.string.middot) + return keyMap.actionList.mapIndexed { index, action -> val icon: ComposeIconInfo = uiHelper.getIcon(action.data) val error: KMError? = actionErrors[action.data] - val extraInfo = buildString { - val midDot = getString(R.string.middot) + val repeatText = uiHelper.getRepeatDescription(keyMap, action) + val burstText = uiHelper.getBurstDescription(action) + val holdDownText = uiHelper.getHoldDownDescription(keyMap, action) - uiHelper.getOptionLabels(keyMap, action).forEachIndexed { index, label -> - if (index != 0) { - append(" $midDot ") - } - - append(label) - } - - action.delayBeforeNextAction.apply { - if (keyMap.isDelayBeforeNextActionAllowed() && - action.delayBeforeNextAction != null - ) { - if (this@buildString.isNotBlank()) { - append(" $midDot ") - } - - append( - getString( - R.string.action_title_wait, - action.delayBeforeNextAction, - ), - ) - } - } - }.takeIf { it.isNotBlank() } - - val linkType = when { - index < keyMap.actionList.lastIndex -> LinkType.ARROW - else -> LinkType.HIDDEN - } + val summary = listOfNotNull(repeatText, burstText, holdDownText) + .joinToString(" $midDot ") + .takeIf { it.isNotBlank() } ActionListItemModel( id = action.uid, icon = icon, - text = title, - secondaryText = extraInfo, + title = uiHelper.getTitle(action, showDeviceDescriptors), + isCustomName = !action.customName.isNullOrBlank(), + isEnabled = action.isEnabled, + isExpanded = expandedActions.contains(action.uid), + summary = summary, error = error?.getFullMessage(this), isErrorFixable = error?.isFixable ?: true, - linkType = linkType, + showRepeat = keyMap.isRepeatingActionsAllowed(), + repeatText = repeatText, + burstText = burstText, + showHoldDown = keyMap.isHoldingDownActionAllowed(action), + holdDownText = holdDownText, + showDelayChip = index < keyMap.actionList.lastIndex, + delayBeforeNextAction = action.delayBeforeNextAction, ) } } @@ -382,12 +444,17 @@ class ConfigActionsViewModel @Inject constructor( val allowedRepeatModes = mutableSetOf() - if (keyMap.isChangingRepeatModeAllowed(action)) { - allowedRepeatModes.add(RepeatMode.TRIGGER_RELEASED) + if (keyMap.isRepeatingActionsAllowed()) { + if (keyMap.isRepeatUntilReleasedAllowed()) { + allowedRepeatModes.add(RepeatMode.TRIGGER_RELEASED) + } + allowedRepeatModes.add(RepeatMode.TRIGGER_PRESSED_AGAIN) allowedRepeatModes.add(RepeatMode.LIMIT_REACHED) } + val showDeviceDescriptors = displayAction.showDeviceDescriptors.first() + val defaultRepeatRate = config.defaultRepeatRate.first() val defaultRepeatDelay = config.defaultRepeatDelay.first() val defaultHoldDownDuration = config.defaultHoldDownDuration.first() @@ -404,6 +471,10 @@ class ConfigActionsViewModel @Inject constructor( keyMap.trigger.keys.any { it is EvdevTriggerKey } return ActionOptionsState( + title = uiHelper.getTitle(action, showDeviceDescriptors), + actionTypeTitle = getString(ActionUtils.getTitle(action.data.id)), + actionTypeIcon = ActionUtils.getComposeIcon(action.data.id), + showEditButton = action.data.isEditable(), showRepeat = keyMap.isRepeatingActionsAllowed(), @@ -432,19 +503,12 @@ class ConfigActionsViewModel @Inject constructor( holdDownDuration = action.holdDownDuration ?: defaultHoldDownDuration, defaultHoldDownDuration = defaultHoldDownDuration, - showHoldDownMode = keyMap.isStopHoldingDownActionWhenTriggerPressedAgainAllowed( - action, - ), holdDownMode = if (action.stopHoldDownWhenTriggerPressedAgain) { HoldDownMode.TRIGGER_PRESSED_AGAIN } else { HoldDownMode.TRIGGER_RELEASED }, - showDelayBeforeNextAction = keyMap.isDelayBeforeNextActionAllowed(), - delayBeforeNextAction = action.delayBeforeNextAction ?: 0, - defaultDelayBeforeNextAction = 0, - multiplier = action.multiplier ?: 1, defaultMultiplier = 1, ) @@ -463,53 +527,3 @@ sealed class ConfigActionsState { override val shortcuts: Set> = emptySet(), ) : ConfigActionsState() } - -data class ActionListItemModel( - val id: String, - val icon: ComposeIconInfo, - val text: String, - val secondaryText: String?, - val error: String? = null, - val isErrorFixable: Boolean = true, - val linkType: LinkType = LinkType.HIDDEN, -) - -data class ActionOptionsState( - val showEditButton: Boolean, - - val showRepeat: Boolean, - val isRepeatChecked: Boolean, - - val showRepeatRate: Boolean, - val showRepeatRateWarning: Boolean, - val repeatRate: Int, - val defaultRepeatRate: Int, - - val showRepeatDelay: Boolean, - val repeatDelay: Int, - val defaultRepeatDelay: Int, - - val showRepeatLimit: Boolean, - val repeatLimit: Int, - val defaultRepeatLimit: Int, - - val allowedRepeatModes: Set, - val repeatMode: RepeatMode, - - val showHoldDown: Boolean, - val isHoldDownChecked: Boolean, - - val showHoldDownDuration: Boolean, - val holdDownDuration: Int, - val defaultHoldDownDuration: Int, - - val showHoldDownMode: Boolean, - val holdDownMode: HoldDownMode, - - val showDelayBeforeNextAction: Boolean, - val delayBeforeNextAction: Int, - val defaultDelayBeforeNextAction: Int, - - val multiplier: Int, - val defaultMultiplier: Int, -) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt index 66671fef6f..f58bd83d0b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/CreateActionDelegate.kt @@ -22,6 +22,7 @@ import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.MultiChoiceItem import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.SystemError @@ -43,6 +44,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.Json +private const val DEFAULT_VIBRATE_ACTION_DURATION_MS = 200 + @OptIn(ExperimentalCoroutinesApi::class) class CreateActionDelegate( private val coroutineScope: CoroutineScope, @@ -50,9 +53,11 @@ class CreateActionDelegate( dialogProvider: DialogProvider, navigationProvider: NavigationProvider, resourceProvider: ResourceProvider, + vibrateConfigDelegate: VibrateConfigDelegate, ) : ResourceProvider by resourceProvider, DialogProvider by dialogProvider, - NavigationProvider by navigationProvider { + NavigationProvider by navigationProvider, + VibrateConfigDelegate by vibrateConfigDelegate { val actionResult: MutableStateFlow = MutableStateFlow(null) var enableFlashlightActionState: EnableFlashlightActionState? by mutableStateOf(null) @@ -389,6 +394,12 @@ class CreateActionDelegate( actionResult.update { action } } + fun onDoneVibrateClick() { + val effect = buildVibrateEffect() ?: return + closeVibrateConfig() + actionResult.update { ActionData.Vibrate(effect) } + } + fun onStepMediaDurationEnabledChange(enabled: Boolean) { stepMediaActionBottomSheetState = stepMediaActionBottomSheetState?.copy(durationEnabled = enabled) @@ -412,9 +423,17 @@ class CreateActionDelegate( ActionId.STEP_FORWARD -> ActionData.ControlMedia.StepForward(durationMs) ActionId.STEP_BACKWARD -> ActionData.ControlMedia.StepBackward(durationMs) ActionId.STEP_FORWARD_PACKAGE -> - ActionData.ControlMediaForApp.StepForward(state.packageName!!, durationMs) + ActionData.ControlMediaForApp.StepForward( + state.packageName!!, + durationMs, + state.appName, + ) ActionId.STEP_BACKWARD_PACKAGE -> - ActionData.ControlMediaForApp.StepBackward(state.packageName!!, durationMs) + ActionData.ControlMediaForApp.StepBackward( + state.packageName!!, + durationMs, + state.appName, + ) else -> throw Exception("don't know how to create action for ${state.actionId}") } @@ -466,7 +485,7 @@ class CreateActionDelegate( ActionId.REWIND_PACKAGE, ActionId.STOP_MEDIA_PACKAGE, -> { - val packageName = + val result = navigate( "choose_app_for_media_action", NavDestination.ChooseApp(allowHiddenApps = true), @@ -474,28 +493,34 @@ class CreateActionDelegate( val action = when (actionId) { ActionId.PAUSE_MEDIA_PACKAGE -> - ActionData.ControlMediaForApp.Pause(packageName) + ActionData.ControlMediaForApp.Pause(result.packageName, result.appName) ActionId.PLAY_MEDIA_PACKAGE -> - ActionData.ControlMediaForApp.Play(packageName) + ActionData.ControlMediaForApp.Play(result.packageName, result.appName) ActionId.PLAY_PAUSE_MEDIA_PACKAGE -> - ActionData.ControlMediaForApp.PlayPause(packageName) + ActionData.ControlMediaForApp.PlayPause(result.packageName, result.appName) ActionId.NEXT_TRACK_PACKAGE -> - ActionData.ControlMediaForApp.NextTrack(packageName) + ActionData.ControlMediaForApp.NextTrack(result.packageName, result.appName) ActionId.PREVIOUS_TRACK_PACKAGE -> - ActionData.ControlMediaForApp.PreviousTrack(packageName) + ActionData.ControlMediaForApp.PreviousTrack( + result.packageName, + result.appName, + ) ActionId.FAST_FORWARD_PACKAGE -> - ActionData.ControlMediaForApp.FastForward(packageName) + ActionData.ControlMediaForApp.FastForward( + result.packageName, + result.appName, + ) ActionId.REWIND_PACKAGE -> - ActionData.ControlMediaForApp.Rewind(packageName) + ActionData.ControlMediaForApp.Rewind(result.packageName, result.appName) ActionId.STOP_MEDIA_PACKAGE -> - ActionData.ControlMediaForApp.Stop(packageName) + ActionData.ControlMediaForApp.Stop(result.packageName, result.appName) else -> throw Exception("don't know how to create action for $actionId") } @@ -506,7 +531,7 @@ class CreateActionDelegate( ActionId.STEP_FORWARD_PACKAGE, ActionId.STEP_BACKWARD_PACKAGE, -> { - val packageName = + val result = navigate( "choose_app_for_media_action", NavDestination.ChooseApp(allowHiddenApps = true), @@ -520,7 +545,8 @@ class CreateActionDelegate( stepMediaActionBottomSheetState = StepMediaActionBottomSheetState( actionId = actionId, - packageName = packageName, + packageName = result.packageName, + appName = result.appName, durationEnabled = oldStepDurationMs != null, durationSeconds = ((oldStepDurationMs ?: 30000L) / 1000).toInt(), ) @@ -784,14 +810,17 @@ class CreateActionDelegate( } ActionId.APP -> { - val packageName = + val result = navigate( "choose_app_for_app_action", NavDestination.ChooseApp(allowHiddenApps = false), ) ?: return null - return ActionData.App(packageName) + return ActionData.App( + packageName = result.packageName, + savedAppName = result.appName, + ) } ActionId.APP_SHORTCUT -> { @@ -803,6 +832,7 @@ class CreateActionDelegate( appShortcutResult.packageName, appShortcutResult.shortcutName, appShortcutResult.uri, + appShortcutResult.appName, ) } @@ -1172,6 +1202,8 @@ class CreateActionDelegate( ActionId.CYCLE_KEYBOARD_LANGUAGE -> return ActionData.CycleKeyboardLanguage + ActionId.CYCLE_KEYBOARD -> return ActionData.CycleKeyboard + ActionId.TEXT_CUT -> return ActionData.CutText ActionId.TEXT_COPY -> return ActionData.CopyText @@ -1236,6 +1268,12 @@ class CreateActionDelegate( return null } + ActionId.VIBRATE -> { + val oldEffect = (oldData as? ActionData.Vibrate)?.effect + openVibrateConfig(oldEffect, DEFAULT_VIBRATE_ACTION_DURATION_MS) + return null + } + ActionId.ANSWER_PHONE_CALL -> return ActionData.AnswerCall ActionId.END_PHONE_CALL -> return ActionData.EndCall diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt index fcbba68c89..6137fe85e7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt @@ -21,6 +21,7 @@ import io.github.sds100.keymapper.base.system.navigation.OpenMenuHelper import io.github.sds100.keymapper.base.system.notifications.NotificationController import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.base.vibration.vibrate import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMError.SdkVersionTooLow @@ -60,8 +61,6 @@ import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.nfc.NfcAdapter import io.github.sds100.keymapper.system.notifications.NotificationAdapter import io.github.sds100.keymapper.system.notifications.NotificationModel -import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapter -import io.github.sds100.keymapper.system.notifications.NotificationServiceEvent import io.github.sds100.keymapper.system.phone.PhoneAdapter import io.github.sds100.keymapper.system.popup.ToastAdapter import io.github.sds100.keymapper.system.ringtones.RingtoneAdapter @@ -70,6 +69,7 @@ import io.github.sds100.keymapper.system.settings.SettingType import io.github.sds100.keymapper.system.settings.SettingsAdapter import io.github.sds100.keymapper.system.shell.ShellAdapter import io.github.sds100.keymapper.system.url.OpenUrlAdapter +import io.github.sds100.keymapper.system.vibrator.VibratorAdapter import io.github.sds100.keymapper.system.volume.RingerMode import io.github.sds100.keymapper.system.volume.VolumeAdapter import io.github.sds100.keymapper.system.volume.VolumeStream @@ -116,13 +116,13 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( private val openUrlAdapter: OpenUrlAdapter, private val resourceProvider: ResourceProvider, private val soundsManager: SoundsManager, - private val notificationReceiverAdapter: NotificationReceiverAdapter, private val notificationAdapter: NotificationAdapter, private val ringtoneAdapter: RingtoneAdapter, private val settingsRepository: PreferenceRepository, private val inputEventHub: InputEventHub, private val systemBridgeConnectionManager: SystemBridgeConnectionManager, private val settingsAdapter: SettingsAdapter, + private val vibratorAdapter: VibratorAdapter, ) : PerformActionsUseCase { companion object { @@ -304,6 +304,23 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = inputMethodAdapter.cycleInputMethodSubtype() } + is ActionData.CycleKeyboard -> { + result = inputMethodAdapter.getNextInputMethod().then { nextIme -> + switchImeInterface.switchIme(nextIme.id) + + // See issue #1064. Wait for the input method to finish switching before returning. + val chosenIme = withTimeoutOrNull(2000) { + inputMethodAdapter.chosenIme.filterNotNull().first { it.id == nextIme.id } + } + + if (chosenIme == null) { + KMError.SwitchImeFailed + } else { + Success(Unit) + } + } + } + is ActionData.Volume.Down -> { result = audioAdapter.lowerVolume( stream = action.volumeStream, @@ -977,17 +994,11 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( } ActionData.DismissAllNotifications -> { - result = - notificationReceiverAdapter.send( - NotificationServiceEvent.DismissAllNotifications, - ) + result = notificationAdapter.dismissAllNotifications() } ActionData.DismissLastNotification -> { - result = - notificationReceiverAdapter.send( - NotificationServiceEvent.DismissLastNotification, - ) + result = notificationAdapter.dismissLastNotification() } is ActionData.CreateNotification -> { @@ -1019,6 +1030,11 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( result = success() } + is ActionData.Vibrate -> { + vibratorAdapter.vibrate(action.effect) + result = success() + } + ActionData.AnswerCall -> { phoneAdapter.answerCall() result = success() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/StepMediaActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/StepMediaActionBottomSheet.kt index 34a683af92..80c60f48c4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/actions/StepMediaActionBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/StepMediaActionBottomSheet.kt @@ -40,6 +40,7 @@ private const val DURATION_STEP_SECONDS = 5 data class StepMediaActionBottomSheetState( val actionId: ActionId, val packageName: String? = null, + val appName: String? = null, val durationEnabled: Boolean = false, /** * UI works with seconds for user-friendliness diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/VibrateActionBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/VibrateActionBottomSheet.kt new file mode 100644 index 0000000000..3fe3e3700d --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/VibrateActionBottomSheet.kt @@ -0,0 +1,156 @@ +package io.github.sds100.keymapper.base.actions + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.VibrationEffectMode +import io.github.sds100.keymapper.base.utils.ui.compose.VibrationEffectPickerContent +import io.github.sds100.keymapper.base.vibration.VibrateConfigState +import io.github.sds100.keymapper.base.vibration.VibrateEffect +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VibrateActionBottomSheet(delegate: CreateActionDelegate) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val state = delegate.vibrateConfigState + + if (state != null) { + VibrateActionBottomSheet( + sheetState = sheetState, + state = state, + onDismissRequest = delegate::closeVibrateConfig, + onModeChange = delegate::onVibrateModeChange, + onDurationChange = delegate::onVibrateDurationChange, + onPredefinedTypeChange = delegate::onVibratePredefinedTypeChange, + onDoneClick = { + scope.launch { + sheetState.hide() + delegate.onDoneVibrateClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VibrateActionBottomSheet( + sheetState: SheetState, + state: VibrateConfigState, + onDismissRequest: () -> Unit = {}, + onModeChange: (VibrationEffectMode) -> Unit = {}, + onDurationChange: (Int) -> Unit = {}, + onPredefinedTypeChange: (VibrateEffect.PredefinedType) -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + textAlign = TextAlign.Center, + text = stringResource(R.string.action_vibrate), + style = MaterialTheme.typography.headlineMedium, + ) + + VibrationEffectPickerContent( + modifier = Modifier.fillMaxWidth(), + mode = state.mode, + onModeChange = onModeChange, + durationMs = state.durationMs, + onDurationChange = onDurationChange, + defaultDurationMs = state.defaultDurationMs, + predefinedType = state.predefinedType, + onPredefinedTypeChange = onPredefinedTypeChange, + isPredefinedSupported = state.isPredefinedSupported, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + modifier = Modifier.weight(1f), + onClick = onDoneClick, + ) { + Text(stringResource(R.string.pos_done)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun VibrateActionBottomSheetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + + VibrateActionBottomSheet( + sheetState = sheetState, + state = VibrateConfigState(durationMs = 200, defaultDurationMs = 200), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt index 5b6f935c88..8c5cee4f1f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/backup/BackupManager.kt @@ -33,6 +33,7 @@ import io.github.sds100.keymapper.data.PreferenceDefaults import io.github.sds100.keymapper.data.db.AppDatabase import io.github.sds100.keymapper.data.entities.ActionEntity import io.github.sds100.keymapper.data.entities.ConstraintEntity +import io.github.sds100.keymapper.data.entities.ConstraintGroupEntity import io.github.sds100.keymapper.data.entities.EntityExtra import io.github.sds100.keymapper.data.entities.FingerprintMapEntity import io.github.sds100.keymapper.data.entities.FloatingButtonEntity @@ -119,6 +120,7 @@ class BackupManagerImpl @Inject constructor( .registerTypeAdapter(ActionEntity.DESERIALIZER) .registerTypeAdapter(EntityExtra.DESERIALIZER) .registerTypeAdapter(ConstraintEntity.DESERIALIZER) + .registerTypeAdapter(ConstraintGroupEntity.DESERIALIZER) .registerTypeAdapter(FloatingLayoutEntity.DESERIALIZER) .registerTypeAdapter(FloatingButtonEntity.DESERIALIZER) .registerTypeAdapter(GroupEntity.DESERIALIZER) @@ -260,6 +262,11 @@ class BackupManagerImpl @Inject constructor( // Do nothing. Just added columns to floating button entity. JsonMigration(21, 22) { json -> json }, + + // Do nothing. Adds the constraint_groups column, which defaults to empty. + // The isNot/groupUid extras this replaces were still WIP and unreleased, + // so pre-existing key maps just fall back to isNot=false/groupUid=null. + JsonMigration(22, 23) { json -> json }, ) if (keyMapListJsonArray != null) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt index 2367aa378f..175a242a40 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ChooseConstraintViewModel.kt @@ -68,6 +68,7 @@ class ChooseConstraintViewModel @Inject constructor( ConstraintCategory.PHONE, ConstraintCategory.POWER, ConstraintCategory.DEVICE, + ConstraintCategory.NOTIFICATIONS, ConstraintCategory.TIME, ) } @@ -182,25 +183,17 @@ class ChooseConstraintViewModel @Inject constructor( when (val constraintType = ConstraintId.valueOf(id)) { ConstraintId.APP_IN_FOREGROUND, - ConstraintId.APP_NOT_IN_FOREGROUND, ConstraintId.APP_PLAYING_MEDIA, - ConstraintId.APP_NOT_PLAYING_MEDIA, -> onSelectAppConstraint(constraintType) ConstraintId.MEDIA_PLAYING -> returnResult.emit(ConstraintData.MediaPlaying) - ConstraintId.MEDIA_NOT_PLAYING -> returnResult.emit(ConstraintData.NoMediaPlaying) + ConstraintId.NOTIFICATION_POSTED -> onSelectNotificationConstraint() - ConstraintId.BT_DEVICE_CONNECTED, - ConstraintId.BT_DEVICE_DISCONNECTED, - -> onSelectBluetoothConstraint( - constraintType, - ) + ConstraintId.BT_DEVICE_CONNECTED -> onSelectBluetoothConstraint() ConstraintId.SCREEN_ON -> returnResult.emit(ConstraintData.ScreenOn) - ConstraintId.SCREEN_OFF -> returnResult.emit(ConstraintData.ScreenOff) - ConstraintId.DISPLAY_RESOLUTION -> { displayResolutionState = buildDisplayResolutionState() } @@ -264,37 +257,18 @@ class ChooseConstraintViewModel @Inject constructor( returnResult.emit(ConstraintData.FlashlightOn(lens = lens)) } - ConstraintId.FLASHLIGHT_OFF -> { - val lens = chooseFlashlightLens() ?: return@launch - returnResult.emit(ConstraintData.FlashlightOff(lens = lens)) - } - ConstraintId.WIFI_ON -> returnResult.emit(ConstraintData.WifiOn) - ConstraintId.WIFI_OFF -> returnResult.emit(ConstraintData.WifiOff) + ConstraintId.WIFI_CONNECTED -> onSelectWifiConnectedConstraint() - ConstraintId.WIFI_CONNECTED, - ConstraintId.WIFI_DISCONNECTED, - -> onSelectWifiConnectedConstraint( - constraintType, - ) - - ConstraintId.IME_CHOSEN, - ConstraintId.IME_NOT_CHOSEN, - -> onSelectImeChosenConstraint(constraintType) + ConstraintId.IME_CHOSEN -> onSelectImeChosenConstraint() ConstraintId.KEYBOARD_SHOWING -> returnResult.emit(ConstraintData.KeyboardShowing) - ConstraintId.KEYBOARD_NOT_SHOWING -> - returnResult.emit(ConstraintData.KeyboardNotShowing) - ConstraintId.DEVICE_IS_LOCKED -> returnResult.emit(ConstraintData.DeviceIsLocked) - ConstraintId.DEVICE_IS_UNLOCKED -> - returnResult.emit(ConstraintData.DeviceIsUnlocked) - ConstraintId.IN_PHONE_CALL -> returnResult.emit(ConstraintData.InPhoneCall) @@ -316,9 +290,6 @@ class ChooseConstraintViewModel @Inject constructor( ConstraintId.CHARGING -> returnResult.emit(ConstraintData.Charging) - ConstraintId.DISCHARGING -> - returnResult.emit(ConstraintData.Discharging) - ConstraintId.HINGE_CLOSED -> returnResult.emit(ConstraintData.HingeClosed) @@ -328,15 +299,9 @@ class ChooseConstraintViewModel @Inject constructor( ConstraintId.LOCK_SCREEN_SHOWING -> returnResult.emit(ConstraintData.LockScreenShowing) - ConstraintId.LOCK_SCREEN_NOT_SHOWING -> - returnResult.emit(ConstraintData.LockScreenNotShowing) - ConstraintId.NOTIFICATION_PANEL_SHOWING -> returnResult.emit(ConstraintData.NotificationPanelShowing) - ConstraintId.NOTIFICATION_PANEL_NOT_SHOWING -> - returnResult.emit(ConstraintData.NotificationPanelNotShowing) - ConstraintId.TIME -> { timeConstraintState = ConstraintData.Time( startHour = 0, @@ -542,7 +507,7 @@ class ChooseConstraintViewModel @Inject constructor( } } - private suspend fun onSelectWifiConnectedConstraint(type: ConstraintId) { + private suspend fun onSelectWifiConnectedConstraint() { val knownSSIDs: List = useCase.getKnownWiFiSSIDs() val chosenSSID: String? @@ -571,18 +536,10 @@ class ChooseConstraintViewModel @Inject constructor( useCase.saveWifiSSID(chosenSSID) } - when (type) { - ConstraintId.WIFI_CONNECTED -> - returnResult.emit(ConstraintData.WifiConnected(ssid = chosenSSID)) - - ConstraintId.WIFI_DISCONNECTED -> - returnResult.emit(ConstraintData.WifiDisconnected(ssid = chosenSSID)) - - else -> Unit - } + returnResult.emit(ConstraintData.WifiConnected(ssid = chosenSSID)) } - private suspend fun onSelectImeChosenConstraint(type: ConstraintId) { + private suspend fun onSelectImeChosenConstraint() { val inputMethods = useCase.getEnabledInputMethods() val items = inputMethods.map { it.id to it.label } val dialog = DialogModel.SingleChoice(items = items) @@ -591,28 +548,15 @@ class ChooseConstraintViewModel @Inject constructor( val imeInfo = inputMethods.single { it.id == result } - when (type) { - ConstraintId.IME_CHOSEN -> - returnResult.emit( - ConstraintData.ImeChosen( - imeId = imeInfo.id, - imeLabel = imeInfo.label, - ), - ) - - ConstraintId.IME_NOT_CHOSEN -> - returnResult.emit( - ConstraintData.ImeNotChosen( - imeId = imeInfo.id, - imeLabel = imeInfo.label, - ), - ) - - else -> Unit - } + returnResult.emit( + ConstraintData.ImeChosen( + imeId = imeInfo.id, + imeLabel = imeInfo.label, + ), + ) } - private suspend fun onSelectBluetoothConstraint(type: ConstraintId) { + private suspend fun onSelectBluetoothConstraint() { val response = showDialog( "bluetooth_device_constraint_limitation", DialogModel.Ok(getString(R.string.dialog_message_bt_constraint_limitation)), @@ -625,27 +569,25 @@ class ChooseConstraintViewModel @Inject constructor( NavDestination.ChooseBluetoothDevice, ) ?: return - val constraintData = when (type) { - ConstraintId.BT_DEVICE_CONNECTED -> ConstraintData.BtDeviceConnected( - bluetoothAddress = device.address, - deviceName = device.name, - ) - - ConstraintId.BT_DEVICE_DISCONNECTED -> ConstraintData.BtDeviceDisconnected( + returnResult.emit( + ConstraintData.BtDeviceConnected( bluetoothAddress = device.address, deviceName = device.name, - ) + ), + ) + } - else -> throw IllegalArgumentException( - "Don't know how to create $type constraint after choosing app", - ) - } + private suspend fun onSelectNotificationConstraint() { + val constraintData = navigate( + "config_notification_constraint", + NavDestination.ConfigNotificationConstraint, + ) ?: return returnResult.emit(constraintData) } private suspend fun onSelectAppConstraint(type: ConstraintId) { - val packageName = + val result = navigate( "choose_package_for_constraint", NavDestination.ChooseApp(allowHiddenApps = true), @@ -654,19 +596,13 @@ class ChooseConstraintViewModel @Inject constructor( val constraintData = when (type) { ConstraintId.APP_IN_FOREGROUND -> ConstraintData.AppInForeground( - packageName = packageName, - ) - - ConstraintId.APP_NOT_IN_FOREGROUND -> ConstraintData.AppNotInForeground( - packageName = packageName, + packageName = result.packageName, + appName = result.appName, ) ConstraintId.APP_PLAYING_MEDIA -> ConstraintData.AppPlayingMedia( - packageName = packageName, - ) - - ConstraintId.APP_NOT_PLAYING_MEDIA -> ConstraintData.AppNotPlayingMedia( - packageName = packageName, + packageName = result.packageName, + appName = result.appName, ) else -> throw IllegalArgumentException( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsUseCase.kt index fc2f113302..c687b32380 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsUseCase.kt @@ -4,6 +4,7 @@ import dagger.hilt.android.scopes.ViewModelScoped import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.common.utils.State +import io.github.sds100.keymapper.common.utils.dataOrNull import io.github.sds100.keymapper.data.Keys import io.github.sds100.keymapper.data.repositories.PreferenceRepository import java.util.LinkedList @@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray @ViewModelScoped class ConfigConstraintsUseCaseImpl @Inject constructor( @@ -35,66 +37,164 @@ class ConfigConstraintsUseCaseImpl @Inject constructor( // Do not include constraints that the key map already contains. shortcutData .filter { constraintData -> - !keyMap.data.constraintState.constraints.any { it.data == constraintData } + !keyMap.data.constraintState.allConstraints.any { it.data == constraintData } } .take(5) } - override fun addConstraint(constraintData: ConstraintData): Boolean { + override fun addConstraint( + groupUid: String?, + constraintData: ConstraintData, + ): ConstraintGroup? { var containsConstraint = false + var affectedGroup: ConstraintGroup? = null val newConstraint = Constraint(data = constraintData) - updateConstraintState { oldState -> - containsConstraint = oldState.constraints.any { it.data == constraintData } + val newKeyMap = updateConstraintState { oldState -> + val group = oldState.groups.find { it.uid == groupUid } + + if (group == null) { + val newGroup = ConstraintGroup(constraints = listOf(newConstraint)) + + // Use OR mode by default when there are multiple groups. I think this is the + // most likely use case because if you want multiple constraints to match with AND + // you will put them in the same group. + val mode = if (oldState.groups.size <= 1) { + ConstraintMode.OR + } else { + oldState.mode + } + + affectedGroup = newGroup + + return@updateConstraintState oldState.copy( + groups = oldState.groups.plus(newGroup), + mode = mode, + ) + } + + containsConstraint = + group.constraints.any { it.data == constraintData && !it.isNot } if (containsConstraint) { oldState } else { - oldState.copy(constraints = oldState.constraints.plus(newConstraint)) + oldState.updateGroup(group.uid) { group -> + affectedGroup = group + + group.copy(constraints = group.constraints.plus(newConstraint)) + } } } - preferenceRepository.update( - Keys.recentlyUsedConstraints, - { old -> - val oldDataList = getConstraintShortcuts(old) + if (newKeyMap == null) { + return null + } + + preferenceRepository.update(Keys.recentlyUsedConstraints) { old -> + val oldDataList = getConstraintShortcuts(old) + + val newDataList = LinkedList(oldDataList) + .apply { addFirst(constraintData) } + .distinct() + + Json.encodeToString(newDataList) + } + + return if (containsConstraint) { + null + } else { + affectedGroup + } + } - val newDataList = LinkedList(oldDataList) - .apply { addFirst(constraintData) } - .distinct() + override fun removeConstraint(uid: String) { + updateConstraintState { oldState -> + val groups = oldState.groups + .map { group -> + group.copy(constraints = group.constraints.filterNot { it.uid == uid }) + } + .filter { it.constraints.isNotEmpty() } - Json.encodeToString(newDataList) - }, - ) + oldState.copy(groups = groups) + } + } - return !containsConstraint + override fun removeGroup(groupUid: String) { + updateConstraintState { oldState -> + oldState.copy(groups = oldState.groups.filterNot { it.uid == groupUid }) + } } - override fun removeConstraint(id: String) { + override fun toggleNot(constraintUid: String) { updateConstraintState { oldState -> - val newList = oldState.constraints.toMutableSet().apply { - removeAll { it.uid == id } + val groups = oldState.groups.map { group -> + val constraints = group.constraints.map { constraint -> + if (constraint.uid == constraintUid) { + constraint.copy(isNot = !constraint.isNot) + } else { + constraint + } + } + + group.copy(constraints = constraints) } - oldState.copy(constraints = newList) + + oldState.copy(groups = groups) } } - override fun setAndMode() { + override fun setMode(mode: ConstraintMode) { updateConstraintState { oldState -> - oldState.copy(mode = ConstraintMode.AND) + oldState.copy(mode = mode) } } - override fun setOrMode() { + override fun setGroupMode(groupUid: String, mode: ConstraintMode) { updateConstraintState { oldState -> - oldState.copy(mode = ConstraintMode.OR) + oldState.updateGroup(groupUid) { it.copy(mode = mode) } } } - private fun updateConstraintState(block: (ConstraintState) -> ConstraintState) { - state.update { keyMap -> + override fun setGroupName(groupUid: String, name: String?) { + updateConstraintState { oldState -> + oldState.updateGroup(groupUid) { it.copy(name = name?.trim()?.ifBlank { null }) } + } + } + + override fun moveGroup(fromIndex: Int, toIndex: Int) { + updateConstraintState { oldState -> + oldState.copy(groups = oldState.groups.move(fromIndex, toIndex)) + } + } + + private fun updateConstraintState(block: (ConstraintState) -> ConstraintState): KeyMap? { + return state.update { keyMap -> keyMap.copy(constraintState = block(keyMap.constraintState)) + }.dataOrNull() + } + + private fun ConstraintState.updateGroup( + groupUid: String, + block: (ConstraintGroup) -> ConstraintGroup, + ): ConstraintState { + val groups = groups.map { group -> + if (group.uid == groupUid) { + block(group) + } else { + group + } } + + return copy(groups = groups) + } + + private fun List.move(fromIndex: Int, toIndex: Int): List { + if (fromIndex !in indices || toIndex !in indices) { + return this + } + + return toMutableList().apply { add(toIndex, removeAt(fromIndex)) } } private fun getConstraintShortcuts(json: String?): List { @@ -103,7 +203,17 @@ class ConfigConstraintsUseCaseImpl @Inject constructor( } try { - return Json.decodeFromString>(json).distinct() + // Decode each constraint separately so constraints that no longer exist are skipped + // without losing the other shortcuts. + return Json.parseToJsonElement(json).jsonArray + .mapNotNull { element -> + try { + Json.decodeFromJsonElement(ConstraintData.serializer(), element) + } catch (_: Exception) { + null + } + } + .distinct() } catch (_: Exception) { return emptyList() } @@ -114,8 +224,22 @@ interface ConfigConstraintsUseCase { val keyMap: StateFlow> val recentlyUsedConstraints: Flow> - fun addConstraint(constraintData: ConstraintData): Boolean - fun removeConstraint(id: String) - fun setAndMode() - fun setOrMode() + + /** + * @param groupUid the group to add the constraint to. A new group is created if this is null. + * @return the group the constraint was added to (a newly created group, or an existing + * group), or null if the group already contains the constraint (a duplicate). + */ + fun addConstraint(groupUid: String?, constraintData: ConstraintData): ConstraintGroup? + fun removeConstraint(uid: String) + fun removeGroup(groupUid: String) + fun toggleNot(constraintUid: String) + + /** + * Set the mode that combines the groups. + */ + fun setMode(mode: ConstraintMode) + fun setGroupMode(groupUid: String, mode: ConstraintMode) + fun setGroupName(groupUid: String, name: String?) + fun moveGroup(fromIndex: Int, toIndex: Int) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt index 46ed17f0dd..d0fa71f44f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigConstraintsViewModel.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.keymaps.ShortcutModel import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.isFixable @@ -15,7 +16,6 @@ import io.github.sds100.keymapper.base.utils.navigation.navigate import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.ViewModelHelper -import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.dataOrNull @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel @@ -70,40 +71,82 @@ class ConfigConstraintsViewModel @Inject constructor( var showDuplicateConstraintsSnackbar: Boolean by mutableStateOf(false) + private val expandedGroups: MutableStateFlow> = MutableStateFlow(emptySet()) + private val knownGroupUids: MutableSet = mutableSetOf() + init { + viewModelScope.launch { + config.keyMap + .map { state -> state.dataOrNull()?.constraintState?.groups } + .filterNotNull() + .collect { groups -> + val errorSnapshot = displayConstraint.constraintErrorSnapshot.first() + val newExpandedGroups = mutableSetOf() + + for (group in groups) { + // Only expand a group automatically if it is the first time it has + // been seen. + if (!knownGroupUids.contains(group.uid) && + group.constraints.any { errorSnapshot.getError(it) != null } + ) { + newExpandedGroups.add(group.uid) + } + } + + // Expand all the groups at once and not one-by-one + expandedGroups.update { set -> set.plus(newExpandedGroups) } + + knownGroupUids.addAll(groups.map { it.uid }) + } + } + combine( config.keyMap, shortcuts, constraintErrorSnapshot.filterNotNull(), - ) { keyMapState, shortcuts, errorSnapshot -> + expandedGroups, + ) { keyMapState, shortcuts, errorSnapshot, expandedGroups -> _state.value = keyMapState.mapData { keyMap -> - buildState(keyMap.constraintState, shortcuts, errorSnapshot) + buildState(keyMap.constraintState, shortcuts, errorSnapshot, expandedGroups) } }.launchIn(viewModelScope) } fun onClickShortcut(constraintData: ConstraintData) { viewModelScope.launch { - config.addConstraint(constraintData) + val group = config.addConstraint(groupUid = null, constraintData) + + if (group != null) { + expandedGroups.update { set -> set.plus(group.uid) } + } } } fun onRemoveClick(id: String) = config.removeConstraint(id) - fun onSelectMode(mode: ConstraintMode) { - when (mode) { - ConstraintMode.AND -> config.setAndMode() - ConstraintMode.OR -> config.setOrMode() - } + fun onRemoveGroupClick(groupUid: String) = config.removeGroup(groupUid) + + fun onNotClick(constraintUid: String) = config.toggleNot(constraintUid) + + fun onSelectMode(mode: ConstraintMode) = config.setMode(mode) + + fun onSelectGroupMode(groupUid: String, mode: ConstraintMode) { + config.setGroupMode(groupUid, mode) } + fun onRenameGroup(groupUid: String, name: String) { + config.setGroupName(groupUid, name) + } + + fun onMoveGroup(fromIndex: Int, toIndex: Int) = config.moveGroup(fromIndex, toIndex) + fun onFixError(constraintUid: String) { viewModelScope.launch { val constraint = config.keyMap .firstOrNull() ?.dataOrNull() ?.constraintState - ?.constraints + ?.allConstraints ?.find { it.uid == constraintUid } ?: return@launch @@ -133,20 +176,33 @@ class ConfigConstraintsViewModel @Inject constructor( } } - fun addConstraint() { + /** + * @param groupUid the group to add the constraint to. A new group is created if this is null. + */ + fun addConstraint(groupUid: String?) { viewModelScope.launch { - val constraint: ConstraintData = + val constraintData: ConstraintData = navigate("add_constraint", NavDestination.ChooseConstraint) ?: return@launch - val isDuplicate = !config.addConstraint(constraint) + val group = config.addConstraint(groupUid, constraintData) - if (isDuplicate) { + if (group == null) { showDuplicateConstraintsSnackbar = true + } else { + expandedGroups.update { set -> set.plus(group.uid) } } } } + fun onExpandedChange(groupUid: String, expanded: Boolean) { + if (expanded) { + expandedGroups.update { set -> set.plus(groupUid) } + } else { + expandedGroups.update { set -> set.minus(groupUid) } + } + } + private fun buildShortcutFromData( constraintData: ConstraintData, ): ShortcutModel { @@ -162,35 +218,48 @@ class ConfigConstraintsViewModel @Inject constructor( state: ConstraintState, shortcuts: Set>, errorSnapshot: ConstraintErrorSnapshot, + expandedGroups: Set, ): ConfigConstraintsState { - if (state.constraints.isEmpty()) { + if (state.allConstraints.isEmpty()) { return ConfigConstraintsState.Empty(shortcuts) } - val constraintList = state.constraints.mapIndexed { index, constraint -> - val title: String = uiHelper.getTitle(constraint) - val icon: ComposeIconInfo = uiHelper.getIcon(constraint) - val error: KMError? = errorSnapshot.getError(constraint) + val groups = state.groups + .filter { it.constraints.isNotEmpty() } + .map { group -> + val constraints = group.constraints.map { constraint -> + val error: KMError? = errorSnapshot.getError(constraint) - ConstraintListItemModel( - id = constraint.uid, - icon = icon, - constraintModeLink = if (state.constraints.size > 1 && - index < state.constraints.size - 1 - ) { - state.mode - } else { - null - }, - text = title, - error = error?.getFullMessage(this), - isErrorFixable = error?.isFixable ?: true, - ) - } + ConstraintListItemModel( + id = constraint.uid, + icon = uiHelper.getIcon(constraint), + text = uiHelper.getTitle(constraint), + isNot = constraint.isNot, + error = error?.getFullMessage(this), + isErrorFixable = error?.isFixable ?: true, + ) + } + + val modeWord = when (group.mode) { + ConstraintMode.AND -> getString(R.string.constraint_mode_and) + ConstraintMode.OR -> getString(R.string.constraint_mode_or) + } + + ConstraintGroupListItemModel( + uid = group.uid, + name = group.name, + mode = group.mode, + constraints = constraints, + description = group.constraints.joinToString(separator = " $modeWord ") { + uiHelper.getTitle(it) + }, + isExpanded = expandedGroups.contains(group.uid), + ) + } return ConfigConstraintsState.Loaded( - constraintList = constraintList, - selectedMode = state.mode, + groups = groups, + mode = state.mode, shortcuts = shortcuts, ) } @@ -201,8 +270,11 @@ sealed class ConfigConstraintsState { ConfigConstraintsState() data class Loaded( - val constraintList: List, - val selectedMode: ConstraintMode, + val groups: List, + /** + * The mode that combines the groups. + */ + val mode: ConstraintMode, val shortcuts: Set> = emptySet(), ) : ConfigConstraintsState() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintScreen.kt new file mode 100644 index 0000000000..a23ff63d16 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintScreen.kt @@ -0,0 +1,241 @@ +package io.github.sds100.keymapper.base.constraints + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfigNotificationConstraintScreen( + modifier: Modifier = Modifier, + viewModel: ConfigNotificationConstraintViewModel, +) { + ConfigNotificationConstraintScreen( + modifier = modifier, + field = viewModel.selectedField, + matchMode = viewModel.matchMode, + value = viewModel.value, + appName = viewModel.selectedAppName, + isValid = viewModel.isValid, + onSelectField = viewModel::onSelectField, + onSelectMatchMode = viewModel::onSelectMatchMode, + onValueChange = viewModel::onValueChange, + onChooseAppClick = viewModel::onChooseAppClick, + onDoneClick = viewModel::onDoneClick, + onNavigateBack = viewModel::onNavigateBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConfigNotificationConstraintScreen( + modifier: Modifier = Modifier, + field: NotificationField = NotificationField.TITLE, + matchMode: TextMatchMode = TextMatchMode.CONTAINS, + value: String = "", + appName: String? = null, + isValid: Boolean = false, + onSelectField: (NotificationField) -> Unit = {}, + onSelectMatchMode: (TextMatchMode) -> Unit = {}, + onValueChange: (String) -> Unit = {}, + onChooseAppClick: () -> Unit = {}, + onDoneClick: () -> Unit = {}, + onNavigateBack: () -> Unit = {}, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.constraint_notification_screen_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.action_go_back), + ) + } + }, + ) + }, + bottomBar = { + // Grows by the height of the keyboard so that the content above it shrinks and the + // focused text field is scrolled into view. + BottomAppBar( + modifier = Modifier.imePadding(), + actions = { + Spacer(modifier = Modifier.weight(1f)) + + OutlinedButton(onClick = onNavigateBack) { + Text(stringResource(R.string.neg_cancel)) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button(onClick = onDoneClick, enabled = isValid) { + Text(stringResource(R.string.pos_done)) + } + + Spacer(modifier = Modifier.width(8.dp)) + }, + ) + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + + Column( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = innerPadding.calculateStartPadding(layoutDirection), + end = innerPadding.calculateEndPadding(layoutDirection), + ) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SectionHeader(stringResource(R.string.constraint_notification_field_header)) + + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + buttonStates = NotificationField.entries.map { + it to stringResource(ConstraintUtils.getNotificationFieldLabel(it)) + }, + selectedState = field, + onStateSelected = onSelectField, + ) + + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.constraint_notification_sensitive_content_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + + if (field.isFreeText) { + SectionHeader( + stringResource(R.string.constraint_notification_match_mode_header), + ) + + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + buttonStates = TextMatchMode.entries.map { + it to stringResource(ConstraintUtils.getTextMatchModeLabel(it)) + }, + selectedState = matchMode, + onStateSelected = onSelectMatchMode, + ) + + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = value, + onValueChange = onValueChange, + label = { + val text = when (field) { + NotificationField.TEXT -> stringResource( + R.string.constraint_notification_value_hint_text, + ) + + else -> stringResource( + R.string.constraint_notification_value_hint_title, + ) + } + Text(text) + }, + singleLine = true, + ) + } else { + SectionHeader(stringResource(R.string.constraint_notification_value_header)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (appName != null) { + Text( + text = appName, + style = MaterialTheme.typography.bodyLarge, + ) + } + + Spacer(Modifier.weight(1f)) + + FilledTonalButton(onClick = onChooseAppClick) { + Text(stringResource(R.string.constraint_notification_choose_app)) + } + } + } + + Spacer(modifier = Modifier.width(8.dp)) + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) +} + +@Preview +@Composable +private fun TitlePreview() { + KeyMapperTheme { + ConfigNotificationConstraintScreen( + field = NotificationField.TITLE, + value = "New message", + isValid = true, + ) + } +} + +@Preview +@Composable +private fun AppPreview() { + KeyMapperTheme { + ConfigNotificationConstraintScreen( + field = NotificationField.PACKAGE, + value = "com.termux", + appName = "Termux", + isValid = true, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt new file mode 100644 index 0000000000..46fe472911 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConfigNotificationConstraintViewModel.kt @@ -0,0 +1,121 @@ +package io.github.sds100.keymapper.base.constraints + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.sds100.keymapper.base.utils.navigation.NavDestination +import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider +import io.github.sds100.keymapper.base.utils.navigation.navigate +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.utils.handle +import javax.inject.Inject +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +@HiltViewModel +class ConfigNotificationConstraintViewModel @Inject constructor( + private val displayConstraintUseCase: DisplayConstraintUseCase, + resourceProvider: ResourceProvider, + navigationProvider: NavigationProvider, +) : ViewModel(), + ResourceProvider by resourceProvider, + NavigationProvider by navigationProvider { + + var selectedField: NotificationField by mutableStateOf(NotificationField.TITLE) + private set + + var matchMode: TextMatchMode by mutableStateOf(TextMatchMode.CONTAINS) + private set + + var value: String by mutableStateOf("") + private set + + var isEditing: Boolean by mutableStateOf(false) + private set + + val isValid: Boolean + get() = value.isNotEmpty() + + /** + * The name of the chosen app, or null when no app has been chosen yet. + */ + val selectedAppName: String? + get() = if (selectedField == NotificationField.PACKAGE && value.isNotEmpty()) { + displayConstraintUseCase.getAppName(value).handle( + onSuccess = { it }, + onError = { value }, + ) + } else { + null + } + + fun onSelectField(field: NotificationField) { + selectedField = field + + // An app is picked from a list so it can only ever be matched exactly. Going back to a text + // field starts from the default rather than keeping whatever was used last. + matchMode = TextMatchMode.CONTAINS + + value = "" + } + + fun onSelectMatchMode(matchMode: TextMatchMode) { + this.matchMode = matchMode + } + + fun onValueChange(value: String) { + this.value = value + } + + fun onChooseAppClick() { + viewModelScope.launch { + val result = navigate( + "choose_app_for_notification_constraint", + NavDestination.ChooseApp(allowHiddenApps = true), + ) ?: return@launch + + value = result.packageName + } + } + + fun onDoneClick() { + val constraint = buildConstraintOrNull() ?: return + + viewModelScope.launch { + popBackStackWithResult(Json.encodeToString(constraint)) + } + } + + fun onNavigateBack() { + viewModelScope.launch { + popBackStack() + } + } + + private fun buildConstraintOrNull(): ConstraintData.NotificationPosted? { + if (value.isEmpty()) { + return null + } + + return when (selectedField) { + NotificationField.PACKAGE -> + ConstraintData.NotificationPosted.FromApp( + packageName = value, + appName = selectedAppName!!, + ) + + NotificationField.TITLE -> ConstraintData.NotificationPosted.Title( + text = value, + matchMode = matchMode, + ) + + NotificationField.TEXT -> ConstraintData.NotificationPosted.Text( + text = value, + matchMode = matchMode, + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt index f5d3c0ec57..23aa88983d 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/Constraint.kt @@ -1,12 +1,6 @@ package io.github.sds100.keymapper.base.constraints import io.github.sds100.keymapper.common.utils.Orientation -import io.github.sds100.keymapper.common.utils.PhysicalOrientation -import io.github.sds100.keymapper.common.utils.getKey -import io.github.sds100.keymapper.common.utils.valueOrNull -import io.github.sds100.keymapper.data.entities.ConstraintEntity -import io.github.sds100.keymapper.data.entities.EntityExtra -import io.github.sds100.keymapper.data.entities.getData import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.volume.RingerMode as SystemRingerMode import java.time.LocalTime @@ -18,57 +12,33 @@ sealed class ConstraintData { abstract val id: ConstraintId @Serializable - data class AppInForeground(val packageName: String) : ConstraintData() { + data class AppInForeground(val packageName: String, val appName: String? = null) : + ConstraintData() { override val id: ConstraintId = ConstraintId.APP_IN_FOREGROUND } @Serializable - data class AppNotInForeground(val packageName: String) : ConstraintData() { - override val id: ConstraintId = ConstraintId.APP_NOT_IN_FOREGROUND - } - - @Serializable - data class AppPlayingMedia(val packageName: String) : ConstraintData() { + data class AppPlayingMedia(val packageName: String, val appName: String? = null) : + ConstraintData() { override val id: ConstraintId = ConstraintId.APP_PLAYING_MEDIA } - @Serializable - data class AppNotPlayingMedia(val packageName: String) : ConstraintData() { - override val id: ConstraintId = ConstraintId.APP_NOT_PLAYING_MEDIA - } - @Serializable data object MediaPlaying : ConstraintData() { override val id: ConstraintId = ConstraintId.MEDIA_PLAYING } - @Serializable - data object NoMediaPlaying : ConstraintData() { - override val id: ConstraintId = ConstraintId.MEDIA_NOT_PLAYING - } - @Serializable data class BtDeviceConnected(val bluetoothAddress: String, val deviceName: String) : ConstraintData() { override val id: ConstraintId = ConstraintId.BT_DEVICE_CONNECTED } - @Serializable - data class BtDeviceDisconnected(val bluetoothAddress: String, val deviceName: String) : - ConstraintData() { - override val id: ConstraintId = ConstraintId.BT_DEVICE_DISCONNECTED - } - @Serializable data object ScreenOn : ConstraintData() { override val id: ConstraintId = ConstraintId.SCREEN_ON } - @Serializable - data object ScreenOff : ConstraintData() { - override val id: ConstraintId = ConstraintId.SCREEN_OFF - } - @Serializable data object OrientationPortrait : ConstraintData() { override val id: ConstraintId = ConstraintId.DISPLAY_ORIENTATION_PORTRAIT @@ -96,10 +66,13 @@ sealed class ConstraintData { override val id: ConstraintId = when (physicalOrientation) { io.github.sds100.keymapper.common.utils.PhysicalOrientation.PORTRAIT -> ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT + io.github.sds100.keymapper.common.utils.PhysicalOrientation.LANDSCAPE -> ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE + io.github.sds100.keymapper.common.utils.PhysicalOrientation.PORTRAIT_INVERTED -> ConstraintId.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED + io.github.sds100.keymapper.common.utils.PhysicalOrientation.LANDSCAPE_INVERTED -> ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED } @@ -115,71 +88,36 @@ sealed class ConstraintData { override val id: ConstraintId = ConstraintId.FLASHLIGHT_ON } - @Serializable - data class FlashlightOff(val lens: CameraLens) : ConstraintData() { - override val id: ConstraintId = ConstraintId.FLASHLIGHT_OFF - } - @Serializable data object WifiOn : ConstraintData() { override val id: ConstraintId = ConstraintId.WIFI_ON } - @Serializable - data object WifiOff : ConstraintData() { - override val id: ConstraintId = ConstraintId.WIFI_OFF - } - @Serializable data class WifiConnected(val ssid: String?) : ConstraintData() { override val id: ConstraintId = ConstraintId.WIFI_CONNECTED } - @Serializable - data class WifiDisconnected(val ssid: String?) : ConstraintData() { - override val id: ConstraintId = ConstraintId.WIFI_DISCONNECTED - } - @Serializable data class ImeChosen(val imeId: String, val imeLabel: String) : ConstraintData() { override val id: ConstraintId = ConstraintId.IME_CHOSEN } - @Serializable - data class ImeNotChosen(val imeId: String, val imeLabel: String) : ConstraintData() { - override val id: ConstraintId = ConstraintId.IME_NOT_CHOSEN - } - @Serializable data object KeyboardShowing : ConstraintData() { override val id: ConstraintId = ConstraintId.KEYBOARD_SHOWING } - @Serializable - data object KeyboardNotShowing : ConstraintData() { - override val id: ConstraintId = ConstraintId.KEYBOARD_NOT_SHOWING - } - @Serializable data object DeviceIsLocked : ConstraintData() { override val id: ConstraintId = ConstraintId.DEVICE_IS_LOCKED } - @Serializable - data object DeviceIsUnlocked : ConstraintData() { - override val id: ConstraintId = ConstraintId.DEVICE_IS_UNLOCKED - } - @Serializable data object LockScreenShowing : ConstraintData() { override val id: ConstraintId = ConstraintId.LOCK_SCREEN_SHOWING } - @Serializable - data object LockScreenNotShowing : ConstraintData() { - override val id: ConstraintId = ConstraintId.LOCK_SCREEN_NOT_SHOWING - } - @Serializable data object InPhoneCall : ConstraintData() { override val id: ConstraintId = ConstraintId.IN_PHONE_CALL @@ -209,11 +147,6 @@ sealed class ConstraintData { override val id: ConstraintId = ConstraintId.CHARGING } - @Serializable - data object Discharging : ConstraintData() { - override val id: ConstraintId = ConstraintId.DISCHARGING - } - @Serializable data object HingeClosed : ConstraintData() { override val id: ConstraintId = ConstraintId.HINGE_CLOSED @@ -230,8 +163,30 @@ sealed class ConstraintData { } @Serializable - data object NotificationPanelNotShowing : ConstraintData() { - override val id: ConstraintId = ConstraintId.NOTIFICATION_PANEL_NOT_SHOWING + sealed class NotificationPosted : ConstraintData() { + override val id: ConstraintId get() = ConstraintId.NOTIFICATION_POSTED + + /** + * A variant that compares free text the user typed, so it can be matched loosely. Matching + * is always case insensitive + */ + @Serializable + sealed class TextMatch : NotificationPosted() { + abstract val text: String + abstract val matchMode: TextMatchMode + } + + @Serializable + data class Title(override val text: String, override val matchMode: TextMatchMode) : + TextMatch() + + @Serializable + data class Text(override val text: String, override val matchMode: TextMatchMode) : + TextMatch() + + @Serializable + data class FromApp(val packageName: String, val appName: String? = null) : + NotificationPosted() } @Serializable @@ -249,546 +204,13 @@ sealed class ConstraintData { } @Serializable -data class Constraint(val uid: String = UUID.randomUUID().toString(), val data: ConstraintData) { +data class Constraint( + val uid: String = UUID.randomUUID().toString(), + val data: ConstraintData, + /** + * Whether the constraint is inverted so it is satisfied when the [data] is not. + */ + val isNot: Boolean = false, +) { val id: ConstraintId get() = data.id } - -object ConstraintModeEntityMapper { - fun fromEntity(entity: Int): ConstraintMode = when (entity) { - ConstraintEntity.MODE_AND -> ConstraintMode.AND - ConstraintEntity.MODE_OR -> ConstraintMode.OR - else -> throw Exception("don't know how to convert constraint mode entity $entity") - } - - fun toEntity(constraintMode: ConstraintMode): Int = when (constraintMode) { - ConstraintMode.AND -> ConstraintEntity.MODE_AND - ConstraintMode.OR -> ConstraintEntity.MODE_OR - } -} - -object ConstraintEntityMapper { - - private val LENS_MAP = mapOf( - CameraLens.BACK to "option_lens_back", - CameraLens.FRONT to "option_lens_front", - ) - - fun fromEntity(entity: ConstraintEntity): Constraint { - fun getPackageName(): String = - entity.extras.getData(ConstraintEntity.EXTRA_PACKAGE_NAME).valueOrNull()!! - - fun getBluetoothAddress(): String = - entity.extras.getData(ConstraintEntity.EXTRA_BT_ADDRESS).valueOrNull()!! - - fun getBluetoothDeviceName(): String = - entity.extras.getData(ConstraintEntity.EXTRA_BT_NAME).valueOrNull()!! - - fun getCameraLens(): CameraLens { - val extraValue = - entity.extras.getData(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS).valueOrNull()!! - return LENS_MAP.getKey(extraValue)!! - } - - fun getSsid(): String? { - val extraValue = - entity.extras.getData(ConstraintEntity.EXTRA_SSID).valueOrNull() - return extraValue - } - - fun getImeId(): String { - val extraValue = - entity.extras.getData(ConstraintEntity.EXTRA_IME_ID).valueOrNull()!! - return extraValue - } - - fun getImeLabel(): String { - val extraValue = - entity.extras.getData(ConstraintEntity.EXTRA_IME_LABEL).valueOrNull()!! - return extraValue - } - - fun getResolutionWidth(): Int = - entity.extras.getData(ConstraintEntity.EXTRA_RESOLUTION_WIDTH).valueOrNull()!!.toInt() - - fun getResolutionHeight(): Int = - entity.extras.getData(ConstraintEntity.EXTRA_RESOLUTION_HEIGHT).valueOrNull()!!.toInt() - - val constraintData = when (entity.type) { - ConstraintEntity.APP_FOREGROUND -> ConstraintData.AppInForeground( - getPackageName(), - ) - - ConstraintEntity.APP_NOT_FOREGROUND -> ConstraintData.AppNotInForeground( - getPackageName(), - ) - - ConstraintEntity.APP_PLAYING_MEDIA -> ConstraintData.AppPlayingMedia( - getPackageName(), - ) - - ConstraintEntity.APP_NOT_PLAYING_MEDIA -> ConstraintData.AppNotPlayingMedia( - getPackageName(), - ) - - ConstraintEntity.MEDIA_PLAYING -> ConstraintData.MediaPlaying - ConstraintEntity.NO_MEDIA_PLAYING -> ConstraintData.NoMediaPlaying - - ConstraintEntity.BT_DEVICE_CONNECTED -> - ConstraintData.BtDeviceConnected( - getBluetoothAddress(), - getBluetoothDeviceName(), - ) - - ConstraintEntity.BT_DEVICE_DISCONNECTED -> - ConstraintData.BtDeviceDisconnected( - getBluetoothAddress(), - getBluetoothDeviceName(), - ) - - ConstraintEntity.ORIENTATION_0 -> ConstraintData.OrientationCustom( - Orientation.ORIENTATION_0, - ) - - ConstraintEntity.ORIENTATION_90 -> ConstraintData.OrientationCustom( - Orientation.ORIENTATION_90, - ) - - ConstraintEntity.ORIENTATION_180 -> ConstraintData.OrientationCustom( - Orientation.ORIENTATION_180, - ) - - ConstraintEntity.ORIENTATION_270 -> ConstraintData.OrientationCustom( - Orientation.ORIENTATION_270, - ) - - ConstraintEntity.ORIENTATION_PORTRAIT -> ConstraintData.OrientationPortrait - ConstraintEntity.ORIENTATION_LANDSCAPE -> ConstraintData.OrientationLandscape - - ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT -> - ConstraintData.PhysicalOrientation(PhysicalOrientation.PORTRAIT) - ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE -> - ConstraintData.PhysicalOrientation(PhysicalOrientation.LANDSCAPE) - ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED -> - ConstraintData.PhysicalOrientation(PhysicalOrientation.PORTRAIT_INVERTED) - ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED -> - ConstraintData.PhysicalOrientation(PhysicalOrientation.LANDSCAPE_INVERTED) - - ConstraintEntity.DISPLAY_RESOLUTION -> ConstraintData.DisplayResolution( - width = getResolutionWidth(), - height = getResolutionHeight(), - ) - - ConstraintEntity.SCREEN_OFF -> ConstraintData.ScreenOff - ConstraintEntity.SCREEN_ON -> ConstraintData.ScreenOn - - ConstraintEntity.FLASHLIGHT_ON -> ConstraintData.FlashlightOn( - getCameraLens(), - ) - - ConstraintEntity.FLASHLIGHT_OFF -> ConstraintData.FlashlightOff( - getCameraLens(), - ) - - ConstraintEntity.WIFI_ON -> ConstraintData.WifiOn - ConstraintEntity.WIFI_OFF -> ConstraintData.WifiOff - ConstraintEntity.WIFI_CONNECTED -> ConstraintData.WifiConnected(getSsid()) - ConstraintEntity.WIFI_DISCONNECTED -> ConstraintData.WifiDisconnected( - getSsid(), - ) - - ConstraintEntity.IME_CHOSEN -> ConstraintData.ImeChosen( - getImeId(), - getImeLabel(), - ) - - ConstraintEntity.IME_NOT_CHOSEN -> ConstraintData.ImeNotChosen( - getImeId(), - getImeLabel(), - ) - - ConstraintEntity.KEYBOARD_SHOWING -> ConstraintData.KeyboardShowing - ConstraintEntity.KEYBOARD_NOT_SHOWING -> ConstraintData.KeyboardNotShowing - - ConstraintEntity.DEVICE_IS_UNLOCKED -> ConstraintData.DeviceIsUnlocked - ConstraintEntity.DEVICE_IS_LOCKED -> ConstraintData.DeviceIsLocked - ConstraintEntity.LOCK_SCREEN_SHOWING -> ConstraintData.LockScreenShowing - ConstraintEntity.LOCK_SCREEN_NOT_SHOWING -> ConstraintData.LockScreenNotShowing - - ConstraintEntity.PHONE_RINGING -> ConstraintData.PhoneRinging - ConstraintEntity.IN_PHONE_CALL -> ConstraintData.InPhoneCall - ConstraintEntity.NOT_IN_PHONE_CALL -> ConstraintData.NotInPhoneCall - - ConstraintEntity.RINGER_MODE_NORMAL -> - ConstraintData.RingerMode(SystemRingerMode.NORMAL) - ConstraintEntity.RINGER_MODE_VIBRATE -> - ConstraintData.RingerMode(SystemRingerMode.VIBRATE) - ConstraintEntity.RINGER_MODE_SILENT -> - ConstraintData.RingerMode(SystemRingerMode.SILENT) - - ConstraintEntity.CHARGING -> ConstraintData.Charging - ConstraintEntity.DISCHARGING -> ConstraintData.Discharging - - ConstraintEntity.HINGE_CLOSED -> ConstraintData.HingeClosed - ConstraintEntity.HINGE_OPEN -> ConstraintData.HingeOpen - - ConstraintEntity.NOTIFICATION_PANEL_SHOWING -> ConstraintData.NotificationPanelShowing - ConstraintEntity.NOTIFICATION_PANEL_NOT_SHOWING -> - ConstraintData.NotificationPanelNotShowing - - ConstraintEntity.TIME -> { - val startTime = - entity.extras.getData(ConstraintEntity.EXTRA_START_TIME).valueOrNull()!! - .split(":") - val startHour = startTime[0].toInt() - val startMin = startTime[1].toInt() - - val endTime = - entity.extras.getData(ConstraintEntity.EXTRA_END_TIME).valueOrNull()!! - .split(":") - val endHour = endTime[0].toInt() - val endMin = endTime[1].toInt() - - ConstraintData.Time( - startHour = startHour, - startMinute = startMin, - endHour = endHour, - endMinute = endMin, - ) - } - - else -> throw Exception( - "don't know how to convert constraint entity with type ${entity.type}", - ) - } - - return Constraint( - uid = entity.uid, - data = constraintData, - ) - } - - fun toEntity(constraint: Constraint): ConstraintEntity = when (constraint.data) { - is ConstraintData.AppInForeground -> ConstraintEntity( - uid = constraint.uid, - type = ConstraintEntity.APP_FOREGROUND, - extras = listOf( - EntityExtra( - ConstraintEntity.EXTRA_PACKAGE_NAME, - constraint.data.packageName, - ), - ), - ) - - is ConstraintData.AppNotInForeground -> ConstraintEntity( - uid = constraint.uid, - type = ConstraintEntity.APP_NOT_FOREGROUND, - extras = listOf( - EntityExtra( - ConstraintEntity.EXTRA_PACKAGE_NAME, - constraint.data.packageName, - ), - ), - ) - - is ConstraintData.AppPlayingMedia -> ConstraintEntity( - uid = constraint.uid, - type = ConstraintEntity.APP_PLAYING_MEDIA, - extras = listOf( - EntityExtra( - ConstraintEntity.EXTRA_PACKAGE_NAME, - constraint.data.packageName, - ), - ), - ) - - is ConstraintData.AppNotPlayingMedia -> ConstraintEntity( - uid = constraint.uid, - type = ConstraintEntity.APP_NOT_PLAYING_MEDIA, - extras = listOf( - EntityExtra( - ConstraintEntity.EXTRA_PACKAGE_NAME, - constraint.data.packageName, - ), - ), - ) - - is ConstraintData.MediaPlaying -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.MEDIA_PLAYING, - ) - - is ConstraintData.NoMediaPlaying -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.NO_MEDIA_PLAYING, - ) - - is ConstraintData.BtDeviceConnected -> ConstraintEntity( - uid = constraint.uid, - type = ConstraintEntity.BT_DEVICE_CONNECTED, - extras = listOf( - EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, constraint.data.bluetoothAddress), - EntityExtra(ConstraintEntity.EXTRA_BT_NAME, constraint.data.deviceName), - ), - ) - - is ConstraintData.BtDeviceDisconnected -> ConstraintEntity( - uid = constraint.uid, - type = ConstraintEntity.BT_DEVICE_DISCONNECTED, - extras = listOf( - EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, constraint.data.bluetoothAddress), - EntityExtra(ConstraintEntity.EXTRA_BT_NAME, constraint.data.deviceName), - ), - ) - - is ConstraintData.OrientationCustom -> when (constraint.data.orientation) { - Orientation.ORIENTATION_0 -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.ORIENTATION_0, - ) - - Orientation.ORIENTATION_90 -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.ORIENTATION_90, - ) - - Orientation.ORIENTATION_180 -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.ORIENTATION_180, - ) - - Orientation.ORIENTATION_270 -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.ORIENTATION_270, - ) - } - - is ConstraintData.OrientationLandscape -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.ORIENTATION_LANDSCAPE, - ) - - is ConstraintData.OrientationPortrait -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.ORIENTATION_PORTRAIT, - ) - - is ConstraintData.PhysicalOrientation -> when (constraint.data.physicalOrientation) { - PhysicalOrientation.PORTRAIT -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT, - ) - PhysicalOrientation.LANDSCAPE -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE, - ) - PhysicalOrientation.PORTRAIT_INVERTED -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED, - ) - PhysicalOrientation.LANDSCAPE_INVERTED -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED, - ) - } - - is ConstraintData.DisplayResolution -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.DISPLAY_RESOLUTION, - EntityExtra( - ConstraintEntity.EXTRA_RESOLUTION_WIDTH, - constraint.data.width.toString(), - ), - EntityExtra( - ConstraintEntity.EXTRA_RESOLUTION_HEIGHT, - constraint.data.height.toString(), - ), - ) - - is ConstraintData.ScreenOff -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.SCREEN_OFF, - ) - - is ConstraintData.ScreenOn -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.SCREEN_ON, - ) - - is ConstraintData.FlashlightOff -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.FLASHLIGHT_OFF, - EntityExtra( - ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, - LENS_MAP[constraint.data.lens]!!, - ), - ) - - is ConstraintData.FlashlightOn -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.FLASHLIGHT_ON, - EntityExtra( - ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, - LENS_MAP[constraint.data.lens]!!, - ), - ) - - is ConstraintData.WifiConnected -> { - val extras = mutableListOf() - - if (constraint.data.ssid != null) { - extras.add(EntityExtra(ConstraintEntity.EXTRA_SSID, constraint.data.ssid)) - } - - ConstraintEntity( - uid = constraint.uid, - type = ConstraintEntity.WIFI_CONNECTED, - extras = extras, - ) - } - - is ConstraintData.WifiDisconnected -> { - val extras = mutableListOf() - - if (constraint.data.ssid != null) { - extras.add(EntityExtra(ConstraintEntity.EXTRA_SSID, constraint.data.ssid)) - } - - ConstraintEntity( - uid = constraint.uid, - type = ConstraintEntity.WIFI_DISCONNECTED, - extras = extras, - ) - } - - is ConstraintData.WifiOff -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.WIFI_OFF, - ) - - is ConstraintData.WifiOn -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.WIFI_ON, - ) - - is ConstraintData.ImeChosen -> { - ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.IME_CHOSEN, - EntityExtra(ConstraintEntity.EXTRA_IME_ID, constraint.data.imeId), - EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, constraint.data.imeLabel), - ) - } - - is ConstraintData.ImeNotChosen -> { - ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.IME_NOT_CHOSEN, - EntityExtra(ConstraintEntity.EXTRA_IME_ID, constraint.data.imeId), - EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, constraint.data.imeLabel), - ) - } - - is ConstraintData.KeyboardShowing -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.KEYBOARD_SHOWING, - ) - - is ConstraintData.KeyboardNotShowing -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.KEYBOARD_NOT_SHOWING, - ) - - is ConstraintData.DeviceIsLocked -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.DEVICE_IS_LOCKED, - ) - - is ConstraintData.DeviceIsUnlocked -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.DEVICE_IS_UNLOCKED, - ) - - is ConstraintData.LockScreenShowing -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.LOCK_SCREEN_SHOWING, - ) - - is ConstraintData.LockScreenNotShowing -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.LOCK_SCREEN_NOT_SHOWING, - ) - - is ConstraintData.InPhoneCall -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.IN_PHONE_CALL, - ) - - is ConstraintData.NotInPhoneCall -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.NOT_IN_PHONE_CALL, - ) - - is ConstraintData.PhoneRinging -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.PHONE_RINGING, - ) - - is ConstraintData.RingerMode -> when (constraint.data.ringerMode) { - SystemRingerMode.NORMAL -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.RINGER_MODE_NORMAL, - ) - SystemRingerMode.VIBRATE -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.RINGER_MODE_VIBRATE, - ) - SystemRingerMode.SILENT -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.RINGER_MODE_SILENT, - ) - } - - is ConstraintData.Charging -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.CHARGING, - ) - - is ConstraintData.Discharging -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.DISCHARGING, - ) - - is ConstraintData.HingeClosed -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.HINGE_CLOSED, - ) - - is ConstraintData.HingeOpen -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.HINGE_OPEN, - ) - - is ConstraintData.NotificationPanelShowing -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.NOTIFICATION_PANEL_SHOWING, - ) - - is ConstraintData.NotificationPanelNotShowing -> ConstraintEntity( - uid = constraint.uid, - ConstraintEntity.NOTIFICATION_PANEL_NOT_SHOWING, - ) - - is ConstraintData.Time -> ConstraintEntity( - uid = constraint.uid, - type = ConstraintEntity.TIME, - EntityExtra( - ConstraintEntity.EXTRA_START_TIME, - "${constraint.data.startHour}:${constraint.data.startMinute}", - ), - EntityExtra( - ConstraintEntity.EXTRA_END_TIME, - "${constraint.data.endHour}:${constraint.data.endMinute}", - ), - ) - } -} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintCategory.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintCategory.kt index e4937d1529..a41bbb6f88 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintCategory.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintCategory.kt @@ -12,5 +12,6 @@ enum class ConstraintCategory { PHONE, POWER, DEVICE, + NOTIFICATIONS, TIME, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt index ac326d39b4..bf2e7a88f8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintDependency.kt @@ -20,5 +20,6 @@ enum class ConstraintDependency { CHARGING_STATE, HINGE_STATE, NOTIFICATION_PANEL_STATE, + POSTED_NOTIFICATIONS, DISPLAY_RESOLUTIONS, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt new file mode 100644 index 0000000000..857de3fd2e --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapper.kt @@ -0,0 +1,550 @@ +package io.github.sds100.keymapper.base.constraints + +import io.github.sds100.keymapper.common.utils.Orientation +import io.github.sds100.keymapper.common.utils.PhysicalOrientation +import io.github.sds100.keymapper.common.utils.getKey +import io.github.sds100.keymapper.common.utils.valueOrNull +import io.github.sds100.keymapper.data.entities.ConstraintEntity +import io.github.sds100.keymapper.data.entities.EntityExtra +import io.github.sds100.keymapper.data.entities.getData +import io.github.sds100.keymapper.system.camera.CameraLens +import io.github.sds100.keymapper.system.volume.RingerMode + +object ConstraintEntityMapper { + + private val LENS_MAP = mapOf( + CameraLens.BACK to "option_lens_back", + CameraLens.FRONT to "option_lens_front", + ) + + /** + * These entity types were replaced by the positive constraint with the NOT extra. + */ + @Suppress("DEPRECATION") + private val NOT_ENTITY_TYPES = setOf( + ConstraintEntity.APP_NOT_FOREGROUND, + ConstraintEntity.APP_NOT_PLAYING_MEDIA, + ConstraintEntity.NO_MEDIA_PLAYING, + ConstraintEntity.BT_DEVICE_DISCONNECTED, + ConstraintEntity.SCREEN_OFF, + ConstraintEntity.FLASHLIGHT_OFF, + ConstraintEntity.WIFI_OFF, + ConstraintEntity.WIFI_DISCONNECTED, + ConstraintEntity.IME_NOT_CHOSEN, + ConstraintEntity.KEYBOARD_NOT_SHOWING, + ConstraintEntity.DEVICE_IS_UNLOCKED, + ConstraintEntity.LOCK_SCREEN_NOT_SHOWING, + ConstraintEntity.DISCHARGING, + ConstraintEntity.NOTIFICATION_PANEL_NOT_SHOWING, + ) + + @Suppress("DEPRECATION") + fun fromEntity(entity: ConstraintEntity): Constraint { + fun getPackageName(): String = + entity.extras.getData(ConstraintEntity.EXTRA_PACKAGE_NAME).valueOrNull()!! + + fun getAppName(): String? = + entity.extras.getData(ConstraintEntity.EXTRA_APP_NAME).valueOrNull() + + fun getBluetoothAddress(): String = + entity.extras.getData(ConstraintEntity.EXTRA_BT_ADDRESS).valueOrNull()!! + + fun getBluetoothDeviceName(): String = + entity.extras.getData(ConstraintEntity.EXTRA_BT_NAME).valueOrNull()!! + + fun getCameraLens(): CameraLens { + val extraValue = + entity.extras.getData(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS).valueOrNull()!! + return LENS_MAP.getKey(extraValue)!! + } + + fun getSsid(): String? { + val extraValue = + entity.extras.getData(ConstraintEntity.EXTRA_SSID).valueOrNull() + return extraValue + } + + fun getImeId(): String { + val extraValue = + entity.extras.getData(ConstraintEntity.EXTRA_IME_ID).valueOrNull()!! + return extraValue + } + + fun getImeLabel(): String { + val extraValue = + entity.extras.getData(ConstraintEntity.EXTRA_IME_LABEL).valueOrNull()!! + return extraValue + } + + fun getResolutionWidth(): Int = + entity.extras.getData(ConstraintEntity.EXTRA_RESOLUTION_WIDTH).valueOrNull()!!.toInt() + + fun getResolutionHeight(): Int = + entity.extras.getData(ConstraintEntity.EXTRA_RESOLUTION_HEIGHT).valueOrNull()!!.toInt() + + val constraintData = when (entity.type) { + ConstraintEntity.APP_FOREGROUND, + ConstraintEntity.APP_NOT_FOREGROUND, + -> ConstraintData.AppInForeground(getPackageName(), getAppName()) + + ConstraintEntity.APP_PLAYING_MEDIA, + ConstraintEntity.APP_NOT_PLAYING_MEDIA, + -> ConstraintData.AppPlayingMedia(getPackageName(), getAppName()) + + ConstraintEntity.MEDIA_PLAYING, + ConstraintEntity.NO_MEDIA_PLAYING, + -> ConstraintData.MediaPlaying + + ConstraintEntity.BT_DEVICE_CONNECTED, + ConstraintEntity.BT_DEVICE_DISCONNECTED, + -> ConstraintData.BtDeviceConnected( + getBluetoothAddress(), + getBluetoothDeviceName(), + ) + + ConstraintEntity.ORIENTATION_0 -> ConstraintData.OrientationCustom( + Orientation.ORIENTATION_0, + ) + + ConstraintEntity.ORIENTATION_90 -> ConstraintData.OrientationCustom( + Orientation.ORIENTATION_90, + ) + + ConstraintEntity.ORIENTATION_180 -> ConstraintData.OrientationCustom( + Orientation.ORIENTATION_180, + ) + + ConstraintEntity.ORIENTATION_270 -> ConstraintData.OrientationCustom( + Orientation.ORIENTATION_270, + ) + + ConstraintEntity.ORIENTATION_PORTRAIT -> ConstraintData.OrientationPortrait + + ConstraintEntity.ORIENTATION_LANDSCAPE -> ConstraintData.OrientationLandscape + + ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT -> + ConstraintData.PhysicalOrientation(PhysicalOrientation.PORTRAIT) + + ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE -> + ConstraintData.PhysicalOrientation(PhysicalOrientation.LANDSCAPE) + + ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED -> + ConstraintData.PhysicalOrientation(PhysicalOrientation.PORTRAIT_INVERTED) + + ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED -> + ConstraintData.PhysicalOrientation(PhysicalOrientation.LANDSCAPE_INVERTED) + + ConstraintEntity.DISPLAY_RESOLUTION -> ConstraintData.DisplayResolution( + width = getResolutionWidth(), + height = getResolutionHeight(), + ) + + ConstraintEntity.SCREEN_ON, + ConstraintEntity.SCREEN_OFF, + -> ConstraintData.ScreenOn + + ConstraintEntity.FLASHLIGHT_ON, + ConstraintEntity.FLASHLIGHT_OFF, + -> ConstraintData.FlashlightOn(getCameraLens()) + + ConstraintEntity.WIFI_ON, + ConstraintEntity.WIFI_OFF, + -> ConstraintData.WifiOn + + ConstraintEntity.WIFI_CONNECTED, + ConstraintEntity.WIFI_DISCONNECTED, + -> ConstraintData.WifiConnected(getSsid()) + + ConstraintEntity.IME_CHOSEN, + ConstraintEntity.IME_NOT_CHOSEN, + -> ConstraintData.ImeChosen( + getImeId(), + getImeLabel(), + ) + + ConstraintEntity.KEYBOARD_SHOWING, + ConstraintEntity.KEYBOARD_NOT_SHOWING, + -> ConstraintData.KeyboardShowing + + ConstraintEntity.DEVICE_IS_LOCKED, + ConstraintEntity.DEVICE_IS_UNLOCKED, + -> ConstraintData.DeviceIsLocked + + ConstraintEntity.LOCK_SCREEN_SHOWING, + ConstraintEntity.LOCK_SCREEN_NOT_SHOWING, + -> ConstraintData.LockScreenShowing + + ConstraintEntity.PHONE_RINGING -> ConstraintData.PhoneRinging + + ConstraintEntity.IN_PHONE_CALL -> ConstraintData.InPhoneCall + + ConstraintEntity.NOT_IN_PHONE_CALL -> ConstraintData.NotInPhoneCall + + ConstraintEntity.RINGER_MODE_NORMAL -> + ConstraintData.RingerMode(RingerMode.NORMAL) + + ConstraintEntity.RINGER_MODE_VIBRATE -> + ConstraintData.RingerMode(RingerMode.VIBRATE) + + ConstraintEntity.RINGER_MODE_SILENT -> + ConstraintData.RingerMode(RingerMode.SILENT) + + ConstraintEntity.CHARGING, + ConstraintEntity.DISCHARGING, + -> ConstraintData.Charging + + ConstraintEntity.HINGE_CLOSED -> ConstraintData.HingeClosed + + ConstraintEntity.HINGE_OPEN -> ConstraintData.HingeOpen + + ConstraintEntity.NOTIFICATION_PANEL_SHOWING, + ConstraintEntity.NOTIFICATION_PANEL_NOT_SHOWING, + -> ConstraintData.NotificationPanelShowing + + ConstraintEntity.NOTIFICATION_POSTED -> { + val value = entity.extras + .getData(ConstraintEntity.EXTRA_NOTIFICATION_VALUE).valueOrNull()!! + + when ( + NotificationField.valueOf( + entity.extras + .getData(ConstraintEntity.EXTRA_NOTIFICATION_FIELD).valueOrNull()!!, + ) + ) { + NotificationField.PACKAGE -> + ConstraintData.NotificationPosted.FromApp( + packageName = value, + appName = getAppName(), + ) + + NotificationField.TITLE -> ConstraintData.NotificationPosted.Title( + text = value, + matchMode = entity.getNotificationMatchMode(), + ) + + NotificationField.TEXT -> ConstraintData.NotificationPosted.Text( + text = value, + matchMode = entity.getNotificationMatchMode(), + ) + } + } + + ConstraintEntity.TIME -> { + val startTime = + entity.extras.getData(ConstraintEntity.EXTRA_START_TIME).valueOrNull()!! + .split(":") + val startHour = startTime[0].toInt() + val startMin = startTime[1].toInt() + + val endTime = + entity.extras.getData(ConstraintEntity.EXTRA_END_TIME).valueOrNull()!! + .split(":") + val endHour = endTime[0].toInt() + val endMin = endTime[1].toInt() + + ConstraintData.Time( + startHour = startHour, + startMinute = startMin, + endHour = endHour, + endMinute = endMin, + ) + } + + else -> throw Exception( + "don't know how to convert constraint entity with type ${entity.type}", + ) + } + + val isNotType = entity.type in NOT_ENTITY_TYPES + + return Constraint( + uid = entity.uid, + data = constraintData, + isNot = isNotType != entity.isNot, + ) + } + + fun toEntity(constraint: Constraint): ConstraintEntity = + toEntityWithoutNot(constraint).copy(isNot = constraint.isNot) + + private fun toEntityWithoutNot(constraint: Constraint): ConstraintEntity = + when (constraint.data) { + is ConstraintData.AppInForeground -> ConstraintEntity( + uid = constraint.uid, + type = ConstraintEntity.APP_FOREGROUND, + extras = listOfNotNull( + EntityExtra( + ConstraintEntity.EXTRA_PACKAGE_NAME, + constraint.data.packageName, + ), + constraint.data.appName?.let { + EntityExtra(ConstraintEntity.EXTRA_APP_NAME, it) + }, + ), + ) + + is ConstraintData.AppPlayingMedia -> ConstraintEntity( + uid = constraint.uid, + type = ConstraintEntity.APP_PLAYING_MEDIA, + extras = listOfNotNull( + EntityExtra( + ConstraintEntity.EXTRA_PACKAGE_NAME, + constraint.data.packageName, + ), + constraint.data.appName?.let { + EntityExtra(ConstraintEntity.EXTRA_APP_NAME, it) + }, + ), + ) + + is ConstraintData.MediaPlaying -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.MEDIA_PLAYING, + ) + + is ConstraintData.BtDeviceConnected -> ConstraintEntity( + uid = constraint.uid, + type = ConstraintEntity.BT_DEVICE_CONNECTED, + extras = listOf( + EntityExtra( + ConstraintEntity.EXTRA_BT_ADDRESS, + constraint.data.bluetoothAddress, + ), + EntityExtra(ConstraintEntity.EXTRA_BT_NAME, constraint.data.deviceName), + ), + ) + + is ConstraintData.OrientationCustom -> when (constraint.data.orientation) { + Orientation.ORIENTATION_0 -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.ORIENTATION_0, + ) + + Orientation.ORIENTATION_90 -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.ORIENTATION_90, + ) + + Orientation.ORIENTATION_180 -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.ORIENTATION_180, + ) + + Orientation.ORIENTATION_270 -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.ORIENTATION_270, + ) + } + + is ConstraintData.OrientationLandscape -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.ORIENTATION_LANDSCAPE, + ) + + is ConstraintData.OrientationPortrait -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.ORIENTATION_PORTRAIT, + ) + + is ConstraintData.PhysicalOrientation -> when (constraint.data.physicalOrientation) { + PhysicalOrientation.PORTRAIT -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT, + ) + + PhysicalOrientation.LANDSCAPE -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE, + ) + + PhysicalOrientation.PORTRAIT_INVERTED -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.PHYSICAL_ORIENTATION_PORTRAIT_INVERTED, + ) + + PhysicalOrientation.LANDSCAPE_INVERTED -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED, + ) + } + + is ConstraintData.DisplayResolution -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.DISPLAY_RESOLUTION, + EntityExtra( + ConstraintEntity.EXTRA_RESOLUTION_WIDTH, + constraint.data.width.toString(), + ), + EntityExtra( + ConstraintEntity.EXTRA_RESOLUTION_HEIGHT, + constraint.data.height.toString(), + ), + ) + + is ConstraintData.ScreenOn -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.SCREEN_ON, + ) + + is ConstraintData.FlashlightOn -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.FLASHLIGHT_ON, + EntityExtra( + ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, + LENS_MAP[constraint.data.lens]!!, + ), + ) + + is ConstraintData.WifiConnected -> { + val extras = mutableListOf() + + if (constraint.data.ssid != null) { + extras.add(EntityExtra(ConstraintEntity.EXTRA_SSID, constraint.data.ssid)) + } + + ConstraintEntity( + uid = constraint.uid, + type = ConstraintEntity.WIFI_CONNECTED, + extras = extras, + ) + } + + is ConstraintData.WifiOn -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.WIFI_ON, + ) + + is ConstraintData.ImeChosen -> { + ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.IME_CHOSEN, + EntityExtra(ConstraintEntity.EXTRA_IME_ID, constraint.data.imeId), + EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, constraint.data.imeLabel), + ) + } + + is ConstraintData.KeyboardShowing -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.KEYBOARD_SHOWING, + ) + + is ConstraintData.DeviceIsLocked -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.DEVICE_IS_LOCKED, + ) + + is ConstraintData.LockScreenShowing -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.LOCK_SCREEN_SHOWING, + ) + + is ConstraintData.InPhoneCall -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.IN_PHONE_CALL, + ) + + is ConstraintData.NotInPhoneCall -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.NOT_IN_PHONE_CALL, + ) + + is ConstraintData.PhoneRinging -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.PHONE_RINGING, + ) + + is ConstraintData.RingerMode -> when (constraint.data.ringerMode) { + RingerMode.NORMAL -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.RINGER_MODE_NORMAL, + ) + + RingerMode.VIBRATE -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.RINGER_MODE_VIBRATE, + ) + + RingerMode.SILENT -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.RINGER_MODE_SILENT, + ) + } + + is ConstraintData.Charging -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.CHARGING, + ) + + is ConstraintData.HingeClosed -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.HINGE_CLOSED, + ) + + is ConstraintData.HingeOpen -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.HINGE_OPEN, + ) + + is ConstraintData.NotificationPanelShowing -> ConstraintEntity( + uid = constraint.uid, + ConstraintEntity.NOTIFICATION_PANEL_SHOWING, + ) + + is ConstraintData.NotificationPosted -> { + val value = when (constraint.data) { + is ConstraintData.NotificationPosted.FromApp -> constraint.data.packageName + is ConstraintData.NotificationPosted.TextMatch -> constraint.data.text + } + + val textMatch = constraint.data as? ConstraintData.NotificationPosted.TextMatch + + ConstraintEntity( + uid = constraint.uid, + type = ConstraintEntity.NOTIFICATION_POSTED, + extras = buildList { + add( + EntityExtra( + ConstraintEntity.EXTRA_NOTIFICATION_FIELD, + NotificationField.of(constraint.data).name, + ), + ) + add(EntityExtra(ConstraintEntity.EXTRA_NOTIFICATION_VALUE, value)) + + // Only free text constraints have a match mode, so writing one for an app + // would store an option that can never be edited. + if (textMatch != null) { + add( + EntityExtra( + ConstraintEntity.EXTRA_NOTIFICATION_MATCH_MODE, + textMatch.matchMode.name, + ), + ) + } + + if (constraint.data is ConstraintData.NotificationPosted.FromApp) { + constraint.data.appName?.let { + add(EntityExtra(ConstraintEntity.EXTRA_APP_NAME, it)) + } + } + }, + ) + } + + is ConstraintData.Time -> ConstraintEntity( + uid = constraint.uid, + type = ConstraintEntity.TIME, + EntityExtra( + ConstraintEntity.EXTRA_START_TIME, + "${constraint.data.startHour}:${constraint.data.startMinute}", + ), + EntityExtra( + ConstraintEntity.EXTRA_END_TIME, + "${constraint.data.endHour}:${constraint.data.endMinute}", + ), + ) + } + + private fun ConstraintEntity.getNotificationMatchMode(): TextMatchMode = TextMatchMode.valueOf( + extras.getData(ConstraintEntity.EXTRA_NOTIFICATION_MATCH_MODE).valueOrNull()!!, + ) +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt index 67eecff0af..3e6b70f7a3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshot.kt @@ -1,7 +1,6 @@ package io.github.sds100.keymapper.base.constraints import android.content.pm.PackageManager -import android.os.Build import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.onSuccess import io.github.sds100.keymapper.system.SystemError @@ -37,34 +36,32 @@ class LazyConstraintErrorSnapshot( override fun getError(constraint: Constraint): KMError? { when (constraint.data) { - is ConstraintData.AppInForeground -> return getAppError(constraint.data.packageName) - is ConstraintData.AppNotInForeground -> return getAppError(constraint.data.packageName) + is ConstraintData.AppInForeground -> + return getAppError( + constraint.data.packageName, + constraint.data.appName, + constraint.isNot, + ) is ConstraintData.AppPlayingMedia -> { if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) { return SystemError.PermissionDenied(Permission.NOTIFICATION_LISTENER) } - return getAppError(constraint.data.packageName) + return getAppError( + constraint.data.packageName, + constraint.data.appName, + constraint.isNot, + ) } - is ConstraintData.AppNotPlayingMedia -> { + ConstraintData.MediaPlaying -> { if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) { return SystemError.PermissionDenied(Permission.NOTIFICATION_LISTENER) } - - return getAppError(constraint.data.packageName) } - ConstraintData.MediaPlaying, ConstraintData.NoMediaPlaying -> { - if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) { - return SystemError.PermissionDenied(Permission.NOTIFICATION_LISTENER) - } - } - - is ConstraintData.BtDeviceConnected, - is ConstraintData.BtDeviceDisconnected, - -> { + is ConstraintData.BtDeviceConnected -> { if (!systemFeatureAdapter.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)) { return KMError.SystemFeatureNotSupported(PackageManager.FEATURE_BLUETOOTH) } @@ -83,23 +80,6 @@ class LazyConstraintErrorSnapshot( } is ConstraintData.FlashlightOn -> { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) - } - - if (!flashLenses.contains(constraint.data.lens)) { - return when (constraint.data.lens) { - CameraLens.FRONT -> KMError.FrontFlashNotFound - CameraLens.BACK -> KMError.BackFlashNotFound - } - } - } - - is ConstraintData.FlashlightOff -> { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return KMError.SdkVersionTooLow(minSdk = Build.VERSION_CODES.M) - } - if (!flashLenses.contains(constraint.data.lens)) { return when (constraint.data.lens) { CameraLens.FRONT -> KMError.FrontFlashNotFound @@ -108,7 +88,7 @@ class LazyConstraintErrorSnapshot( } } - is ConstraintData.WifiConnected, is ConstraintData.WifiDisconnected -> { + is ConstraintData.WifiConnected -> { if (!isPermissionGranted(Permission.ACCESS_FINE_LOCATION)) { return SystemError.PermissionDenied(Permission.ACCESS_FINE_LOCATION) } @@ -129,21 +109,42 @@ class LazyConstraintErrorSnapshot( } } + is ConstraintData.NotificationPosted -> { + if (!isPermissionGranted(Permission.NOTIFICATION_LISTENER)) { + return SystemError.PermissionDenied(Permission.NOTIFICATION_LISTENER) + } + + if (constraint.data is ConstraintData.NotificationPosted.FromApp) { + return getAppError( + constraint.data.packageName, + constraint.data.appName, + constraint.isNot, + ) + } + } + else -> Unit } return null } - private fun getAppError(packageName: String): KMError? { + private fun getAppError(packageName: String, appName: String?, isNot: Boolean): KMError? { + // If the constraint is negated then a missing or disabled app can never satisfy the + // un-negated condition, so the negated condition is trivially true. There is nothing + // to warn about. + if (isNot) { + return null + } + packageManager.isAppEnabled(packageName).onSuccess { isEnabled -> if (!isEnabled) { - return KMError.AppDisabled(packageName) + return KMError.AppDisabled(packageName, appName) } } if (!packageManager.isAppInstalled(packageName)) { - return KMError.AppNotFound(packageName) + return KMError.AppNotFound(packageName, appName) } return null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroup.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroup.kt new file mode 100644 index 0000000000..74122e5951 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroup.kt @@ -0,0 +1,15 @@ +package io.github.sds100.keymapper.base.constraints + +import java.util.UUID +import kotlinx.serialization.Serializable + +/** + * @param mode how the constraints in this group are combined. + */ +@Serializable +data class ConstraintGroup( + val uid: String = UUID.randomUUID().toString(), + val name: String? = null, + val constraints: List = emptyList(), + val mode: ConstraintMode = ConstraintMode.AND, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt new file mode 100644 index 0000000000..2c72ecc45a --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintGroupItem.kt @@ -0,0 +1,474 @@ +package io.github.sds100.keymapper.base.constraints + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandIn +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.FlashlightOn +import androidx.compose.material.icons.outlined.Wifi +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.utils.ui.compose.DragDropState +import io.github.sds100.keymapper.base.utils.ui.compose.EXPAND_ANIMATION_DURATION +import io.github.sds100.keymapper.base.utils.ui.compose.ExpandableDraggableCard +import io.github.sds100.keymapper.base.utils.ui.compose.horizontalFadingEdges + +/** + * The description and error fade out in the first half of the animation so they are invisible + * before the shrink clips them. When expanding, the space is made first and the text fades in + * during the second half. + */ +private val summaryEnterTransition: EnterTransition = + expandVertically( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top, + ) + fadeIn( + tween( + durationMillis = EXPAND_ANIMATION_DURATION / 2, + delayMillis = EXPAND_ANIMATION_DURATION / 2, + easing = FastOutSlowInEasing, + ), + ) + +private val summaryExitTransition: ExitTransition = + fadeOut(tween(EXPAND_ANIMATION_DURATION / 2, easing = FastOutSlowInEasing)) + + shrinkVertically( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.Top, + ) + +/** + * Animate both width and height so the title row does not jump when the rename button appears. + */ +private val renameButtonEnterTransition: EnterTransition = + fadeIn(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) + + expandIn( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + expandFrom = Alignment.CenterStart, + ) + +private val renameButtonExitTransition: ExitTransition = + fadeOut(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) + + shrinkOut( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.CenterStart, + ) + +@Composable +fun ConstraintGroupItem( + modifier: Modifier = Modifier, + model: ConstraintGroupListItemModel, + isExpanded: Boolean, + isDraggingEnabled: Boolean = false, + isDragging: Boolean = false, + isReorderingEnabled: Boolean = false, + dragDropState: DragDropState? = null, + onExpandedChange: (Boolean) -> Unit = {}, + onSelectMode: (ConstraintMode) -> Unit = {}, + onAddConstraintClick: () -> Unit = {}, + onRenameClick: () -> Unit = {}, + onDeleteClick: () -> Unit = {}, + onRemoveConstraintClick: (String) -> Unit = {}, + onFixConstraintClick: (String) -> Unit = {}, + onNotClick: (String) -> Unit = {}, + onMoveUp: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, +) { + val countBasedTitle = when { + model.constraints.size == 1 -> stringResource(R.string.constraint_group_title_single) + + model.mode == ConstraintMode.AND -> pluralStringResource( + R.plurals.constraint_group_title_and, + model.constraints.size, + model.constraints.size, + ) + + else -> pluralStringResource( + R.plurals.constraint_group_title_or, + model.constraints.size, + model.constraints.size, + ) + } + + val title = model.name ?: countBasedTitle + + ExpandableDraggableCard( + modifier = modifier, + key = model.uid, + isExpanded = isExpanded, + isDragging = isDragging, + isReorderingEnabled = isReorderingEnabled, + isDraggingEnabled = isDraggingEnabled, + dragDropState = dragDropState, + dragHandleContentDescription = stringResource(R.string.drag_handle_for, title), + expandContentDescription = stringResource(R.string.constraint_group_expand), + collapseContentDescription = stringResource(R.string.constraint_group_collapse), + onExpandedChange = onExpandedChange, + onMoveUp = onMoveUp, + onMoveDown = onMoveDown, + headerContent = { expanded -> + HeaderText( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp), + title = if (isExpanded) { + model.name ?: countBasedTitle + } else { + model.name + ?: model.description + }, + model = model, + isExpanded = expanded, + onRenameClick = onRenameClick, + ) + }, + expandedContent = { + ExpandedContent( + modifier = Modifier.padding( + start = 16.dp, + end = 8.dp, + bottom = 8.dp, + top = 8.dp, + ), + model = model, + onSelectMode = onSelectMode, + onAddConstraintClick = onAddConstraintClick, + onDeleteClick = onDeleteClick, + onRemoveConstraintClick = onRemoveConstraintClick, + onFixConstraintClick = onFixConstraintClick, + onNotClick = onNotClick, + ) + }, + ) +} + +@Composable +private fun HeaderText( + modifier: Modifier = Modifier, + title: String, + model: ConstraintGroupListItemModel, + isExpanded: Boolean, + onRenameClick: () -> Unit, +) { + Column(modifier = modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = Modifier + .weight(1f, fill = false).animateContentSize(), + text = title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + AnimatedVisibility( + visible = isExpanded, + enter = renameButtonEnterTransition, + exit = renameButtonExitTransition, + ) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + IconButton(onClick = onRenameClick) { + Icon( + modifier = Modifier.size(20.dp), + imageVector = Icons.Rounded.Edit, + contentDescription = stringResource(R.string.action_list_item_rename), + ) + } + } + } + } + + AnimatedVisibility( + visible = !isExpanded, + enter = summaryEnterTransition, + exit = summaryExitTransition, + ) { + if (model.error != null) { + Text( + text = model.error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +@Composable +private fun ExpandedContent( + modifier: Modifier = Modifier, + model: ConstraintGroupListItemModel, + onSelectMode: (ConstraintMode) -> Unit, + onAddConstraintClick: () -> Unit, + onDeleteClick: () -> Unit, + onRemoveConstraintClick: (String) -> Unit, + onFixConstraintClick: (String) -> Unit, + onNotClick: (String) -> Unit, +) { + Column(modifier) { + Text( + modifier = Modifier.padding(end = 8.dp), + text = when { + model.constraints.size == 1 -> + stringResource(R.string.constraint_group_explanation_single) + + model.mode == ConstraintMode.AND -> + stringResource(R.string.constraint_group_explanation_and) + + else -> stringResource(R.string.constraint_group_explanation_or) + }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + + Spacer(Modifier.size(8.dp)) + + GroupConstraintList( + modifier = Modifier.padding(end = 8.dp), + constraints = model.constraints, + mode = model.mode, + onRemoveConstraintClick = onRemoveConstraintClick, + onFixConstraintClick = onFixConstraintClick, + onNotClick = onNotClick, + ) + + Spacer(Modifier.size(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + ConstraintModeButtons( + modifier = Modifier.width(160.dp), + mode = model.mode, + onSelectMode = onSelectMode, + isEnabled = model.constraints.size > 1, + ) + + Spacer(Modifier.weight(1f)) + + val scrollState = rememberScrollState() + + // Scroll to the end initially so the delete button is always visible without + // needing to scroll. + LaunchedEffect(Unit) { + scrollState.scrollTo(scrollState.maxValue) + } + + Row( + modifier = Modifier + .weight(1f) + .horizontalFadingEdges(scrollState) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.End, + ) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + IconButton(onClick = onAddConstraintClick) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource( + R.string.constraint_group_add_constraint, + ), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(R.string.constraint_group_delete), + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } +} + +@Composable +private fun GroupConstraintList( + modifier: Modifier = Modifier, + constraints: List, + mode: ConstraintMode, + onRemoveConstraintClick: (String) -> Unit, + onFixConstraintClick: (String) -> Unit, + onNotClick: (String) -> Unit, +) { + val linkText = when (mode) { + ConstraintMode.AND -> stringResource(R.string.constraint_mode_and) + ConstraintMode.OR -> stringResource(R.string.constraint_mode_or) + } + + Column(modifier = modifier) { + constraints.forEachIndexed { index, constraint -> + key(constraint.id) { + ConstraintListItem( + modifier = Modifier.fillMaxWidth(), + model = constraint, + onRemoveClick = { onRemoveConstraintClick(constraint.id) }, + onFixClick = { onFixConstraintClick(constraint.id) }, + onNotClick = { onNotClick(constraint.id) }, + ) + + if (index < constraints.lastIndex) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + text = linkText, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} + +private val previewConstraints = listOf( + ConstraintListItemModel( + id = "1", + icon = ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn), + text = "Flashlight is on", + isNot = true, + error = "Flashlight not found", + isErrorFixable = true, + ), + ConstraintListItemModel( + id = "2", + icon = ComposeIconInfo.Vector(Icons.Outlined.Wifi), + text = "Wi-Fi is on", + ), +) + +@PreviewLightDark +@Preview(widthDp = 300) +@Composable +private fun AndExpandedPreview() { + KeyMapperTheme { + ConstraintGroupItem( + model = ConstraintGroupListItemModel( + uid = "group", + mode = ConstraintMode.AND, + constraints = previewConstraints, + description = "Flashlight is not on AND Wi-Fi is on", + ), + isExpanded = true, + isReorderingEnabled = true, + ) + } +} + +@Preview +@Composable +private fun OrExpandedPreview() { + KeyMapperTheme { + ConstraintGroupItem( + model = ConstraintGroupListItemModel( + uid = "group", + mode = ConstraintMode.OR, + constraints = previewConstraints.map { it.copy(error = null, isNot = false) }, + description = "Flashlight is on OR Wi-Fi is on", + ), + isExpanded = true, + ) + } +} + +@Preview +@Composable +private fun SingleExpandedPreview() { + KeyMapperTheme { + ConstraintGroupItem( + model = ConstraintGroupListItemModel( + uid = "group", + mode = ConstraintMode.AND, + constraints = listOf(previewConstraints[1]), + description = "Wi-Fi is on", + ), + isExpanded = true, + ) + } +} + +@PreviewLightDark +@Preview(widthDp = 300) +@Composable +private fun CollapsedPreview() { + KeyMapperTheme { + ConstraintGroupItem( + model = ConstraintGroupListItemModel( + uid = "group", + mode = ConstraintMode.AND, + constraints = previewConstraints, + description = "Flashlight is not on AND Wi-Fi is on", + ), + isExpanded = false, + isReorderingEnabled = true, + ) + } +} + +@Preview +@Composable +private fun CollapsedErrorPreview() { + KeyMapperTheme { + ConstraintGroupItem( + model = ConstraintGroupListItemModel( + uid = "group", + mode = ConstraintMode.AND, + constraints = previewConstraints, + description = "Flashlight is not on AND Wi-Fi is on", + ), + isExpanded = false, + isReorderingEnabled = true, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt index 944eed883d..d3b2014c7a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintId.kt @@ -7,17 +7,12 @@ import kotlinx.serialization.Serializable @Serializable enum class ConstraintId { APP_IN_FOREGROUND, - APP_NOT_IN_FOREGROUND, APP_PLAYING_MEDIA, - APP_NOT_PLAYING_MEDIA, - MEDIA_NOT_PLAYING, MEDIA_PLAYING, BT_DEVICE_CONNECTED, - BT_DEVICE_DISCONNECTED, SCREEN_ON, - SCREEN_OFF, DISPLAY_ORIENTATION_PORTRAIT, DISPLAY_ORIENTATION_LANDSCAPE, @@ -34,23 +29,16 @@ enum class ConstraintId { DISPLAY_RESOLUTION, FLASHLIGHT_ON, - FLASHLIGHT_OFF, WIFI_ON, - WIFI_OFF, WIFI_CONNECTED, - WIFI_DISCONNECTED, IME_CHOSEN, - IME_NOT_CHOSEN, KEYBOARD_SHOWING, - KEYBOARD_NOT_SHOWING, DEVICE_IS_LOCKED, - DEVICE_IS_UNLOCKED, LOCK_SCREEN_SHOWING, - LOCK_SCREEN_NOT_SHOWING, IN_PHONE_CALL, NOT_IN_PHONE_CALL, @@ -61,13 +49,12 @@ enum class ConstraintId { RINGER_MODE_SILENT, CHARGING, - DISCHARGING, HINGE_CLOSED, HINGE_OPEN, NOTIFICATION_PANEL_SHOWING, - NOTIFICATION_PANEL_NOT_SHOWING, + NOTIFICATION_POSTED, TIME, } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt index ab1115f82b..0af2d36e3e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItem.kt @@ -1,25 +1,26 @@ package io.github.sds100.keymapper.base.constraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ClearAll +import androidx.compose.material.icons.outlined.FlashlightOn import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.Error import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -30,11 +31,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.google.accompanist.drawablepainter.rememberDrawablePainter import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.CompactErrorButton +import io.github.sds100.keymapper.base.utils.ui.compose.CompactFilledTonalButton +import io.github.sds100.keymapper.base.utils.ui.compose.CompactOutlinedButton import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo import io.github.sds100.keymapper.base.utils.ui.drawable @@ -42,168 +52,262 @@ import io.github.sds100.keymapper.base.utils.ui.drawable fun ConstraintListItem( modifier: Modifier = Modifier, model: ConstraintListItemModel, + isDragging: Boolean = false, onRemoveClick: () -> Unit = {}, onFixClick: () -> Unit = {}, + onNotClick: () -> Unit = {}, ) { - Column(modifier = modifier.fillMaxWidth()) { - ElevatedCard( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .height(IntrinsicSize.Min) - .padding(start = 16.dp, end = 16.dp), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Row( - modifier = Modifier.fillMaxSize(), - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer(Modifier.width(8.dp)) - - if (model.error == null) { - when (model.icon) { - is ComposeIconInfo.Vector -> Icon( - modifier = Modifier.size(24.dp), - imageVector = model.icon.imageVector, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - ) - - is ComposeIconInfo.Drawable -> { - val painter = rememberDrawablePainter(model.icon.drawable) - Icon( - modifier = Modifier.size(24.dp), - painter = painter, - contentDescription = null, - tint = Color.Unspecified, - ) - } - } - } - - val primaryText = model.text - - Spacer(Modifier.width(8.dp)) + ElevatedCard( + modifier = modifier, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + elevation = if (isDragging) { + CardDefaults.elevatedCardElevation(defaultElevation = 6.dp) + } else { + CardDefaults.elevatedCardElevation() + }, + ) { + val listItemModifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(vertical = 4.dp) - TextColumn( - modifier = Modifier - .weight(1f) - .padding(vertical = 8.dp), - primaryText = primaryText, - errorText = model.error, - ) + BoxWithConstraints { + when { + maxWidth < 400.dp -> { + ConstraintListItemMedium( + modifier = listItemModifier, + model = model, + onFixClick = onFixClick, + onNotClick = onNotClick, + onRemoveClick = onRemoveClick, + ) + } - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, - ) { - if (model.error != null && model.isErrorFixable) { - FilledTonalButton( - modifier = Modifier.padding(start = 8.dp, end = 8.dp), - onClick = onFixClick, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Text( - text = stringResource(R.string.button_fix), - ) - } - } - - IconButton(onClick = onRemoveClick) { - Icon( - imageVector = Icons.Rounded.Clear, - contentDescription = stringResource( - R.string.constraint_list_item_remove, - ), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(24.dp), - ) - } + else -> { + ConstraintListItemLarge( + modifier = listItemModifier, + model = model, + onFixClick = onFixClick, + onNotClick = onNotClick, + onRemoveClick = onRemoveClick, + ) } } } + } +} - if (model.constraintModeLink == null) { - // Important! Show an empty spacer so the height of the card remains constant - // while dragging. If the height changes while dragging it can lead to janky - // behavior. - Spacer(Modifier.height(32.dp)) - } else { - Spacer(Modifier.height(4.dp)) +@Composable +private fun ConstraintListItemLarge( + modifier: Modifier = Modifier, + model: ConstraintListItemModel, + onFixClick: () -> Unit, + onNotClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(8.dp)) - val text = when (model.constraintModeLink) { - ConstraintMode.AND -> stringResource(R.string.constraint_mode_and) - ConstraintMode.OR -> stringResource(R.string.constraint_mode_or) + ConstraintIcon(icon = model.icon) + + Spacer(Modifier.width(8.dp)) + + Text( + modifier = Modifier.weight(1f), + text = model.text, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + if (model.error != null && model.isErrorFixable) { + CompactErrorButton(onClick = onFixClick) { + Text(stringResource(R.string.button_fix)) } + } - Text( - modifier = Modifier.align(Alignment.CenterHorizontally), - text = text, - ) + Spacer(Modifier.width(8.dp)) + + NotToggle(isNot = model.isNot, onClick = onNotClick) - Spacer(Modifier.height(4.dp)) + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + IconButton(onClick = onRemoveClick) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.Rounded.Clear, + contentDescription = stringResource(R.string.constraint_list_item_remove), + ) + } } } } @Composable -private fun TextColumn( +private fun ConstraintListItemMedium( modifier: Modifier = Modifier, - primaryText: String, - errorText: String? = null, + model: ConstraintListItemModel, + onFixClick: () -> Unit, + onNotClick: () -> Unit, + onRemoveClick: () -> Unit, ) { - Column( + Row( modifier = modifier, + verticalAlignment = Alignment.CenterVertically, ) { + Spacer(Modifier.width(8.dp)) + + ConstraintIcon(icon = model.icon) + + Spacer(Modifier.width(8.dp)) + Text( - text = primaryText, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + text = model.text, + style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, ) - if (errorText != null) { - Text( - text = errorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, + + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + if (model.error != null && model.isErrorFixable) { + IconButton( + modifier = Modifier.size(24.dp), + onClick = onFixClick, + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon( + imageVector = Icons.Rounded.Error, + contentDescription = stringResource(R.string.button_fix), + ) + } + } + + Spacer(Modifier.width(8.dp)) + + NotToggle(isNot = model.isNot, onClick = onNotClick) + + IconButton(onClick = onRemoveClick) { + Icon( + modifier = Modifier.size(24.dp), + imageVector = Icons.Rounded.Clear, + contentDescription = stringResource(R.string.constraint_list_item_remove), + ) + } + } + } +} + +@Composable +private fun NotToggle(modifier: Modifier = Modifier, isNot: Boolean, onClick: () -> Unit) { + val contentDescription = stringResource(R.string.constraint_list_item_not) + + val modifier = modifier + .toggleable(value = isNot, role = Role.Checkbox, onValueChange = { onClick() }) + .semantics { this.contentDescription = contentDescription } + + if (isNot) { + CompactFilledTonalButton( + modifier = modifier, + onClick = onClick, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.inversePrimary, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Text(text = stringResource(R.string.constraint_not), fontWeight = FontWeight.Black) + } + } else { + CompactOutlinedButton( + modifier = modifier, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.38f), + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.inversePrimary), + onClick = onClick, + ) { + Text(text = stringResource(R.string.constraint_not)) + } + } +} + +@Composable +private fun ConstraintIcon(modifier: Modifier = Modifier, icon: ComposeIconInfo) { + when (icon) { + is ComposeIconInfo.Vector -> Icon( + modifier = modifier.size(24.dp), + imageVector = icon.imageVector, + contentDescription = null, + ) + + is ComposeIconInfo.Drawable -> { + val painter = rememberDrawablePainter(icon.drawable) + Icon( + modifier = modifier.size(24.dp), + painter = painter, + contentDescription = null, + tint = Color.Unspecified, ) } } } -@Preview +@PreviewLightDark +@Preview(widthDp = 200) @Composable private fun VectorPreview() { - ConstraintListItem( - model = ConstraintListItemModel( - id = "id", - icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), - constraintModeLink = ConstraintMode.AND, - text = "Clear all", - error = null, - isErrorFixable = true, - ), - ) + KeyMapperTheme { + ConstraintListItem( + model = ConstraintListItemModel( + id = "id", + icon = ComposeIconInfo.Vector(Icons.Outlined.ClearAll), + text = "Clear all", + ), + ) + } } -@Preview +@PreviewLightDark +@Preview(widthDp = 200) +@Composable +private fun NotErrorPreview() { + KeyMapperTheme { + ConstraintListItem( + model = ConstraintListItemModel( + id = "id", + icon = ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn), + text = "Flashlight is on", + isNot = true, + error = "Permission need to control camera", + isErrorFixable = true, + ), + ) + } +} + +@PreviewLightDark @Composable private fun DrawablePreview() { val drawable = LocalContext.current.drawable(R.mipmap.ic_launcher_round) - ConstraintListItem( - model = ConstraintListItemModel( - id = "id", - text = "Dismiss most recent notification", - error = null, - isErrorFixable = true, - icon = ComposeIconInfo.Drawable(drawable), - constraintModeLink = ConstraintMode.OR, - ), - ) + KeyMapperTheme { + ConstraintListItem( + model = ConstraintListItemModel( + id = "id", + text = "Key Mapper is in foreground", + icon = ComposeIconInfo.Drawable(drawable), + ), + ) + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItemModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItemModel.kt index 39f2ca17a2..0285a23587 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItemModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintListItemModel.kt @@ -5,11 +5,19 @@ import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo data class ConstraintListItemModel( val id: String, val icon: ComposeIconInfo, - /** - * Null if no link should be shown between the items. - */ - val constraintModeLink: ConstraintMode?, val text: String, + val isNot: Boolean = false, val error: String? = null, val isErrorFixable: Boolean = true, ) + +data class ConstraintGroupListItemModel( + val uid: String, + val name: String? = null, + val mode: ConstraintMode, + val constraints: List, + val description: String, + val isExpanded: Boolean = false, +) { + val error: String? = constraints.firstNotNullOfOrNull { it.error } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeButtons.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeButtons.kt new file mode 100644 index 0000000000..821f4898d3 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeButtons.kt @@ -0,0 +1,74 @@ +package io.github.sds100.keymapper.base.constraints + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.SegmentedButtonColors +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow + +/** + * The AND/OR switcher that is used for the constraints in a group and for combining the groups. + */ +@Composable +fun ConstraintModeButtons( + mode: ConstraintMode, + onSelectMode: (ConstraintMode) -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, + colors: SegmentedButtonColors = SegmentedButtonDefaults.colors(), +) { + KeyMapperSegmentedButtonRow( + modifier = modifier, + buttonStates = listOf( + ConstraintMode.AND to stringResource(R.string.constraint_mode_and), + ConstraintMode.OR to stringResource(R.string.constraint_mode_or), + ), + selectedState = mode, + onStateSelected = onSelectMode, + isCompact = true, + isEnabled = isEnabled, + colors = colors, + ) +} + +@PreviewLightDark +@Composable +private fun Preview() { + KeyMapperTheme { + Surface { + ConstraintModeButtons( + modifier = Modifier + .padding(16.dp) + .width(160.dp), + mode = ConstraintMode.AND, + onSelectMode = {}, + ) + } + } +} + +@Preview +@Composable +private fun DisabledPreview() { + KeyMapperTheme { + Surface { + ConstraintModeButtons( + modifier = Modifier + .padding(16.dp) + .width(160.dp), + mode = ConstraintMode.OR, + onSelectMode = {}, + isEnabled = false, + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeEntityMapper.kt new file mode 100644 index 0000000000..19ec539f35 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintModeEntityMapper.kt @@ -0,0 +1,16 @@ +package io.github.sds100.keymapper.base.constraints + +import io.github.sds100.keymapper.data.entities.ConstraintEntity + +object ConstraintModeEntityMapper { + fun fromEntity(entity: Int): ConstraintMode = when (entity) { + ConstraintEntity.MODE_AND -> ConstraintMode.AND + ConstraintEntity.MODE_OR -> ConstraintMode.OR + else -> throw Exception("don't know how to convert constraint mode entity $entity") + } + + fun toEntity(constraintMode: ConstraintMode): Int = when (constraintMode) { + ConstraintMode.AND -> ConstraintEntity.MODE_AND + ConstraintMode.OR -> ConstraintEntity.MODE_OR + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt index 0cc28dc291..1fd23b1d7c 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshot.kt @@ -19,6 +19,8 @@ import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.lock.LockScreenAdapter import io.github.sds100.keymapper.system.media.MediaAdapter import io.github.sds100.keymapper.system.network.NetworkAdapter +import io.github.sds100.keymapper.system.notifications.NotificationAdapter +import io.github.sds100.keymapper.system.notifications.PostedNotification import io.github.sds100.keymapper.system.phone.CallState import io.github.sds100.keymapper.system.phone.PhoneAdapter import io.github.sds100.keymapper.system.power.PowerAdapter @@ -42,6 +44,7 @@ class LazyConstraintSnapshot( powerAdapter: PowerAdapter, private val foldableAdapter: FoldableAdapter, volumeAdapter: VolumeAdapter, + notificationAdapter: NotificationAdapter, ) : ConstraintSnapshot { private val appInForeground: String? by lazy { accessibilityService.rootNode?.packageName } private val connectedBluetoothDevices: Set by lazy { @@ -69,7 +72,7 @@ class LazyConstraintSnapshot( private val isKeyboardShowing: Boolean by lazy { accessibilityService.isInputMethodVisible.firstBlocking() } - private val callState: CallState by lazy { phoneAdapter.getCallState() } + private val callState: CallState? by lazy { phoneAdapter.getCallState() } private val isCharging: Boolean by lazy { powerAdapter.isCharging.value } private val ringerMode: RingerMode by lazy { volumeAdapter.ringerMode } @@ -85,6 +88,10 @@ class LazyConstraintSnapshot( accessibilityService.isNotificationShadeExpanded.firstBlocking() } + private val activeNotifications: List by lazy { + notificationAdapter.activeNotifications.value + } + private val localTime = LocalTime.now() private fun isMediaPlaying(): Boolean { @@ -95,34 +102,19 @@ class LazyConstraintSnapshot( override fun isSatisfied(constraint: Constraint): Boolean { val isSatisfied = when (constraint.data) { is ConstraintData.AppInForeground -> appInForeground == constraint.data.packageName - is ConstraintData.AppNotInForeground -> appInForeground != constraint.data.packageName - is ConstraintData.AppPlayingMedia -> { - if (appsPlayingMedia.contains(constraint.data.packageName)) { - return true - } else if (appInForeground == constraint.data.packageName && isMediaPlaying()) { - return true - } else { - return false - } - } - is ConstraintData.AppNotPlayingMedia -> - appsPlayingMedia.none { it == constraint.data.packageName } && - !(appInForeground == constraint.data.packageName && isMediaPlaying()) + is ConstraintData.AppPlayingMedia -> + appsPlayingMedia.contains(constraint.data.packageName) || + (appInForeground == constraint.data.packageName && isMediaPlaying()) is ConstraintData.MediaPlaying -> isMediaPlaying() - is ConstraintData.NoMediaPlaying -> !isMediaPlaying() - is ConstraintData.BtDeviceConnected -> { connectedBluetoothDevices.any { it.address == constraint.data.bluetoothAddress } } - is ConstraintData.BtDeviceDisconnected -> { - connectedBluetoothDevices.none { it.address == constraint.data.bluetoothAddress } - } - is ConstraintData.OrientationCustom -> orientation == constraint.data.orientation + is ConstraintData.OrientationLandscape -> orientation == Orientation.ORIENTATION_90 || orientation == Orientation.ORIENTATION_270 @@ -134,7 +126,6 @@ class LazyConstraintSnapshot( is ConstraintData.PhysicalOrientation -> physicalOrientation == constraint.data.physicalOrientation - is ConstraintData.ScreenOff -> !isScreenOn is ConstraintData.ScreenOn -> isScreenOn // Compare the resolution regardless of orientation so that the constraint @@ -149,8 +140,8 @@ class LazyConstraintSnapshot( displaySize.height == constraint.data.width ) - is ConstraintData.FlashlightOff -> !cameraAdapter.isFlashlightOn(constraint.data.lens) is ConstraintData.FlashlightOn -> cameraAdapter.isFlashlightOn(constraint.data.lens) + is ConstraintData.WifiConnected -> { if (constraint.data.ssid == null) { // connected to any network @@ -160,22 +151,14 @@ class LazyConstraintSnapshot( } } - is ConstraintData.WifiDisconnected -> - if (constraint.data.ssid == null) { - // connected to no network - connectedWifiSSID == null - } else { - connectedWifiSSID != constraint.data.ssid - } - - is ConstraintData.WifiOff -> !isWifiEnabled is ConstraintData.WifiOn -> isWifiEnabled + is ConstraintData.ImeChosen -> chosenImeId == constraint.data.imeId - is ConstraintData.ImeNotChosen -> chosenImeId != constraint.data.imeId + is ConstraintData.KeyboardShowing -> isKeyboardShowing - is ConstraintData.KeyboardNotShowing -> !isKeyboardShowing + is ConstraintData.DeviceIsLocked -> isLocked - is ConstraintData.DeviceIsUnlocked -> !isLocked + is ConstraintData.InPhoneCall -> callState == CallState.IN_PHONE_CALL || audioVolumeStreams.contains(AudioManager.STREAM_VOICE_CALL) @@ -191,7 +174,6 @@ class LazyConstraintSnapshot( is ConstraintData.RingerMode -> ringerMode == constraint.data.ringerMode is ConstraintData.Charging -> isCharging - is ConstraintData.Discharging -> !isCharging is ConstraintData.HingeClosed -> { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -220,12 +202,22 @@ class LazyConstraintSnapshot( is ConstraintData.LockScreenShowing -> isLockscreenShowing && appInForeground == "com.android.systemui" - is ConstraintData.LockScreenNotShowing -> - !isLockscreenShowing || - appInForeground != "com.android.systemui" is ConstraintData.NotificationPanelShowing -> isNotificationShadeExpanded - is ConstraintData.NotificationPanelNotShowing -> !isNotificationShadeExpanded + + is ConstraintData.NotificationPosted -> if (constraint.isNot) { + // Some notifications will not match, such as from the system. The text will + // be replaced with a stub saying it is sensitive content. + + // If it is not then none of the notifications can match. + return activeNotifications.none { + NotificationConstraintMatcher.matches(it, constraint.data) + } + } else { + return activeNotifications.any { + NotificationConstraintMatcher.matches(it, constraint.data) + } + } is ConstraintData.Time -> if (constraint.data.startTime.isAfter(constraint.data.endTime)) { @@ -237,11 +229,14 @@ class LazyConstraintSnapshot( } } - return isSatisfied + return isSatisfied != constraint.isNot } } interface ConstraintSnapshot { + /** + * Whether the constraint is satisfied, taking into account whether it is inverted. + */ fun isSatisfied(constraint: Constraint): Boolean } @@ -251,36 +246,31 @@ interface ConstraintSnapshot { */ fun ConstraintSnapshot.isSatisfied(vararg constraintState: ConstraintState): Boolean { for (state in constraintState) { - when (state.mode) { - ConstraintMode.AND -> { - for (constraint in state.constraints) { - if (!isSatisfied(constraint)) { - return false - } - } - } + if (!isStateSatisfied(state)) { + return false + } + } - ConstraintMode.OR -> { - // If no constraints then still satisfied - if (state.constraints.isEmpty()) { - continue - } + return true +} - var anySatisfied = false +private fun ConstraintSnapshot.isStateSatisfied(state: ConstraintState): Boolean { + // Empty groups are ignored and if there are no constraints then it is still satisfied. + val groups = state.groups.filter { it.constraints.isNotEmpty() } - for (constraint in state.constraints) { - if (isSatisfied(constraint)) { - anySatisfied = true - break - } - } + if (groups.isEmpty()) { + return true + } - if (!anySatisfied) { - return false - } - } - } + return when (state.mode) { + ConstraintMode.AND -> groups.all { isGroupSatisfied(it) } + ConstraintMode.OR -> groups.any { isGroupSatisfied(it) } } +} - return true +private fun ConstraintSnapshot.isGroupSatisfied(group: ConstraintGroup): Boolean { + return when (group.mode) { + ConstraintMode.AND -> group.constraints.all { isSatisfied(it) } + ConstraintMode.OR -> group.constraints.any { isSatisfied(it) } + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintState.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintState.kt index ad284ccdb2..08538ec3c2 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintState.kt @@ -2,8 +2,14 @@ package io.github.sds100.keymapper.base.constraints import kotlinx.serialization.Serializable +/** + * @param mode how the results of each group are combined. + */ @Serializable data class ConstraintState( - val constraints: Set = emptySet(), + val groups: List = emptyList(), val mode: ConstraintMode = ConstraintMode.AND, -) +) { + val allConstraints: List + get() = groups.flatMap { it.constraints } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapper.kt new file mode 100644 index 0000000000..c84e39189f --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapper.kt @@ -0,0 +1,126 @@ +package io.github.sds100.keymapper.base.constraints + +import io.github.sds100.keymapper.data.entities.ConstraintEntity +import io.github.sds100.keymapper.data.entities.ConstraintGroupEntity +import java.util.UUID + +object ConstraintStateEntityMapper { + + /** + * For [GroupEntity][io.github.sds100.keymapper.data.entities.GroupEntity], which only ever + * stores a single flat list of constraints combined with one mode. Its constraints never + * have a [io.github.sds100.keymapper.data.entities.ConstraintEntity.groupUid]. + */ + fun fromEntity(entities: List, constraintMode: Int): ConstraintState { + val mode = ConstraintModeEntityMapper.fromEntity(constraintMode) + val constraints = entities.map { ConstraintEntityMapper.fromEntity(it) } + + val groups = if (constraints.isEmpty()) { + emptyList() + } else { + listOf( + ConstraintGroup( + uid = UUID.randomUUID().toString(), + constraints = constraints, + mode = mode, + ), + ) + } + + return ConstraintState(groups = groups, mode = mode) + } + + /** + * @return the constraint entities and the constraint mode to save. + */ + fun toEntity(state: ConstraintState): Pair, Int> { + val group = state.groups.singleOrNull() + val entities = group?.constraints?.map { ConstraintEntityMapper.toEntity(it) } + val mode = group?.mode ?: state.mode + + return Pair(entities.orEmpty(), ConstraintModeEntityMapper.toEntity(mode)) + } + + /** + * For [KeyMapEntity][io.github.sds100.keymapper.data.entities.KeyMapEntity], which supports + * multiple nameable constraint groups. + * + * Constraints that were saved before constraint groups existed do not have a + * [ConstraintEntity.groupUid] and the constraint mode is the mode of the only group. + * + * If there are multiple groups then each constraint entity has a [ConstraintEntity.groupUid] + * for the group it is in, and the group's mode/name are looked up from the matching + * [io.github.sds100.keymapper.data.entities.ConstraintGroupEntity]. + */ + fun fromEntityWithGroups( + entities: List, + groupEntities: List, + constraintMode: Int, + ): ConstraintState { + val mode = ConstraintModeEntityMapper.fromEntity(constraintMode) + + val hasGroups = entities.any { it.groupUid != null } + + if (!hasGroups) { + val constraints = entities.map { ConstraintEntityMapper.fromEntity(it) } + + val groups = if (constraints.isEmpty()) { + emptyList() + } else { + listOf( + ConstraintGroup( + uid = UUID.randomUUID().toString(), + constraints = constraints, + mode = mode, + ), + ) + } + + return ConstraintState(groups = groups, mode = mode) + } + + val groups = entities + .groupBy { it.groupUid } + .map { (groupUid, groupConstraintEntities) -> + val groupEntity = groupEntities.find { it.uid == groupUid } + val constraints = groupConstraintEntities.map { + ConstraintEntityMapper.fromEntity(it) + } + + ConstraintGroup( + uid = groupUid ?: constraints.first().uid, + name = groupEntity?.name, + constraints = constraints, + mode = ConstraintModeEntityMapper.fromEntity( + groupEntity?.mode ?: ConstraintEntity.DEFAULT_MODE, + ), + ) + } + + return ConstraintState(groups = groups, mode = mode) + } + + /** + * @return the constraint entities, the constraint group entities, and the constraint mode + * to save. + */ + fun toEntityWithGroups( + state: ConstraintState, + ): Triple, List, Int> { + val groupEntities = state.groups.map { group -> + ConstraintGroupEntity( + uid = group.uid, + name = group.name, + mode = ConstraintModeEntityMapper.toEntity(group.mode), + ) + } + + val entities = state.groups.flatMap { group -> + group.constraints.map { constraint -> + ConstraintEntityMapper.toEntity(constraint).copy(groupUid = group.uid) + } + } + + return Triple(entities, groupEntities, ConstraintModeEntityMapper.toEntity(state.mode)) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt index 4990777498..f64f6b1cc8 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelper.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.base.constraints +import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Android import io.github.sds100.keymapper.base.R @@ -22,38 +23,32 @@ class ConstraintUiHelper( private val timeFormatter by lazy { TimeUtils.localeDateFormatter(FormatStyle.SHORT) } - fun getTitle(constraint: Constraint): String = when (constraint.data) { - is ConstraintData.AppInForeground -> - getAppName(constraint.data.packageName).handle( - onSuccess = { getString(R.string.constraint_app_foreground_description, it) }, - onError = { getString(R.string.constraint_choose_app_foreground) }, - ) + fun getTitle(constraint: Constraint): String { + return if (constraint.isNot) { + getNotTitle(constraint) + } else { + getNormalTitle(constraint) + } + } - is ConstraintData.AppNotInForeground -> - getAppName(constraint.data.packageName).handle( - onSuccess = { getString(R.string.constraint_app_not_foreground_description, it) }, - onError = { getString(R.string.constraint_choose_app_not_foreground) }, - ) + private fun getNormalTitle(constraint: Constraint): String = when (constraint.data) { + is ConstraintData.AppInForeground -> { + val name = getAppName(constraint.data.packageName).valueIfFailure { + constraint.data.appName ?: constraint.data.packageName + } - is ConstraintData.AppPlayingMedia -> - getAppName(constraint.data.packageName).handle( - onSuccess = { getString(R.string.constraint_app_playing_media_description, it) }, - onError = { getString(R.string.constraint_choose_app_playing_media) }, - ) + getString(R.string.constraint_app_foreground_description, name) + } - is ConstraintData.AppNotPlayingMedia -> - getAppName(constraint.data.packageName).handle( - onSuccess = { - getString( - R.string.constraint_app_not_playing_media_description, - it, - ) - }, - onError = { getString(R.string.constraint_choose_app_playing_media) }, - ) + is ConstraintData.AppPlayingMedia -> { + val name = getAppName(constraint.data.packageName).valueIfFailure { + constraint.data.appName ?: constraint.data.packageName + } + + getString(R.string.constraint_app_playing_media_description, name) + } is ConstraintData.MediaPlaying -> getString(R.string.constraint_choose_media_playing) - is ConstraintData.NoMediaPlaying -> getString(R.string.constraint_choose_media_not_playing) is ConstraintData.BtDeviceConnected -> getString( @@ -61,12 +56,6 @@ class ConstraintUiHelper( constraint.data.deviceName, ) - is ConstraintData.BtDeviceDisconnected -> - getString( - R.string.constraint_bt_device_disconnected_description, - constraint.data.deviceName, - ) - is ConstraintData.OrientationCustom -> { val resId = when (constraint.data.orientation) { Orientation.ORIENTATION_0 -> R.string.constraint_choose_orientation_0 @@ -88,10 +77,13 @@ class ConstraintUiHelper( val resId = when (constraint.data.physicalOrientation) { PhysicalOrientation.PORTRAIT -> R.string.constraint_choose_physical_orientation_portrait + PhysicalOrientation.LANDSCAPE -> R.string.constraint_choose_physical_orientation_landscape + PhysicalOrientation.PORTRAIT_INVERTED -> R.string.constraint_choose_physical_orientation_portrait_inverted + PhysicalOrientation.LANDSCAPE_INVERTED -> R.string.constraint_choose_physical_orientation_landscape_inverted } @@ -99,9 +91,6 @@ class ConstraintUiHelper( getString(resId) } - is ConstraintData.ScreenOff -> - getString(R.string.constraint_screen_off_description) - is ConstraintData.ScreenOn -> getString(R.string.constraint_screen_on_description) @@ -110,12 +99,6 @@ class ConstraintUiHelper( arrayOf(constraint.data.width, constraint.data.height), ) - is ConstraintData.FlashlightOff -> if (constraint.data.lens == CameraLens.FRONT) { - getString(R.string.constraint_front_flashlight_off_description) - } else { - getString(R.string.constraint_flashlight_off_description) - } - is ConstraintData.FlashlightOn -> if (constraint.data.lens == CameraLens.FRONT) { getString(R.string.constraint_front_flashlight_on_description) } else { @@ -130,15 +113,6 @@ class ConstraintUiHelper( } } - is ConstraintData.WifiDisconnected -> { - if (constraint.data.ssid == null) { - getString(R.string.constraint_wifi_disconnected_any_description) - } else { - getString(R.string.constraint_wifi_disconnected_description, constraint.data.ssid) - } - } - - is ConstraintData.WifiOff -> getString(R.string.constraint_wifi_off) is ConstraintData.WifiOn -> getString(R.string.constraint_wifi_on) is ConstraintData.ImeChosen -> { @@ -149,43 +123,38 @@ class ConstraintUiHelper( getString(R.string.constraint_ime_chosen_description, label) } - is ConstraintData.ImeNotChosen -> { - val label = getInputMethodLabel(constraint.data.imeId).valueIfFailure { - constraint.data.imeLabel - } - - getString(R.string.constraint_ime_not_chosen_description, label) - } - is ConstraintData.KeyboardShowing -> getString( R.string.constraint_keyboard_showing_description, ) - is ConstraintData.KeyboardNotShowing -> getString( - R.string.constraint_keyboard_not_showing_description, - ) is ConstraintData.DeviceIsLocked -> getString(R.string.constraint_device_is_locked) - is ConstraintData.DeviceIsUnlocked -> getString(R.string.constraint_device_is_unlocked) + is ConstraintData.InPhoneCall -> getString(R.string.constraint_in_phone_call) + is ConstraintData.NotInPhoneCall -> getString(R.string.constraint_not_in_phone_call) + is ConstraintData.PhoneRinging -> getString(R.string.constraint_phone_ringing) + is ConstraintData.RingerMode -> when (constraint.data.ringerMode) { RingerMode.NORMAL -> getString(R.string.constraint_ringer_mode_normal) RingerMode.VIBRATE -> getString(R.string.constraint_ringer_mode_vibrate) RingerMode.SILENT -> getString(R.string.constraint_ringer_mode_silent) } + is ConstraintData.Charging -> getString(R.string.constraint_charging) - is ConstraintData.Discharging -> getString(R.string.constraint_discharging) + is ConstraintData.HingeClosed -> getString(R.string.constraint_hinge_closed_description) + is ConstraintData.HingeOpen -> getString(R.string.constraint_hinge_open_description) + is ConstraintData.LockScreenShowing -> getString(R.string.constraint_lock_screen_showing) - is ConstraintData.LockScreenNotShowing -> getString( - R.string.constraint_lock_screen_not_showing, - ) + is ConstraintData.NotificationPanelShowing -> getString(R.string.constraint_notification_panel_showing) - is ConstraintData.NotificationPanelNotShowing -> - getString(R.string.constraint_notification_panel_not_showing) + + is ConstraintData.NotificationPosted -> + getNotificationPostedTitle(constraint.data, isNot = false) + is ConstraintData.Time -> getString( R.string.constraint_time_formatted, arrayOf( @@ -195,20 +164,201 @@ class ConstraintUiHelper( ) } + private fun getNotTitle(constraint: Constraint): String = when (constraint.data) { + is ConstraintData.AppInForeground -> { + val name = getAppName(constraint.data.packageName).valueIfFailure { + constraint.data.appName ?: constraint.data.packageName + } + + getString(R.string.constraint_app_not_foreground_description, name) + } + + is ConstraintData.AppPlayingMedia -> { + val name = getAppName(constraint.data.packageName).valueIfFailure { + constraint.data.appName ?: constraint.data.packageName + } + + getString(R.string.constraint_app_not_playing_media_description, name) + } + + is ConstraintData.MediaPlaying -> + getString(R.string.constraint_media_playing_not_description) + + is ConstraintData.BtDeviceConnected -> + getString( + R.string.constraint_bt_device_disconnected_description, + constraint.data.deviceName, + ) + + is ConstraintData.ScreenOn -> getString(R.string.constraint_screen_off_description) + + is ConstraintData.FlashlightOn -> if (constraint.data.lens == CameraLens.FRONT) { + getString(R.string.constraint_front_flashlight_off_description) + } else { + getString(R.string.constraint_flashlight_off_description) + } + + is ConstraintData.WifiConnected -> if (constraint.data.ssid == null) { + getString(R.string.constraint_wifi_disconnected_any_description) + } else { + getString(R.string.constraint_wifi_disconnected_description, constraint.data.ssid) + } + + is ConstraintData.WifiOn -> getString(R.string.constraint_wifi_off) + + is ConstraintData.ImeChosen -> { + val label = getInputMethodLabel(constraint.data.imeId).valueIfFailure { + constraint.data.imeLabel + } + + getString(R.string.constraint_ime_not_chosen_description, label) + } + + is ConstraintData.KeyboardShowing -> + getString(R.string.constraint_keyboard_not_showing_description) + + is ConstraintData.DeviceIsLocked -> getString(R.string.constraint_device_is_unlocked) + + is ConstraintData.InPhoneCall -> getString(R.string.constraint_in_phone_call_not) + + is ConstraintData.NotInPhoneCall -> getString(R.string.constraint_not_in_phone_call_not) + + is ConstraintData.LockScreenShowing -> + getString(R.string.constraint_lock_screen_not_showing) + + is ConstraintData.NotificationPanelShowing -> + getString(R.string.constraint_notification_panel_not_showing) + + is ConstraintData.Charging -> getString(R.string.constraint_discharging) + + is ConstraintData.PhoneRinging -> getString(R.string.constraint_phone_not_ringing) + + is ConstraintData.RingerMode -> when (constraint.data.ringerMode) { + RingerMode.NORMAL -> getString(R.string.constraint_ringer_mode_not_normal) + RingerMode.VIBRATE -> getString(R.string.constraint_ringer_mode_not_vibrate) + RingerMode.SILENT -> getString(R.string.constraint_ringer_mode_not_silent) + } + + is ConstraintData.HingeClosed -> + getString(R.string.constraint_hinge_not_closed_description) + + is ConstraintData.HingeOpen -> + getString(R.string.constraint_hinge_not_open_description) + + is ConstraintData.NotificationPosted -> + getNotificationPostedTitle(constraint.data, isNot = true) + + is ConstraintData.Time -> getString( + R.string.constraint_time_not_formatted, + arrayOf( + timeFormatter.format(constraint.data.startTime), + timeFormatter.format(constraint.data.endTime), + ), + ) + + is ConstraintData.OrientationCustom -> { + val resId = when (constraint.data.orientation) { + Orientation.ORIENTATION_0 -> R.string.constraint_not_orientation_0 + Orientation.ORIENTATION_90 -> R.string.constraint_not_orientation_90 + Orientation.ORIENTATION_180 -> R.string.constraint_not_orientation_180 + Orientation.ORIENTATION_270 -> R.string.constraint_not_orientation_270 + } + + getString(resId) + } + + is ConstraintData.OrientationLandscape -> + getString(R.string.constraint_not_orientation_landscape) + + is ConstraintData.OrientationPortrait -> + getString(R.string.constraint_not_orientation_portrait) + + is ConstraintData.PhysicalOrientation -> { + val resId = when (constraint.data.physicalOrientation) { + PhysicalOrientation.PORTRAIT -> + R.string.constraint_not_physical_orientation_portrait + + PhysicalOrientation.LANDSCAPE -> + R.string.constraint_not_physical_orientation_landscape + + PhysicalOrientation.PORTRAIT_INVERTED -> + R.string.constraint_not_physical_orientation_portrait_inverted + + PhysicalOrientation.LANDSCAPE_INVERTED -> + R.string.constraint_not_physical_orientation_landscape_inverted + } + + getString(resId) + } + + is ConstraintData.DisplayResolution -> getString( + R.string.constraint_display_resolution_not_description, + arrayOf(constraint.data.width, constraint.data.height), + ) + } + fun getIcon(constraint: Constraint): ComposeIconInfo = when (constraint.data) { is ConstraintData.AppInForeground -> getAppIconInfo(constraint.data.packageName) ?: ComposeIconInfo.Vector(Icons.Rounded.Android) - is ConstraintData.AppNotInForeground -> getAppIconInfo(constraint.data.packageName) - ?: ComposeIconInfo.Vector(Icons.Rounded.Android) - is ConstraintData.AppPlayingMedia -> getAppIconInfo(constraint.data.packageName) ?: ComposeIconInfo.Vector(Icons.Rounded.Android) - is ConstraintData.AppNotPlayingMedia -> getAppIconInfo(constraint.data.packageName) - ?: ComposeIconInfo.Vector(Icons.Rounded.Android) + is ConstraintData.NotificationPosted.FromApp -> + getAppIconInfo(constraint.data.packageName) + ?: ConstraintUtils.getIcon(constraint.id) + + else -> if (constraint.isNot) { + ConstraintUtils.getNotIcon(constraint.id) + } else { + ConstraintUtils.getIcon(constraint.id) + } + } + + private fun getNotificationPostedTitle( + data: ConstraintData.NotificationPosted, + isNot: Boolean, + ): String = when (data) { + is ConstraintData.NotificationPosted.FromApp -> { + val appName = getAppName(data.packageName).valueIfFailure { + data.appName ?: data.packageName + } + + getString( + if (isNot) { + R.string.constraint_notification_not_posted_from_app + } else { + R.string.constraint_notification_posted_from_app + }, + appName, + ) + } + + is ConstraintData.NotificationPosted.TextMatch -> getString( + if (isNot) { + R.string.constraint_notification_not_posted_match + } else { + R.string.constraint_notification_posted_match + }, + arrayOf( + getString(getNotificationFieldNoun(data)), + getString(getMatchModeVerb(data.matchMode)), + data.text, + ), + ) + } + + @StringRes + private fun getNotificationFieldNoun(data: ConstraintData.NotificationPosted.TextMatch): Int = + when (data) { + is ConstraintData.NotificationPosted.Title -> R.string.notification_field_title + is ConstraintData.NotificationPosted.Text -> R.string.notification_field_text + } - else -> ConstraintUtils.getIcon(constraint.id) + @StringRes + private fun getMatchModeVerb(matchMode: TextMatchMode): Int = when (matchMode) { + TextMatchMode.CONTAINS -> R.string.notification_match_mode_contains_sentence + TextMatchMode.EXACT -> R.string.notification_match_mode_exact_sentence } private fun getAppIconInfo(packageName: String): ComposeIconInfo? = diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt index cb9d2f2f79..dc42af5605 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintUtils.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.LockOpen import androidx.compose.material.icons.outlined.MobileOff import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.NotificationsActive import androidx.compose.material.icons.outlined.NotificationsOff import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.RingVolume @@ -48,26 +49,20 @@ object ConstraintUtils { ConstraintCategory.PHONE -> R.string.constraint_cat_phone ConstraintCategory.POWER -> R.string.constraint_cat_power ConstraintCategory.DEVICE -> R.string.constraint_cat_device + ConstraintCategory.NOTIFICATIONS -> R.string.constraint_cat_notifications ConstraintCategory.TIME -> R.string.constraint_cat_time } fun getCategory(constraintId: ConstraintId): ConstraintCategory = when (constraintId) { ConstraintId.APP_IN_FOREGROUND, - ConstraintId.APP_NOT_IN_FOREGROUND, ConstraintId.APP_PLAYING_MEDIA, - ConstraintId.APP_NOT_PLAYING_MEDIA, -> ConstraintCategory.APPS - ConstraintId.MEDIA_PLAYING, - ConstraintId.MEDIA_NOT_PLAYING, - -> ConstraintCategory.MEDIA + ConstraintId.MEDIA_PLAYING -> ConstraintCategory.MEDIA - ConstraintId.BT_DEVICE_CONNECTED, - ConstraintId.BT_DEVICE_DISCONNECTED, - -> ConstraintCategory.BLUETOOTH + ConstraintId.BT_DEVICE_CONNECTED -> ConstraintCategory.BLUETOOTH ConstraintId.SCREEN_ON, - ConstraintId.SCREEN_OFF, ConstraintId.DISPLAY_ORIENTATION_PORTRAIT, ConstraintId.DISPLAY_ORIENTATION_LANDSCAPE, ConstraintId.DISPLAY_ORIENTATION_0, @@ -81,26 +76,18 @@ object ConstraintUtils { ConstraintId.DISPLAY_RESOLUTION, -> ConstraintCategory.DISPLAY - ConstraintId.FLASHLIGHT_ON, - ConstraintId.FLASHLIGHT_OFF, - -> ConstraintCategory.FLASHLIGHT + ConstraintId.FLASHLIGHT_ON -> ConstraintCategory.FLASHLIGHT ConstraintId.WIFI_ON, - ConstraintId.WIFI_OFF, ConstraintId.WIFI_CONNECTED, - ConstraintId.WIFI_DISCONNECTED, -> ConstraintCategory.WIFI ConstraintId.IME_CHOSEN, - ConstraintId.IME_NOT_CHOSEN, ConstraintId.KEYBOARD_SHOWING, - ConstraintId.KEYBOARD_NOT_SHOWING, -> ConstraintCategory.KEYBOARD ConstraintId.DEVICE_IS_LOCKED, - ConstraintId.DEVICE_IS_UNLOCKED, ConstraintId.LOCK_SCREEN_SHOWING, - ConstraintId.LOCK_SCREEN_NOT_SHOWING, -> ConstraintCategory.LOCK ConstraintId.IN_PHONE_CALL, @@ -111,40 +98,30 @@ object ConstraintUtils { ConstraintId.RINGER_MODE_SILENT, -> ConstraintCategory.PHONE - ConstraintId.CHARGING, - ConstraintId.DISCHARGING, - -> ConstraintCategory.POWER + ConstraintId.CHARGING -> ConstraintCategory.POWER ConstraintId.HINGE_CLOSED, ConstraintId.HINGE_OPEN, -> ConstraintCategory.DEVICE ConstraintId.NOTIFICATION_PANEL_SHOWING, - ConstraintId.NOTIFICATION_PANEL_NOT_SHOWING, - -> ConstraintCategory.DISPLAY + ConstraintId.NOTIFICATION_POSTED, + -> ConstraintCategory.NOTIFICATIONS ConstraintId.TIME -> ConstraintCategory.TIME } fun getIcon(constraintId: ConstraintId): ComposeIconInfo = when (constraintId) { ConstraintId.APP_IN_FOREGROUND, - ConstraintId.APP_NOT_IN_FOREGROUND, ConstraintId.APP_PLAYING_MEDIA, - ConstraintId.APP_NOT_PLAYING_MEDIA, -> ComposeIconInfo.Vector(Icons.Rounded.Android) ConstraintId.MEDIA_PLAYING -> ComposeIconInfo.Vector(Icons.Outlined.PlayArrow) - ConstraintId.MEDIA_NOT_PLAYING -> ComposeIconInfo.Vector(Icons.Outlined.StopCircle) - ConstraintId.BT_DEVICE_CONNECTED -> ComposeIconInfo.Vector( Icons.Outlined.BluetoothConnected, ) - ConstraintId.BT_DEVICE_DISCONNECTED -> ComposeIconInfo.Vector( - Icons.Outlined.BluetoothDisabled, - ) - ConstraintId.DISPLAY_ORIENTATION_0, ConstraintId.DISPLAY_ORIENTATION_180, -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait) @@ -169,38 +146,22 @@ object ConstraintUtils { ConstraintId.PHYSICAL_ORIENTATION_LANDSCAPE_INVERTED, -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentLandscape) - ConstraintId.SCREEN_OFF -> ComposeIconInfo.Vector(Icons.Outlined.MobileOff) - ConstraintId.SCREEN_ON -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait) ConstraintId.DISPLAY_RESOLUTION -> ComposeIconInfo.Vector(Icons.Outlined.AspectRatio) - ConstraintId.FLASHLIGHT_OFF -> ComposeIconInfo.Vector(Icons.Outlined.FlashlightOff) - ConstraintId.FLASHLIGHT_ON -> ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn) ConstraintId.WIFI_CONNECTED -> ComposeIconInfo.Vector(Icons.Outlined.Wifi) - ConstraintId.WIFI_DISCONNECTED -> ComposeIconInfo.Vector( - Icons.Outlined.SignalWifiStatusbarNull, - ) - - ConstraintId.WIFI_OFF -> ComposeIconInfo.Vector(Icons.Outlined.WifiOff) - ConstraintId.WIFI_ON -> ComposeIconInfo.Vector(Icons.Outlined.Wifi) - ConstraintId.IME_CHOSEN, - ConstraintId.IME_NOT_CHOSEN, - -> ComposeIconInfo.Vector(Icons.Outlined.Keyboard) + ConstraintId.IME_CHOSEN -> ComposeIconInfo.Vector(Icons.Outlined.Keyboard) ConstraintId.KEYBOARD_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.Keyboard) - ConstraintId.KEYBOARD_NOT_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.KeyboardHide) - ConstraintId.DEVICE_IS_LOCKED -> ComposeIconInfo.Vector(Icons.Outlined.Lock) - ConstraintId.DEVICE_IS_UNLOCKED -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen) - ConstraintId.IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.Call) ConstraintId.NOT_IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.CallEnd) @@ -215,8 +176,6 @@ object ConstraintUtils { ConstraintId.CHARGING -> ComposeIconInfo.Vector(Icons.Outlined.BatteryChargingFull) - ConstraintId.DISCHARGING -> ComposeIconInfo.Vector(Icons.Outlined.Battery2Bar) - ConstraintId.HINGE_CLOSED -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentPortrait) ConstraintId.HINGE_OPEN -> ComposeIconInfo.Vector(Icons.Outlined.StayCurrentLandscape) @@ -225,39 +184,67 @@ object ConstraintUtils { Icons.Outlined.ScreenLockPortrait, ) - ConstraintId.LOCK_SCREEN_NOT_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen) - ConstraintId.NOTIFICATION_PANEL_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.Notifications) - ConstraintId.NOTIFICATION_PANEL_NOT_SHOWING -> - ComposeIconInfo.Vector(Icons.Outlined.NotificationsOff) + ConstraintId.NOTIFICATION_POSTED -> + ComposeIconInfo.Vector(Icons.Outlined.NotificationsActive) ConstraintId.TIME -> ComposeIconInfo.Vector(Icons.Outlined.Timer) } - fun getTitleStringId(constraintId: ConstraintId): Int = when (constraintId) { - ConstraintId.APP_IN_FOREGROUND -> R.string.constraint_choose_app_foreground + fun getNotIcon(constraintId: ConstraintId): ComposeIconInfo = when (constraintId) { + ConstraintId.MEDIA_PLAYING -> ComposeIconInfo.Vector(Icons.Outlined.StopCircle) - ConstraintId.APP_NOT_IN_FOREGROUND -> R.string.constraint_choose_app_not_foreground + ConstraintId.BT_DEVICE_CONNECTED -> ComposeIconInfo.Vector( + Icons.Outlined.BluetoothDisabled, + ) - ConstraintId.APP_PLAYING_MEDIA -> R.string.constraint_choose_app_playing_media + ConstraintId.SCREEN_ON -> ComposeIconInfo.Vector(Icons.Outlined.MobileOff) - ConstraintId.APP_NOT_PLAYING_MEDIA -> R.string.constraint_choose_app_not_playing_media + ConstraintId.FLASHLIGHT_ON -> ComposeIconInfo.Vector(Icons.Outlined.FlashlightOff) - ConstraintId.MEDIA_NOT_PLAYING -> R.string.constraint_choose_media_not_playing + ConstraintId.WIFI_CONNECTED -> ComposeIconInfo.Vector( + Icons.Outlined.SignalWifiStatusbarNull, + ) + + ConstraintId.WIFI_ON -> ComposeIconInfo.Vector(Icons.Outlined.WifiOff) + + ConstraintId.KEYBOARD_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.KeyboardHide) + + ConstraintId.DEVICE_IS_LOCKED -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen) + + ConstraintId.LOCK_SCREEN_SHOWING -> ComposeIconInfo.Vector(Icons.Outlined.LockOpen) + + ConstraintId.CHARGING -> ComposeIconInfo.Vector(Icons.Outlined.Battery2Bar) + + ConstraintId.NOTIFICATION_PANEL_SHOWING -> ComposeIconInfo.Vector( + Icons.Outlined.NotificationsOff, + ) + + ConstraintId.IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.CallEnd) + + ConstraintId.NOT_IN_PHONE_CALL -> ComposeIconInfo.Vector(Icons.Outlined.Call) + + ConstraintId.PHONE_RINGING -> ComposeIconInfo.Vector(Icons.Outlined.CallEnd) + + ConstraintId.NOTIFICATION_POSTED -> + ComposeIconInfo.Vector(Icons.Outlined.NotificationsOff) + + else -> getIcon(constraintId) + } + + fun getTitleStringId(constraintId: ConstraintId): Int = when (constraintId) { + ConstraintId.APP_IN_FOREGROUND -> R.string.constraint_choose_app_foreground + + ConstraintId.APP_PLAYING_MEDIA -> R.string.constraint_choose_app_playing_media ConstraintId.MEDIA_PLAYING -> R.string.constraint_choose_media_playing ConstraintId.BT_DEVICE_CONNECTED -> R.string.constraint_choose_bluetooth_device_connected - ConstraintId.BT_DEVICE_DISCONNECTED -> - R.string.constraint_choose_bluetooth_device_disconnected - ConstraintId.SCREEN_ON -> R.string.constraint_choose_screen_on_description - ConstraintId.SCREEN_OFF -> R.string.constraint_choose_screen_off_description - ConstraintId.DISPLAY_RESOLUTION -> R.string.constraint_choose_display_resolution ConstraintId.DISPLAY_ORIENTATION_PORTRAIT -> R.string.constraint_choose_orientation_portrait @@ -287,28 +274,16 @@ object ConstraintUtils { ConstraintId.FLASHLIGHT_ON -> R.string.constraint_flashlight_on - ConstraintId.FLASHLIGHT_OFF -> R.string.constraint_flashlight_off - ConstraintId.WIFI_ON -> R.string.constraint_wifi_on - ConstraintId.WIFI_OFF -> R.string.constraint_wifi_off - ConstraintId.WIFI_CONNECTED -> R.string.constraint_wifi_connected - ConstraintId.WIFI_DISCONNECTED -> R.string.constraint_wifi_disconnected - ConstraintId.IME_CHOSEN -> R.string.constraint_ime_chosen - ConstraintId.IME_NOT_CHOSEN -> R.string.constraint_ime_not_chosen - ConstraintId.KEYBOARD_SHOWING -> R.string.constraint_keyboard_showing - ConstraintId.KEYBOARD_NOT_SHOWING -> R.string.constraint_keyboard_not_showing - ConstraintId.DEVICE_IS_LOCKED -> R.string.constraint_device_is_locked - ConstraintId.DEVICE_IS_UNLOCKED -> R.string.constraint_device_is_unlocked - ConstraintId.IN_PHONE_CALL -> R.string.constraint_in_phone_call ConstraintId.NOT_IN_PHONE_CALL -> R.string.constraint_not_in_phone_call @@ -323,84 +298,68 @@ object ConstraintUtils { ConstraintId.CHARGING -> R.string.constraint_charging - ConstraintId.DISCHARGING -> R.string.constraint_discharging - ConstraintId.HINGE_CLOSED -> R.string.constraint_hinge_closed ConstraintId.HINGE_OPEN -> R.string.constraint_hinge_open ConstraintId.LOCK_SCREEN_SHOWING -> R.string.constraint_lock_screen_showing - ConstraintId.LOCK_SCREEN_NOT_SHOWING -> R.string.constraint_lock_screen_not_showing - ConstraintId.NOTIFICATION_PANEL_SHOWING -> R.string.constraint_notification_panel_showing - ConstraintId.NOTIFICATION_PANEL_NOT_SHOWING -> - R.string.constraint_notification_panel_not_showing + ConstraintId.NOTIFICATION_POSTED -> R.string.constraint_notification_posted ConstraintId.TIME -> R.string.constraint_time } + @StringRes + fun getNotificationFieldLabel(field: NotificationField): Int = when (field) { + NotificationField.PACKAGE -> R.string.notification_field_package_label + NotificationField.TITLE -> R.string.notification_field_title_label + NotificationField.TEXT -> R.string.notification_field_text_label + } + + @StringRes + fun getTextMatchModeLabel(matchMode: TextMatchMode): Int = when (matchMode) { + TextMatchMode.CONTAINS -> R.string.notification_match_mode_contains + TextMatchMode.EXACT -> R.string.notification_match_mode_exact + } + fun Constraint.getDependency(): Set { return when (data) { - is ConstraintData.AppInForeground, - is ConstraintData.AppNotInForeground, - -> setOf(ConstraintDependency.FOREGROUND_APP) + is ConstraintData.AppInForeground -> setOf(ConstraintDependency.FOREGROUND_APP) - is ConstraintData.AppNotPlayingMedia, - is ConstraintData.AppPlayingMedia, - -> setOf(ConstraintDependency.APP_PLAYING_MEDIA) + is ConstraintData.AppPlayingMedia -> setOf(ConstraintDependency.APP_PLAYING_MEDIA) - is ConstraintData.BtDeviceConnected, - is ConstraintData.BtDeviceDisconnected, - -> setOf(ConstraintDependency.CONNECTED_BT_DEVICES) + is ConstraintData.BtDeviceConnected -> setOf(ConstraintDependency.CONNECTED_BT_DEVICES) - is ConstraintData.Charging, - is ConstraintData.Discharging, - -> setOf(ConstraintDependency.CHARGING_STATE) + is ConstraintData.Charging -> setOf(ConstraintDependency.CHARGING_STATE) - is ConstraintData.DeviceIsLocked, - is ConstraintData.DeviceIsUnlocked, - -> setOf(ConstraintDependency.DEVICE_LOCKED_STATE) + is ConstraintData.DeviceIsLocked -> setOf(ConstraintDependency.DEVICE_LOCKED_STATE) - is ConstraintData.FlashlightOff, - is ConstraintData.FlashlightOn, - -> setOf(ConstraintDependency.FLASHLIGHT_STATE) + is ConstraintData.FlashlightOn -> setOf(ConstraintDependency.FLASHLIGHT_STATE) - is ConstraintData.ImeChosen, - is ConstraintData.ImeNotChosen, - -> setOf(ConstraintDependency.CHOSEN_IME) + is ConstraintData.ImeChosen -> setOf(ConstraintDependency.CHOSEN_IME) is ConstraintData.InPhoneCall, is ConstraintData.NotInPhoneCall, is ConstraintData.PhoneRinging, -> setOf(ConstraintDependency.PHONE_STATE) - is ConstraintData.MediaPlaying, - is ConstraintData.NoMediaPlaying, - -> setOf(ConstraintDependency.MEDIA_PLAYING) + is ConstraintData.MediaPlaying -> setOf(ConstraintDependency.MEDIA_PLAYING) is ConstraintData.OrientationCustom, is ConstraintData.OrientationLandscape, is ConstraintData.OrientationPortrait, -> setOf(ConstraintDependency.DISPLAY_ORIENTATION) - is ConstraintData.ScreenOff, - is ConstraintData.ScreenOn, - -> setOf(ConstraintDependency.SCREEN_STATE) + is ConstraintData.ScreenOn -> setOf(ConstraintDependency.SCREEN_STATE) - is ConstraintData.WifiConnected, - is ConstraintData.WifiDisconnected, - -> setOf(ConstraintDependency.WIFI_SSID) + is ConstraintData.WifiConnected -> setOf(ConstraintDependency.WIFI_SSID) - is ConstraintData.WifiOff, - is ConstraintData.WifiOn, - -> setOf(ConstraintDependency.WIFI_STATE) + is ConstraintData.WifiOn -> setOf(ConstraintDependency.WIFI_STATE) - is ConstraintData.LockScreenShowing, - is ConstraintData.LockScreenNotShowing, - -> setOf(ConstraintDependency.LOCK_SCREEN_SHOWING) + is ConstraintData.LockScreenShowing -> setOf(ConstraintDependency.LOCK_SCREEN_SHOWING) is ConstraintData.Time -> setOf( ConstraintDependency.FOREGROUND_APP, @@ -411,9 +370,7 @@ object ConstraintUtils { is ConstraintData.HingeOpen -> setOf(ConstraintDependency.HINGE_STATE) - ConstraintData.KeyboardNotShowing, - ConstraintData.KeyboardShowing, - -> setOf(ConstraintDependency.KEYBOARD_VISIBLE) + ConstraintData.KeyboardShowing -> setOf(ConstraintDependency.KEYBOARD_VISIBLE) is ConstraintData.PhysicalOrientation -> setOf( ConstraintDependency.PHYSICAL_ORIENTATION, @@ -421,12 +378,13 @@ object ConstraintUtils { is ConstraintData.RingerMode -> setOf(ConstraintDependency.RINGER_MODE) - ConstraintData.NotificationPanelNotShowing, ConstraintData.NotificationPanelShowing -> - setOf( - ConstraintDependency.NOTIFICATION_PANEL_STATE, - ) + ConstraintData.NotificationPanelShowing -> + setOf(ConstraintDependency.NOTIFICATION_PANEL_STATE) is ConstraintData.DisplayResolution -> setOf(ConstraintDependency.DISPLAY_RESOLUTIONS) + + is ConstraintData.NotificationPosted -> + setOf(ConstraintDependency.POSTED_NOTIFICATIONS) } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt index dfe69d2467..ffe841aba1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/ConstraintsScreen.kt @@ -9,10 +9,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Wifi import androidx.compose.material.icons.rounded.FlashlightOn import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults @@ -42,11 +44,19 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.keymaps.ShortcutModel import io.github.sds100.keymapper.base.keymaps.ShortcutRow import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo -import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText +import io.github.sds100.keymapper.base.utils.ui.compose.DraggableItem +import io.github.sds100.keymapper.base.utils.ui.compose.TextFieldDialog +import io.github.sds100.keymapper.base.utils.ui.compose.rememberDragDropState import io.github.sds100.keymapper.base.utils.ui.drawable import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.system.camera.CameraLens +/** + * The height of the row between group cards. The last card has a spacer of the same height so + * the height of the items stays constant while dragging. + */ +private val linkRowHeight = 40.dp + @Composable fun ConstraintsScreen( modifier: Modifier = Modifier, @@ -67,11 +77,18 @@ fun ConstraintsScreen( ConstraintsScreen( modifier = modifier, state = state, + onAddClick = { viewModel.addConstraint(groupUid = null) }, + onAddToGroupClick = viewModel::addConstraint, onRemoveClick = viewModel::onRemoveClick, + onRemoveGroupClick = viewModel::onRemoveGroupClick, + onNotClick = viewModel::onNotClick, onFixErrorClick = viewModel::onFixError, onClickShortcut = viewModel::onClickShortcut, - onAddClick = viewModel::addConstraint, onSelectMode = viewModel::onSelectMode, + onSelectGroupMode = viewModel::onSelectGroupMode, + onRenameGroup = viewModel::onRenameGroup, + onExpandedChange = viewModel::onExpandedChange, + onMoveGroup = viewModel::onMoveGroup, ) } @@ -80,39 +97,94 @@ private fun ConstraintsScreen( modifier: Modifier = Modifier, state: State, onAddClick: () -> Unit = {}, + onAddToGroupClick: (String) -> Unit = {}, onRemoveClick: (String) -> Unit = {}, + onRemoveGroupClick: (String) -> Unit = {}, + onNotClick: (String) -> Unit = {}, onFixErrorClick: (String) -> Unit = {}, onClickShortcut: (ConstraintData) -> Unit = {}, onSelectMode: (ConstraintMode) -> Unit = {}, + onSelectGroupMode: (String, ConstraintMode) -> Unit = { _, _ -> }, + onRenameGroup: (groupUid: String, name: String) -> Unit = { _, _ -> }, + onExpandedChange: (groupUid: String, expanded: Boolean) -> Unit = { _, _ -> }, + onMoveGroup: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, ) { - var showDeleteDialog by rememberSaveable { mutableStateOf(false) } var constraintToDelete by rememberSaveable { mutableStateOf(null) } + var groupToDelete by rememberSaveable { mutableStateOf(null) } + var groupToRename by rememberSaveable { mutableStateOf(null) } - if (showDeleteDialog && constraintToDelete != null) { + if (constraintToDelete != null) { AlertDialog( - onDismissRequest = { showDeleteDialog = false }, + onDismissRequest = { constraintToDelete = null }, title = { Text(stringResource(R.string.constraint_list_delete_dialog_title)) }, text = { Text(stringResource(R.string.constraint_list_delete_dialog_text)) }, confirmButton = { - TextButton(onClick = { - onRemoveClick(constraintToDelete!!) - showDeleteDialog = false - }) { + TextButton( + onClick = { + onRemoveClick(constraintToDelete!!) + constraintToDelete = null + }, + ) { + Text(stringResource(R.string.constraint_list_delete_yes)) + } + }, + dismissButton = { + TextButton(onClick = { constraintToDelete = null }) { + Text(stringResource(R.string.constraint_list_delete_cancel)) + } + }, + ) + } + + if (groupToDelete != null) { + AlertDialog( + onDismissRequest = { groupToDelete = null }, + title = { + Text(stringResource(R.string.constraint_group_delete_dialog_title)) + }, + text = { Text(stringResource(R.string.constraint_group_delete_dialog_text)) }, + confirmButton = { + TextButton( + onClick = { + onRemoveGroupClick(groupToDelete!!) + groupToDelete = null + }, + ) { Text(stringResource(R.string.constraint_list_delete_yes)) } }, dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { + TextButton(onClick = { groupToDelete = null }) { Text(stringResource(R.string.constraint_list_delete_cancel)) } }, ) } - when (val state = state) { + val groups = ((state as? State.Data)?.data as? ConfigConstraintsState.Loaded) + ?.groups + .orEmpty() + val renameModel = groups.find { it.uid == groupToRename } + + if (renameModel != null) { + TextFieldDialog( + title = stringResource(R.string.constraint_group_rename_hint), + submitButtonText = stringResource(R.string.pos_save), + initialText = renameModel.name.orEmpty(), + canBeEmpty = true, + onSubmitClick = { newText -> + onRenameGroup(renameModel.uid, newText) + null + }, + onDismissRequest = { groupToRename = null }, + ) + } + + when (state) { State.Loading -> Loading() + is State.Data -> Surface(modifier = modifier) { Column { when (val data = state.data) { @@ -152,40 +224,40 @@ private fun ConstraintsScreen( } is ConfigConstraintsState.Loaded -> { - Spacer(Modifier.height(8.dp)) - - if (data.constraintList.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - - Text( - modifier = Modifier.padding(horizontal = 16.dp), - text = stringResource(R.string.constraint_list_explanation_header), - style = MaterialTheme.typography.titleSmall, - ) - } - - Spacer(Modifier.height(8.dp)) - - ConstraintList( + ConstraintGroupList( modifier = Modifier.weight(1f), - constraintList = data.constraintList, - shortcuts = data.shortcuts, - onRemoveClick = { - constraintToDelete = it - showDeleteDialog = true - }, + state = data, + onAddToGroupClick = onAddToGroupClick, + onRemoveClick = { constraintToDelete = it }, + onRemoveGroupClick = { groupToDelete = it }, + onNotClick = onNotClick, onFixErrorClick = onFixErrorClick, onClickShortcut = onClickShortcut, + onSelectGroupMode = onSelectGroupMode, + onRenameGroupClick = { groupToRename = it }, + onExpandedChange = onExpandedChange, + onMoveGroup = onMoveGroup, ) - if (data.constraintList.size > 1) { - ConstraintModeRow( + if (data.groups.size > 1) { + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp), - mode = data.selectedMode, - onSelectMode = onSelectMode, - ) + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.constraint_groups_mode_title), + style = MaterialTheme.typography.titleSmall, + ) + + ConstraintModeButtons( + modifier = Modifier.width(160.dp), + mode = data.mode, + onSelectMode = onSelectMode, + ) + } } } } @@ -207,37 +279,6 @@ private fun ConstraintsScreen( } } -@Composable -private fun ConstraintModeRow( - modifier: Modifier = Modifier, - mode: ConstraintMode, - onSelectMode: (ConstraintMode) -> Unit, -) { - Column(modifier = modifier) { - Text( - modifier = Modifier.padding(horizontal = 8.dp), - text = stringResource(R.string.constraint_mode_title), - style = MaterialTheme.typography.labelLarge, - ) - - Row(modifier = Modifier.fillMaxWidth()) { - RadioButtonText( - modifier = Modifier.weight(1f), - isSelected = mode == ConstraintMode.AND, - text = stringResource(R.string.constraint_mode_and), - onSelected = { onSelectMode(ConstraintMode.AND) }, - ) - - RadioButtonText( - modifier = Modifier.weight(1f), - isSelected = mode == ConstraintMode.OR, - text = stringResource(R.string.constraint_mode_or), - onSelected = { onSelectMode(ConstraintMode.OR) }, - ) - } - } -} - @Composable private fun Loading(modifier: Modifier = Modifier) { Box(modifier = modifier, contentAlignment = Alignment.Center) { @@ -246,50 +287,130 @@ private fun Loading(modifier: Modifier = Modifier) { } @Composable -private fun ConstraintList( +private fun ConstraintGroupList( modifier: Modifier = Modifier, - constraintList: List, - shortcuts: Set>, + state: ConfigConstraintsState.Loaded, + onAddToGroupClick: (String) -> Unit, onRemoveClick: (String) -> Unit, + onRemoveGroupClick: (String) -> Unit, + onNotClick: (String) -> Unit, onFixErrorClick: (String) -> Unit, onClickShortcut: (ConstraintData) -> Unit, + onSelectGroupMode: (String, ConstraintMode) -> Unit, + onRenameGroupClick: (groupUid: String) -> Unit, + onExpandedChange: (groupUid: String, expanded: Boolean) -> Unit, + onMoveGroup: (fromIndex: Int, toIndex: Int) -> Unit, ) { val lazyListState = rememberLazyListState() + val groupUids = state.groups.map { it.uid } + + // Only the groups can be dragged. Not the row of shortcuts. + val dragDropState = rememberDragDropState( + lazyListState = lazyListState, + keys = groupUids, + onMove = onMoveGroup, + ) + + val orderedGroups = dragDropState.ordered(state.groups) { it.uid } + + val linkText = when (state.mode) { + ConstraintMode.AND -> stringResource(R.string.constraint_mode_and) + ConstraintMode.OR -> stringResource(R.string.constraint_mode_or) + } - // Use dragContainer rather than .draggable() modifier because that causes - // dragging the first item to be always be dropped in the next position. LazyColumn( modifier = modifier, state = lazyListState, contentPadding = PaddingValues(vertical = 8.dp), ) { - items( - constraintList, - key = { item -> item.id }, - contentType = { _ -> "constraint" }, - ) { model -> - ConstraintListItem( - modifier = Modifier.fillMaxWidth(), - model = model, - onRemoveClick = { onRemoveClick(model.id) }, - onFixClick = { onFixErrorClick(model.id) }, + item(key = "header") { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + text = stringResource(R.string.constraint_list_explanation), + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, ) } - if (shortcuts.isNotEmpty()) { + itemsIndexed( + orderedGroups, + key = { _, group -> group.uid }, + contentType = { _, _ -> "constraint_group" }, + ) { index, group -> + DraggableItem( + dragDropState = dragDropState, + key = group.uid, + ) { isDragging -> + Column { + ConstraintGroupItem( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + model = group, + isExpanded = group.isExpanded, + isDraggingEnabled = orderedGroups.size > 1, + isDragging = isDragging, + isReorderingEnabled = state.groups.size > 1, + dragDropState = dragDropState, + onExpandedChange = { expanded -> onExpandedChange(group.uid, expanded) }, + onSelectMode = { onSelectGroupMode(group.uid, it) }, + onAddConstraintClick = { onAddToGroupClick(group.uid) }, + onRenameClick = { onRenameGroupClick(group.uid) }, + onDeleteClick = { onRemoveGroupClick(group.uid) }, + onRemoveConstraintClick = onRemoveClick, + onFixConstraintClick = onFixErrorClick, + onNotClick = onNotClick, + onMoveUp = if (index > 0) { + { onMoveGroup(index, index - 1) } + } else { + null + }, + onMoveDown = if (index < orderedGroups.lastIndex) { + { onMoveGroup(index, index + 1) } + } else { + null + }, + ) + + if (index < orderedGroups.lastIndex) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(linkRowHeight), + contentAlignment = Alignment.Center, + ) { + Text( + text = linkText, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } else { + // Important! Keep the height of the item constant while dragging. + // If the height changes while dragging it can lead to janky behavior. + Spacer(Modifier.height(linkRowHeight)) + } + } + } + } + + if (state.shortcuts.isNotEmpty()) { item(key = "shortcuts", contentType = "shortcuts") { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = stringResource(R.string.recently_used_constraints), style = MaterialTheme.typography.titleSmall, ) + Spacer(Modifier.height(8.dp)) ShortcutRow( modifier = Modifier .fillMaxWidth() .padding(horizontal = 32.dp), - shortcuts = shortcuts, + shortcuts = state.shortcuts, onClick = { onClickShortcut(it) }, ) } @@ -318,35 +439,58 @@ private fun EmptyPreview() { } } -@Preview +@Preview(heightDp = 1200) @Composable private fun LoadedPreview() { KeyMapperTheme { val ctx = LocalContext.current + val flashlightConstraint = ConstraintListItemModel( + id = "1", + icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn), + text = "Flashlight is on", + isNot = true, + error = "Flashlight not found", + isErrorFixable = true, + ) + + val appConstraint = ConstraintListItemModel( + id = "2", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + text = "Key Mapper is in foreground", + ) + + val wifiConstraint = ConstraintListItemModel( + id = "3", + icon = ComposeIconInfo.Vector(Icons.Outlined.Wifi), + text = "Wi-Fi is on", + ) + ConstraintsScreen( state = State.Data( ConfigConstraintsState.Loaded( - constraintList = listOf( - ConstraintListItemModel( - id = "1", - icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn), - constraintModeLink = ConstraintMode.AND, - text = "Flashlight is on", - error = "Flashlight not found", - isErrorFixable = true, + groups = listOf( + ConstraintGroupListItemModel( + uid = "group1", + mode = ConstraintMode.AND, + constraints = listOf(flashlightConstraint, appConstraint), + description = "Flashlight is not on AND Key Mapper is in foreground", + ), + ConstraintGroupListItemModel( + uid = "group2", + name = "Foreground or wifi", + mode = ConstraintMode.OR, + constraints = listOf(appConstraint, wifiConstraint), + description = "Key Mapper is in foreground OR Wi-Fi is on", ), - ConstraintListItemModel( - id = "2", - icon = ComposeIconInfo.Drawable( - ctx.drawable(R.mipmap.ic_launcher_round), - ), - constraintModeLink = null, - text = "Key Mapper in foreground", - error = null, - isErrorFixable = true, + ConstraintGroupListItemModel( + uid = "group3", + mode = ConstraintMode.AND, + constraints = listOf(wifiConstraint), + description = "Wi-Fi is on", ), ), + mode = ConstraintMode.OR, shortcuts = setOf( ShortcutModel( icon = ComposeIconInfo.Vector(Icons.Rounded.FlashlightOn), @@ -354,7 +498,6 @@ private fun LoadedPreview() { data = ConstraintData.FlashlightOn(lens = CameraLens.BACK), ), ), - selectedMode = ConstraintMode.AND, ), ), ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintUseCase.kt index a9650ccbd5..dfff01cde4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/CreateConstraintUseCase.kt @@ -27,7 +27,7 @@ class CreateConstraintUseCaseImpl @Inject constructor( override fun isSupported(constraint: ConstraintId): KMError? { when (constraint) { - ConstraintId.FLASHLIGHT_ON, ConstraintId.FLASHLIGHT_OFF -> { + ConstraintId.FLASHLIGHT_ON -> { if (cameraAdapter.getFlashInfo(CameraLens.BACK) == null && cameraAdapter.getFlashInfo(CameraLens.FRONT) == null ) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt index a2eb148de6..bb722e7ee7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/DetectConstraintsUseCase.kt @@ -14,6 +14,7 @@ import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter import io.github.sds100.keymapper.system.lock.LockScreenAdapter import io.github.sds100.keymapper.system.media.MediaAdapter import io.github.sds100.keymapper.system.network.NetworkAdapter +import io.github.sds100.keymapper.system.notifications.NotificationAdapter import io.github.sds100.keymapper.system.phone.PhoneAdapter import io.github.sds100.keymapper.system.power.PowerAdapter import io.github.sds100.keymapper.system.volume.VolumeAdapter @@ -36,6 +37,7 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( private val powerAdapter: PowerAdapter, private val foldableAdapter: FoldableAdapter, private val volumeAdapter: VolumeAdapter, + private val notificationAdapter: NotificationAdapter, ) : DetectConstraintsUseCase { @AssistedFactory @@ -56,6 +58,7 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( powerAdapter, foldableAdapter, volumeAdapter, + notificationAdapter, ) override fun onDependencyChanged(dependency: ConstraintDependency): Flow { @@ -121,6 +124,9 @@ class DetectConstraintsUseCaseImpl @AssistedInject constructor( ConstraintDependency.NOTIFICATION_PANEL_STATE -> accessibilityService.isNotificationShadeExpanded.map { dependency } + ConstraintDependency.POSTED_NOTIFICATIONS -> + notificationAdapter.activeNotifications.map { dependency } + ConstraintDependency.DISPLAY_RESOLUTIONS -> displayAdapter.supportedResolutions.map { dependency } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcher.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcher.kt new file mode 100644 index 0000000000..6632749938 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcher.kt @@ -0,0 +1,33 @@ +package io.github.sds100.keymapper.base.constraints + +import io.github.sds100.keymapper.system.notifications.PostedNotification + +/** + * Matches a notification against a [ConstraintData.NotificationPosted]. + */ +object NotificationConstraintMatcher { + + fun matches( + notification: PostedNotification, + data: ConstraintData.NotificationPosted, + ): Boolean = when (data) { + is ConstraintData.NotificationPosted.FromApp -> + notification.packageName == data.packageName + + is ConstraintData.NotificationPosted.Title -> matchesText(notification.title, data) + + is ConstraintData.NotificationPosted.Text -> matchesText(notification.text, data) + } + + private fun matchesText( + fieldValue: String?, + data: ConstraintData.NotificationPosted.TextMatch, + ): Boolean { + fieldValue ?: return false + + return when (data.matchMode) { + TextMatchMode.CONTAINS -> fieldValue.contains(data.text, ignoreCase = true) + TextMatchMode.EXACT -> fieldValue.equals(data.text, ignoreCase = true) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationField.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationField.kt new file mode 100644 index 0000000000..22ea843cb6 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/NotificationField.kt @@ -0,0 +1,23 @@ +package io.github.sds100.keymapper.base.constraints + +enum class NotificationField { + PACKAGE, + TITLE, + TEXT, + ; + + /** + * Whether the value is free text that the user types rather than an app they pick. Only these + * fields have a [TextMatchMode]. + */ + val isFreeText: Boolean + get() = this != PACKAGE + + companion object { + fun of(data: ConstraintData.NotificationPosted): NotificationField = when (data) { + is ConstraintData.NotificationPosted.FromApp -> PACKAGE + is ConstraintData.NotificationPosted.Title -> TITLE + is ConstraintData.NotificationPosted.Text -> TEXT + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/constraints/TextMatchMode.kt b/base/src/main/java/io/github/sds100/keymapper/base/constraints/TextMatchMode.kt new file mode 100644 index 0000000000..21df90a481 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/constraints/TextMatchMode.kt @@ -0,0 +1,12 @@ +package io.github.sds100.keymapper.base.constraints + +import kotlinx.serialization.Serializable + +/** + * How the text the user entered is compared against the value of a notification field. + */ +@Serializable +enum class TextMatchMode { + CONTAINS, + EXACT, +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt index 01d28b35b1..c9d9dbb442 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/DetectKeyMapsUseCase.kt @@ -19,6 +19,8 @@ import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityServic import io.github.sds100.keymapper.base.system.navigation.OpenMenuHelper import io.github.sds100.keymapper.base.trigger.FingerprintTriggerKey import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.base.vibration.VibrateEffect +import io.github.sds100.keymapper.base.vibration.vibrate import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.dataOrNull import io.github.sds100.keymapper.data.Keys @@ -81,7 +83,10 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor( if (groupUid == null) { add( DetectKeyMapModel( - keyMap = keyMap, + // Disabled actions must never be performed. + keyMap = keyMap.copy( + actionList = keyMap.actionList.filter { it.isEnabled }, + ), groupConstraintStates = constraintStates, ), ) @@ -95,7 +100,7 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor( val group = groupMap[groupUid]!! groupUid = group.parentUid - if (group.constraintState.constraints.isNotEmpty()) { + if (group.constraintState.allConstraints.isNotEmpty()) { constraintStates.add(group.constraintState) } @@ -134,11 +139,6 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor( } } - override val keyMapsToTriggerFromOtherApps: Flow> = - allKeyMapList.map { keyMapList -> - keyMapList.filter { it.keyMap.trigger.triggerFromOtherApps }.map { it.keyMap } - }.flowOn(Dispatchers.Default) - override val defaultLongPressDelay: Flow = preferenceRepository.get(Keys.defaultLongPressDelay) .map { it ?: PreferenceDefaults.LONG_PRESS_DELAY } @@ -183,6 +183,10 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor( vibrator.vibrate(duration) } + override fun vibrate(effect: VibrateEffect) { + vibrator.vibrate(effect) + } + override fun imitateKeyEvent( keyCode: Int, metaState: Int, @@ -259,7 +263,6 @@ class DetectKeyMapsUseCaseImpl @AssistedInject constructor( interface DetectKeyMapsUseCase { val allKeyMapList: Flow> val requestFingerprintGestureDetection: Flow - val keyMapsToTriggerFromOtherApps: Flow> val defaultLongPressDelay: Flow val defaultDoublePressDelay: Flow @@ -270,6 +273,7 @@ interface DetectKeyMapsUseCase { fun showTriggeredToast() fun vibrate(duration: Long) + fun vibrate(effect: VibrateEffect) val currentTime: Long diff --git a/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt index 36959ac9b6..7f4af9c185 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/KeyMapAlgorithm.kt @@ -26,6 +26,7 @@ import io.github.sds100.keymapper.base.trigger.Trigger import io.github.sds100.keymapper.base.trigger.TriggerKey import io.github.sds100.keymapper.base.trigger.TriggerMode import io.github.sds100.keymapper.base.trigger.detectWithScancode +import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.models.EvdevDeviceInfo import io.github.sds100.keymapper.common.utils.minusFlag import io.github.sds100.keymapper.common.utils.withFlag @@ -800,7 +801,7 @@ class KeyMapAlgorithm( var awaitingLongPress = false var showToast = false val detectedShortPressTriggers = mutableSetOf() - val vibrateDurations = mutableListOf() + val vibrateEffects = mutableListOf() val errorSnapshot = performActionsUseCase.getErrorSnapshot() @@ -953,7 +954,7 @@ class KeyMapAlgorithm( awaitingLongPress = true if (trigger.vibrate && trigger.longPressDoubleVibration) { - vibrateDurations.add(vibrateDuration(trigger)) + vibrateEffects.add(resolveVibrateEffect(trigger)) } val oldJob = parallelTriggerLongPressJobs[triggerIndex] @@ -1033,13 +1034,14 @@ class KeyMapAlgorithm( showToast = true } - val vibrateDuration = when { - trigger.vibrate -> vibrateDuration(trigger) - forceVibrate.value -> defaultVibrateDuration.value - else -> -1L + val vibrateEffect = when { + trigger.vibrate -> resolveVibrateEffect(trigger) + forceVibrate.value -> + VibrateEffect.CustomDuration(defaultVibrateDuration.value) + else -> null } - vibrateDurations.add(vibrateDuration) + vibrateEffect?.let { vibrateEffects.add(it) } } } } @@ -1049,11 +1051,11 @@ class KeyMapAlgorithm( useCase.showTriggeredToast() } - if (vibrateDurations.isNotEmpty()) { + if (vibrateEffects.isNotEmpty()) { if (forceVibrate.value) { useCase.vibrate(defaultVibrateDuration.value) } else { - vibrateDurations.maxOrNull()?.let { + resolveWinningVibrateEffect(vibrateEffects)?.let { useCase.vibrate(it) } } @@ -1112,7 +1114,7 @@ class KeyMapAlgorithm( val detectedSequenceTriggerIndexes = mutableListOf() val detectedParallelTriggerIndexes = mutableListOf() - val vibrateDurations = mutableListOf() + val vibrateEffects = mutableListOf() val imitateKeyAfterDoublePressTimeout = mutableListOf() @@ -1254,7 +1256,7 @@ class KeyMapAlgorithm( triggerActions[triggerIndex].forEach { _ -> if (trigger.vibrate) { - vibrateDurations.add(vibrateDuration(trigger)) + vibrateEffects.add(resolveVibrateEffect(trigger)) } } @@ -1451,7 +1453,7 @@ class KeyMapAlgorithm( if (forceVibrate.value) { useCase.vibrate(defaultVibrateDuration.value) } else { - vibrateDurations.maxOrNull()?.let { + resolveWinningVibrateEffect(vibrateEffects)?.let { useCase.vibrate(it) } } @@ -1638,7 +1640,7 @@ class KeyMapAlgorithm( private fun performActionsOnFailedDoublePress(event: AlgoEvent): Boolean { var showToast = false val detectedTriggerIndexes = mutableListOf() - val vibrateDurations = mutableListOf() + val vibrateEffects = mutableListOf() performActionsOnFailedDoublePress.forEach { triggerIndex -> if (triggers[triggerIndex].keys.last().matchesEvent(event.withShortPress)) { @@ -1649,7 +1651,7 @@ class KeyMapAlgorithm( } if (triggers[triggerIndex].vibrate) { - vibrateDurations.add(vibrateDuration(triggers[triggerIndex])) + vibrateEffects.add(resolveVibrateEffect(triggers[triggerIndex])) } } } @@ -1672,7 +1674,7 @@ class KeyMapAlgorithm( if (forceVibrate.value) { useCase.vibrate(defaultVibrateDuration.value) } else { - vibrateDurations.maxOrNull()?.let { + resolveWinningVibrateEffect(vibrateEffects)?.let { useCase.vibrate(it) } } @@ -1737,7 +1739,7 @@ class KeyMapAlgorithm( forceVibrate.value || triggers[triggerIndex].longPressDoubleVibration ) { - useCase.vibrate(vibrateDuration(triggers[triggerIndex])) + useCase.vibrate(resolveVibrateEffect(triggers[triggerIndex])) } if (triggers[triggerIndex].showToast) { @@ -1773,7 +1775,7 @@ class KeyMapAlgorithm( forceVibrate.value || triggers[triggerIndex].longPressDoubleVibration ) { - useCase.vibrate(vibrateDuration(triggers[triggerIndex])) + useCase.vibrate(resolveVibrateEffect(triggers[triggerIndex])) } if (triggers[triggerIndex].showToast) { @@ -1884,8 +1886,18 @@ class KeyMapAlgorithm( private fun doublePressTimeout(trigger: Trigger): Long = trigger.doublePressDelay?.toLong() ?: defaultDoublePressDelay.value - private fun vibrateDuration(trigger: Trigger): Long = - trigger.vibrateDuration?.toLong() ?: defaultVibrateDuration.value + private fun resolveVibrateEffect(trigger: Trigger): VibrateEffect = + trigger.vibrateEffect ?: VibrateEffect.CustomDuration(defaultVibrateDuration.value) + + /** + * When several triggers fire at once, vibrate only once using the "strongest" of their + * effects: a predefined effect (a deliberate choice) wins over a plain duration, and + * amongst durations the longest one wins, matching the previous duration-only behaviour. + */ + private fun resolveWinningVibrateEffect(effects: List): VibrateEffect? = + effects.filterIsInstance().firstOrNull() + ?: effects.filterIsInstance() + .maxByOrNull { it.durationMs } private fun sequenceTriggerTimeout(trigger: Trigger): Long = trigger.sequenceTriggerTimeout?.toLong() ?: defaultSequenceTriggerTimeout.value diff --git a/base/src/main/java/io/github/sds100/keymapper/base/detection/SimpleMappingController.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/SimpleMappingController.kt index 4952391d4a..74f675d8eb 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/detection/SimpleMappingController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/SimpleMappingController.kt @@ -6,6 +6,7 @@ import io.github.sds100.keymapper.base.actions.RepeatMode import io.github.sds100.keymapper.base.constraints.DetectConstraintsUseCase import io.github.sds100.keymapper.base.constraints.isSatisfied import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.data.PreferenceDefaults import kotlinx.coroutines.CoroutineScope @@ -58,7 +59,7 @@ abstract class SimpleMappingController( if (!keyMap.isEnabled) return if (keyMap.actionList.isEmpty()) return - if (keyMap.constraintState.constraints.isNotEmpty()) { + if (keyMap.constraintState.allConstraints.isNotEmpty()) { val constraintSnapshot = detectConstraintsUseCase.getSnapshot() if (!constraintSnapshot.isSatisfied(keyMap.constraintState)) return } @@ -120,7 +121,8 @@ abstract class SimpleMappingController( if (keyMap.vibrate || forceVibrate.value) { detectMappingUseCase.vibrate( - keyMap.vibrateDuration?.toLong() ?: defaultVibrateDuration.value, + keyMap.vibrateEffect + ?: VibrateEffect.CustomDuration(defaultVibrateDuration.value), ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/detection/TriggerKeyMapFromOtherAppsController.kt b/base/src/main/java/io/github/sds100/keymapper/base/detection/TriggerKeyMapFromOtherAppsController.kt index 01755046ef..59dbc7a0b6 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/detection/TriggerKeyMapFromOtherAppsController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/detection/TriggerKeyMapFromOtherAppsController.kt @@ -23,9 +23,9 @@ class TriggerKeyMapFromOtherAppsController( init { coroutineScope.launch { - detectKeyMapsUseCase.keyMapsToTriggerFromOtherApps.collectLatest { keyMaps -> + detectKeyMapsUseCase.allKeyMapList.collectLatest { keyMaps -> reset() - this@TriggerKeyMapFromOtherAppsController.keyMapList = keyMaps + this@TriggerKeyMapFromOtherAppsController.keyMapList = keyMaps.map { it.keyMap } } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/Group.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/Group.kt index 0ca10b7535..815ee4cfdf 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/groups/Group.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/Group.kt @@ -1,8 +1,7 @@ package io.github.sds100.keymapper.base.groups -import io.github.sds100.keymapper.base.constraints.ConstraintEntityMapper -import io.github.sds100.keymapper.base.constraints.ConstraintModeEntityMapper import io.github.sds100.keymapper.base.constraints.ConstraintState +import io.github.sds100.keymapper.base.constraints.ConstraintStateEntityMapper import io.github.sds100.keymapper.data.entities.GroupEntity data class Group( @@ -15,28 +14,29 @@ data class Group( object GroupEntityMapper { fun fromEntity(entity: GroupEntity): Group { - val constraintList = - entity.constraintList.map { ConstraintEntityMapper.fromEntity(it) }.toSet() - - val constraintMode = ConstraintModeEntityMapper.fromEntity(entity.constraintMode) + val constraintState = ConstraintStateEntityMapper.fromEntity( + entity.constraintList, + entity.constraintMode, + ) return Group( uid = entity.uid, name = entity.name, - constraintState = ConstraintState(constraintList, constraintMode), + constraintState = constraintState, parentUid = entity.parentUid, lastOpenedDate = entity.lastOpenedDate ?: System.currentTimeMillis(), ) } fun toEntity(group: Group): GroupEntity { + val (constraintList, constraintMode) = + ConstraintStateEntityMapper.toEntity(group.constraintState) + return GroupEntity( uid = group.uid, name = group.name, - constraintList = group.constraintState.constraints.map { - ConstraintEntityMapper.toEntity(it) - }, - constraintMode = ConstraintModeEntityMapper.toEntity(group.constraintState.mode), + constraintList = constraintList, + constraintMode = constraintMode, parentUid = group.parentUid, lastOpenedDate = group.lastOpenedDate, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt index a38e78f492..0304f76cdf 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupConstraintRow.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -14,10 +13,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -30,15 +27,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.accompanist.drawablepainter.rememberDrawablePainter import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.constraints.ConstraintMode +import io.github.sds100.keymapper.base.utils.ui.compose.CompactChip import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.utils.ui.compose.ErrorCompactChip import io.github.sds100.keymapper.base.utils.ui.drawable import io.github.sds100.keymapper.common.utils.KMError @@ -50,11 +48,13 @@ fun GroupConstraintRow( parentConstraintCount: Int, onNewConstraintClick: () -> Unit = {}, onRemoveConstraintClick: (String) -> Unit = {}, - onFixConstraintClick: (KMError) -> Unit = {}, + onNotConstraintClick: (String) -> Unit = {}, enabled: Boolean = true, ) { val maxChipWidth = 300.dp + val chipColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f) + FlowRow( modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp), @@ -63,54 +63,72 @@ fun GroupConstraintRow( ) { for ((index, constraint) in constraints.withIndex()) { when (constraint) { - is ComposeChipModel.Normal -> - CompositionLocalProvider( - LocalContentColor provides MaterialTheme.colorScheme.onSurface, - ) { - ConstraintButton( - modifier = Modifier.widthIn(max = maxChipWidth), - text = constraint.text, - onRemoveClick = { onRemoveConstraintClick(constraint.id) }, - // Only allow clicking on error chips - enabled = enabled, - icon = { - if (constraint.icon is ComposeIconInfo.Vector) { - Icon( - modifier = Modifier - .size(24.dp) - .padding(end = 8.dp), - imageVector = constraint.icon.imageVector, - contentDescription = null, - ) - } else if (constraint.icon is ComposeIconInfo.Drawable) { - Icon( - modifier = Modifier - .size(24.dp) - .padding(end = 8.dp), - painter = rememberDrawablePainter( - constraint.icon.drawable, - ), - contentDescription = null, - tint = Color.Unspecified, - ) - } - }, - ) - } + is ComposeChipModel.Normal -> { + CompactChip( + modifier = Modifier.widthIn(max = maxChipWidth), + text = constraint.text, + containerColor = chipColor, + enabled = true, + onClick = { onNotConstraintClick(constraint.id) }, + icon = { + if (constraint.icon is ComposeIconInfo.Vector) { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + imageVector = constraint.icon.imageVector, + contentDescription = null, + ) + } else if (constraint.icon is ComposeIconInfo.Drawable) { + Icon( + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp), + painter = rememberDrawablePainter( + constraint.icon.drawable, + ), + contentDescription = null, + tint = Color.Unspecified, + ) + } + }, + actionContent = { + IconButton( + modifier = Modifier.size(16.dp), + onClick = { onRemoveConstraintClick(constraint.id) }, + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource( + R.string.home_group_delete_constraint_button, + ), + ) + } + }, + ) + } is ComposeChipModel.Error -> - CompositionLocalProvider( - LocalContentColor provides MaterialTheme.colorScheme.onErrorContainer, - ) { - ConstraintErrorButton( - modifier = Modifier.widthIn(max = maxChipWidth), - text = constraint.text, - onClick = { onFixConstraintClick(constraint.error) }, - onRemoveClick = { onRemoveConstraintClick(constraint.id) }, - // Only allow clicking on error chips - enabled = enabled, - ) - } + ErrorCompactChip( + modifier = Modifier.widthIn(max = maxChipWidth), + text = constraint.text, + containerColor = chipColor, + onClick = { onNotConstraintClick(constraint.id) }, + enabled = true, + actionContent = { + IconButton( + modifier = Modifier.size(16.dp), + onClick = { onRemoveConstraintClick(constraint.id) }, + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource( + R.string.home_group_delete_constraint_button, + ), + ) + } + }, + ) } if (index < constraints.lastIndex) { @@ -189,116 +207,6 @@ private fun NewConstraintButton( } } -@Composable -private fun ConstraintButton( - modifier: Modifier = Modifier, - text: String, - icon: @Composable () -> Unit, - onRemoveClick: () -> Unit = {}, - enabled: Boolean, -) { - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, - ) { - Surface( - modifier = modifier, - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), - ) { - Row( - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 8.dp) - .heightIn(min = 24.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - icon() - - Text( - modifier = Modifier.weight(1f, fill = false), - text = text, - maxLines = 1, - style = MaterialTheme.typography.titleSmall, - overflow = TextOverflow.Ellipsis, - ) - - Spacer(modifier = Modifier.width(4.dp)) - - IconButton( - modifier = Modifier.size(16.dp), - onClick = onRemoveClick, - enabled = enabled, - ) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource( - R.string.home_group_delete_constraint_button, - ), - ) - } - } - } - } -} - -@Composable -private fun ConstraintErrorButton( - modifier: Modifier = Modifier, - text: String, - onClick: () -> Unit, - onRemoveClick: () -> Unit = {}, - enabled: Boolean, -) { - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, - ) { - Surface( - modifier = modifier, - shape = MaterialTheme.shapes.small, - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f), - onClick = onClick, - enabled = enabled, - ) { - Row( - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 8.dp) - .heightIn(min = 24.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - modifier = Modifier - .size(24.dp) - .padding(end = 8.dp), - imageVector = Icons.Rounded.ErrorOutline, - contentDescription = null, - ) - - Text( - modifier = Modifier.weight(1f, fill = false), - text = text, - maxLines = 1, - style = MaterialTheme.typography.titleSmall, - overflow = TextOverflow.Ellipsis, - ) - - Spacer(modifier = Modifier.width(4.dp)) - - IconButton( - modifier = Modifier.size(16.dp), - onClick = onRemoveClick, - enabled = enabled, - ) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource( - R.string.home_group_delete_constraint_button, - ), - ) - } - } - } - } -} - @Preview @Composable private fun PreviewEmpty() { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupListItemModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupListItemModel.kt index 446bfffc55..6d7e0d5471 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupListItemModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupListItemModel.kt @@ -2,4 +2,10 @@ package io.github.sds100.keymapper.base.groups import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo -data class GroupListItemModel(val uid: String, val name: String, val icon: ComposeIconInfo? = null) +data class GroupListItemModel( + val uid: String, + val name: String, + val icon: ComposeIconInfo? = null, + val isEnabled: Boolean = true, + val isError: Boolean = false, +) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt index 11a7b1b197..e629bbc2a7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupRow.kt @@ -32,6 +32,7 @@ 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.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -135,13 +136,16 @@ fun GroupRow( onClick = { onGroupClick(group.uid) }, text = group.name, enabled = enabled, + isGroupEnabled = group.isEnabled, + isGroupError = group.isError, icon = { when (group.icon) { is ComposeIconInfo.Drawable -> { Icon( modifier = Modifier .size(24.dp) - .padding(end = 8.dp), + .padding(end = 8.dp) + .alpha(if (group.isEnabled) 1.0f else 0.38f), painter = rememberDrawablePainter(group.icon.drawable), contentDescription = null, tint = Color.Unspecified, @@ -275,29 +279,54 @@ private fun GroupButton( text: String, icon: @Composable () -> Unit, enabled: Boolean, + isGroupEnabled: Boolean = true, + isGroupError: Boolean = false, ) { - Surface( - modifier = modifier, - onClick = onClick, - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f), - enabled = enabled, - ) { - Row( - modifier = Modifier - .padding(vertical = 6.dp, horizontal = 12.dp) - .heightIn(min = 24.dp), - verticalAlignment = Alignment.CenterVertically, + val containerColor: Color + val contentColor: Color + + when { + // If the group is disabled then never show the error color. + !isGroupEnabled -> { + containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } + + isGroupError -> { + containerColor = MaterialTheme.colorScheme.errorContainer + contentColor = MaterialTheme.colorScheme.onErrorContainer + } + + else -> { + containerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) + contentColor = MaterialTheme.colorScheme.onSurface + } + } + + CompositionLocalProvider(LocalContentColor provides contentColor) { + Surface( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = containerColor, + enabled = enabled, ) { - icon() + Row( + modifier = Modifier + .padding(vertical = 6.dp, horizontal = 12.dp) + .heightIn(min = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + icon() - Text( - text = text, - maxLines = 1, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - overflow = TextOverflow.Ellipsis, - ) + Text( + text = text, + maxLines = 1, + style = MaterialTheme.typography.titleSmall, + color = contentColor, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -322,6 +351,85 @@ private fun PreviewEmptyDisabled() { } } +@Preview +@Composable +private fun PreviewDisabledGroup() { + KeyMapperTheme { + Surface { + GroupRow( + groups = listOf( + GroupListItemModel( + uid = "1", + name = "Torch", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + isEnabled = false, + ), + ), + ) + } + } +} + +@Preview +@Composable +private fun PreviewDisabledGroupDrawable() { + val ctx = LocalContext.current + + KeyMapperTheme { + Surface { + GroupRow( + groups = listOf( + GroupListItemModel( + uid = "1", + name = "Torch", + icon = ComposeIconInfo.Drawable(ctx.drawable(R.mipmap.ic_launcher_round)), + isEnabled = false, + ), + ), + ) + } + } +} + +@Preview +@Composable +private fun PreviewErrorGroup() { + KeyMapperTheme { + Surface { + GroupRow( + groups = listOf( + GroupListItemModel( + uid = "1", + name = "Torch", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + isError = true, + ), + ), + ) + } + } +} + +@Preview +@Composable +private fun PreviewDisabledErrorGroup() { + KeyMapperTheme { + Surface { + GroupRow( + groups = listOf( + GroupListItemModel( + uid = "1", + name = "Torch", + icon = ComposeIconInfo.Vector(Icons.Outlined.Lock), + isEnabled = false, + isError = true, + ), + ), + ) + } + } +} + @Preview @Composable private fun PreviewOneItem() { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupWithState.kt b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupWithState.kt new file mode 100644 index 0000000000..aad7b05c7d --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/groups/GroupWithState.kt @@ -0,0 +1,3 @@ +package io.github.sds100.keymapper.base.groups + +data class GroupWithState(val group: Group, val isEnabled: Boolean, val isError: Boolean) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/FixErrorsDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/FixErrorsDialog.kt new file mode 100644 index 0000000000..a73b71caf7 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/FixErrorsDialog.kt @@ -0,0 +1,152 @@ +package io.github.sds100.keymapper.base.home + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.trigger.TriggerError +import io.github.sds100.keymapper.base.utils.ui.compose.CustomDialog +import io.github.sds100.keymapper.common.utils.KMError + +data class FixErrorsDialogState(val errors: List) + +sealed class FixErrorsDialogId { + data class Group(val uid: String) : FixErrorsDialogId() + data class KeyMap(val uid: String) : FixErrorsDialogId() +} + +@Composable +fun FixErrorsDialog( + state: FixErrorsDialogState, + onFixClick: (KeyMapError) -> Unit, + onDismissRequest: () -> Unit, +) { + CustomDialog( + title = stringResource(R.string.dialog_title_home_fix_error), + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.pos_done)) + } + }, + onDismissRequest = onDismissRequest, + ) { + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + ErrorSection( + title = stringResource(R.string.trigger_header), + errors = state.errors.filterIsInstance(), + onFixClick = onFixClick, + ) + ErrorSection( + title = stringResource(R.string.action_list_header), + errors = state.errors.filterIsInstance(), + onFixClick = onFixClick, + ) + ErrorSection( + title = stringResource(R.string.constraint_list_header), + errors = state.errors.filterIsInstance(), + onFixClick = onFixClick, + ) + } + } +} + +@Composable +private fun ErrorSection( + title: String, + errors: List, + onFixClick: (KeyMapError) -> Unit, +) { + if (errors.isEmpty()) { + return + } + + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = title, + style = MaterialTheme.typography.titleSmall, + ) + + for (item in errors) { + ErrorListItem( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + text = item.message, + isFixable = item.isFixable, + onFixClick = { onFixClick(item) }, + ) + } +} + +@Composable +private fun ErrorListItem( + modifier: Modifier = Modifier, + text: String, + isFixable: Boolean, + onFixClick: () -> Unit = {}, +) { + Row(modifier = modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + modifier = Modifier.weight(1f), + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.width(8.dp)) + FilledTonalButton( + onClick = onFixClick, + enabled = isFixable, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Text(text = stringResource(R.string.button_fix)) + } + } +} + +@Preview(showSystemUi = true) +@Composable +private fun PreviewFixErrorsDialog() { + KeyMapperTheme { + FixErrorsDialog( + state = FixErrorsDialogState( + errors = listOf( + KeyMapError.Trigger( + TriggerError.DND_ACCESS_DENIED, + "Grant Key Mapper access to Do Not Disturb", + ), + KeyMapError.Action( + KMError.NoCompatibleImeChosen, + "Choose a compatible keyboard", + isFixable = true, + ), + KeyMapError.Constraint( + KMError.AppNotFound("com.example.app"), + "App not found", + isFixable = false, + ), + ), + ), + onFixClick = {}, + onDismissRequest = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt index dc04ed1056..418b72d2ff 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/HomeKeyMapListScreen.kt @@ -66,7 +66,6 @@ import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.groups.GroupListItemModel import io.github.sds100.keymapper.base.sorting.SortBottomSheet import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel -import io.github.sds100.keymapper.base.trigger.TriggerError import io.github.sds100.keymapper.base.utils.ShareUtils import io.github.sds100.keymapper.base.utils.ui.compose.CollapsableFloatingActionButton import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel @@ -136,6 +135,16 @@ fun HomeKeyMapListScreen( ) } + val fixErrorsDialogState by viewModel.fixErrorsDialogState.collectAsStateWithLifecycle() + + fixErrorsDialogState?.let { dialogState -> + FixErrorsDialog( + state = dialogState, + onFixClick = viewModel::onFixErrorClick, + onDismissRequest = viewModel::onDismissFixErrorsDialog, + ) + } + val fixKeyEventActionState by viewModel.fixKeyEventActionState.collectAsStateWithLifecycle() if (fixKeyEventActionState != null) { @@ -202,8 +211,9 @@ fun HomeKeyMapListScreen( onGroupClick = viewModel::onGroupClick, onNewConstraintClick = viewModel::onNewGroupConstraintClick, onRemoveConstraintClick = viewModel::onRemoveGroupConstraintClick, + onNotConstraintClick = viewModel::onNotGroupConstraintClick, onConstraintModeChanged = viewModel::onGroupConstraintModeChanged, - onFixConstraintClick = viewModel::onFixClick, + onFixClick = viewModel::onFixGroupConstraintsClick, onKeyMapsEnabledChange = viewModel::onGroupKeyMapsEnabledChanged, ) }, @@ -213,7 +223,6 @@ fun HomeKeyMapListScreen( onLongClickKeyMap = viewModel::onKeyMapCardLongClick, onSelectedChange = viewModel::onKeyMapSelectedChanged, onFixClick = viewModel::onFixClick, - onTriggerErrorClick = viewModel::onFixTriggerError, bottomListPadding = keyMapListBottomPadding, ) }, @@ -499,8 +508,8 @@ fun sameKeyMapListItems(): List { ), ), options = listOf("Vibrate"), - triggerErrors = listOf(TriggerError.DND_ACCESS_DENIED), - extraInfo = "Disabled • No trigger", + isEnabled = true, + hasError = true, ), ), KeyMapListItemModel( @@ -532,8 +541,8 @@ fun sameKeyMapListItems(): List { "Vibrate", "Vibrate when keys are initially pressed and again when long pressed", ), - triggerErrors = emptyList(), - extraInfo = null, + isEnabled = true, + hasError = false, ), ), KeyMapListItemModel( @@ -549,6 +558,7 @@ fun sameKeyMapListItems(): List { drawable = context.drawable(R.drawable.ic_launcher_web), ), "Open Key Mapper", + isEnabled = false, ), ), constraintMode = ConstraintMode.AND, @@ -559,11 +569,12 @@ fun sameKeyMapListItems(): List { drawable = context.drawable(R.drawable.ic_launcher_web), ), "Key Mapper is not open", + isEnabled = false, ), ), options = emptyList(), - triggerErrors = emptyList(), - extraInfo = null, + isEnabled = false, + hasError = false, ), ), KeyMapListItemModel( @@ -584,8 +595,8 @@ fun sameKeyMapListItems(): List { constraintMode = ConstraintMode.AND, constraints = emptyList(), options = emptyList(), - triggerErrors = emptyList(), - extraInfo = null, + isEnabled = true, + hasError = false, ), ), KeyMapListItemModel( @@ -598,8 +609,8 @@ fun sameKeyMapListItems(): List { constraintMode = ConstraintMode.OR, constraints = emptyList(), options = emptyList(), - triggerErrors = emptyList(), - extraInfo = "Disabled • No trigger", + isEnabled = true, + hasError = false, ), ), ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapError.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapError.kt new file mode 100644 index 0000000000..dd1294503c --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapError.kt @@ -0,0 +1,25 @@ +package io.github.sds100.keymapper.base.home + +import io.github.sds100.keymapper.base.trigger.TriggerError +import io.github.sds100.keymapper.common.utils.KMError + +sealed class KeyMapError { + abstract val message: String + abstract val isFixable: Boolean + + data class Trigger(val error: TriggerError, override val message: String) : KeyMapError() { + override val isFixable: Boolean = error.isFixable + } + + data class Action( + val error: KMError, + override val message: String, + override val isFixable: Boolean, + ) : KeyMapError() + + data class Constraint( + val error: KMError, + override val message: String, + override val isFixable: Boolean, + ) : KeyMapError() +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapGroup.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapGroup.kt index 79d33b8888..4399416ee3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapGroup.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapGroup.kt @@ -1,12 +1,13 @@ package io.github.sds100.keymapper.base.home import io.github.sds100.keymapper.base.groups.Group +import io.github.sds100.keymapper.base.groups.GroupWithState import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.common.utils.State data class KeyMapGroup( val group: Group?, - val subGroups: List, + val subGroups: List, val parents: List, val keyMaps: State>, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt index 6e4745b152..56d9958e33 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListAppBar.kt @@ -30,8 +30,8 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Sort -import androidx.compose.material.icons.outlined.NewReleases import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.Campaign import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Done import androidx.compose.material.icons.rounded.Edit @@ -135,6 +135,7 @@ fun KeyMapListAppBar( var expandedDropdown by rememberSaveable { mutableStateOf(false) } AppBarActions( + showWhatsNew = true, onWhatsNewClick, onMenuClick = { expandedDropdown = true }, dropdownMenuContent = { @@ -246,6 +247,7 @@ fun KeyMapListAppBar( var expandedDropdown by rememberSaveable { mutableStateOf(false) } AppBarActions( + showWhatsNew = false, onWhatsNewClick, onMenuClick = { expandedDropdown = true }, dropdownMenuContent = { @@ -410,16 +412,19 @@ private fun SelectingAppBar( @Composable private fun AppBarActions( + showWhatsNew: Boolean, onWhatsNewClick: () -> Unit, onMenuClick: () -> Unit = {}, dropdownMenuContent: @Composable () -> Unit, ) { Row { - IconButton(onClick = onWhatsNewClick) { - Icon( - Icons.Outlined.NewReleases, - contentDescription = stringResource(R.string.home_app_bar_whats_new), - ) + if (showWhatsNew) { + IconButton(onClick = onWhatsNewClick) { + Icon( + Icons.Rounded.Campaign, + contentDescription = stringResource(R.string.home_app_bar_whats_new), + ) + } } IconButton(onClick = onMenuClick) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt index 20d1b27073..b67df2198e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListHeader.kt @@ -10,19 +10,18 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Lock import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -34,17 +33,19 @@ import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.constraints.ConstraintMode +import io.github.sds100.keymapper.base.constraints.ConstraintModeButtons import io.github.sds100.keymapper.base.groups.GroupBreadcrumbRow import io.github.sds100.keymapper.base.groups.GroupConstraintRow import io.github.sds100.keymapper.base.groups.GroupListItemModel import io.github.sds100.keymapper.base.groups.GroupRow +import io.github.sds100.keymapper.base.utils.ui.compose.CompactErrorButton import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo -import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText import io.github.sds100.keymapper.base.utils.ui.drawable import io.github.sds100.keymapper.common.utils.KMError @@ -65,8 +66,9 @@ fun KeyMapListHeader( onGroupClick: (String?) -> Unit = {}, onNewConstraintClick: () -> Unit = {}, onRemoveConstraintClick: (String) -> Unit = {}, + onNotConstraintClick: (String) -> Unit = {}, onConstraintModeChanged: (ConstraintMode) -> Unit = {}, - onFixConstraintClick: (KMError) -> Unit = {}, + onFixClick: () -> Unit = {}, onKeyMapsEnabledChange: (Boolean) -> Unit = {}, ) { // This is taken from the AppBar color code so the header is the same color as the app bar @@ -109,8 +111,9 @@ fun KeyMapListHeader( onGroupClick = onGroupClick, onNewConstraintClick = onNewConstraintClick, onRemoveConstraintClick = onRemoveConstraintClick, + onNotConstraintClick = onNotConstraintClick, onConstraintModeChanged = onConstraintModeChanged, - onFixConstraintClick = onFixConstraintClick, + onFixClick = onFixClick, onKeyMapsEnabledChange = onKeyMapsEnabledChange, ) @@ -164,8 +167,9 @@ private fun ChildGroupHeader( onGroupClick: (String?) -> Unit, onNewConstraintClick: () -> Unit, onRemoveConstraintClick: (String) -> Unit, + onNotConstraintClick: (String) -> Unit, onConstraintModeChanged: (ConstraintMode) -> Unit, - onFixConstraintClick: (KMError) -> Unit, + onFixClick: () -> Unit, onKeyMapsEnabledChange: (Boolean) -> Unit, ) { val enabled = !state.isEditingGroupName @@ -177,7 +181,27 @@ private fun ChildGroupHeader( color = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ) { - Column { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (state.constraints.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.home_group_invert_constraint_hint), + style = MaterialTheme.typography.labelMedium, + ) + + if (state.constraints.any { it is ComposeChipModel.Error }) { + CompactErrorButton(onClick = onFixClick) { + Text(text = stringResource(R.string.button_fix)) + } + } + } + } + GroupConstraintRow( modifier = Modifier .padding(horizontal = 8.dp) @@ -185,14 +209,12 @@ private fun ChildGroupHeader( constraints = state.constraints, mode = state.constraintMode, parentConstraintCount = state.parentConstraintCount, - onFixConstraintClick = onFixConstraintClick, onNewConstraintClick = onNewConstraintClick, onRemoveConstraintClick = onRemoveConstraintClick, + onNotConstraintClick = onNotConstraintClick, enabled = enabled, ) - Spacer(Modifier.height(8.dp)) - Row( modifier = Modifier .fillMaxWidth() @@ -200,29 +222,20 @@ private fun ChildGroupHeader( horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { + val modeColors = SegmentedButtonDefaults.colors( + activeContainerColor = MaterialTheme.colorScheme.primary, + activeContentColor = MaterialTheme.colorScheme.onPrimary, + activeBorderColor = MaterialTheme.colorScheme.primary, + inactiveBorderColor = MaterialTheme.colorScheme.primary, + inactiveContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + AnimatedVisibility(visible = state.constraints.size > 1) { Row(verticalAlignment = Alignment.CenterVertically) { - RadioButtonText( - text = stringResource(R.string.constraint_mode_and), - isSelected = state.constraintMode == ConstraintMode.AND, - isEnabled = enabled, - onSelected = { - onConstraintModeChanged(ConstraintMode.AND) - }, - ) - - RadioButtonText( - text = stringResource(R.string.constraint_mode_or), - isSelected = state.constraintMode == ConstraintMode.OR, - isEnabled = enabled, - onSelected = { - onConstraintModeChanged(ConstraintMode.OR) - }, - ) - - VerticalDivider( - modifier = Modifier.height(24.dp), - color = MaterialTheme.colorScheme.onPrimaryContainer, + ConstraintModeButtons( + mode = state.constraintMode, + onSelectMode = onConstraintModeChanged, + colors = modeColors, ) } } @@ -374,7 +387,7 @@ private fun RootGroupHeaderNoWarningsPreview() { } @OptIn(ExperimentalMaterial3Api::class) -@Preview +@PreviewLightDark @Composable private fun ChildGroupHeaderPreview() { val state = KeyMapAppBarState.ChildGroup( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt new file mode 100644 index 0000000000..5475cecad3 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItem.kt @@ -0,0 +1,420 @@ +package io.github.sds100.keymapper.base.home + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.constraints.ConstraintMode +import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel +import io.github.sds100.keymapper.base.utils.ui.compose.CompactChip +import io.github.sds100.keymapper.base.utils.ui.compose.CompactErrorButton +import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel +import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo +import io.github.sds100.keymapper.base.utils.ui.compose.ErrorCompactChip + +@Composable +fun KeyMapListItem( + modifier: Modifier = Modifier, + isSelectable: Boolean, + model: KeyMapListItemModel, + onClickKeyMap: () -> Unit, + onLongClickKeyMap: () -> Unit, + onSelectedChange: (Boolean) -> Unit, + onFixClick: () -> Unit, +) { + val cardColors = if (model.content.isEnabled) { + CardDefaults.outlinedCardColors() + } else { + CardDefaults.outlinedCardColors( + contentColor = + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + } + + OutlinedCard( + modifier = modifier, + colors = cardColors, + onClick = onClickKeyMap, + ) { + Row( + modifier = Modifier.combinedClickable( + onClick = onClickKeyMap, + onLongClick = onLongClickKeyMap, + ), + ) { + if (isSelectable) { + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + Checkbox( + modifier = Modifier + .padding(start = 16.dp) + .align(Alignment.CenterVertically), + checked = model.isSelected, + onCheckedChange = onSelectedChange, + ) + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 10.dp), + ) { + Row( + modifier = Modifier.heightIn(min = chipHeight), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TriggerDescription( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + triggerKeys = model.content.triggerKeys, + separator = model.content.triggerSeparatorIcon, + ) + + if (model.content.isEnabled && model.content.hasError) { + CompactErrorButton(onClick = onFixClick) { + Text(stringResource(R.string.button_fix)) + } + } + } + + if (model.content.actions.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + itemVerticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy( + 8.dp, + alignment = Alignment.CenterVertically, + ), + ) { + Text( + text = stringResource(R.string.action_list_header), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + for (chipModel in model.content.actions) { + ActionConstraintChip( + chipModel, + onFixClick = onFixClick, + ) + } + } + } + + if (model.content.constraints.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + itemVerticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy( + 8.dp, + alignment = Alignment.CenterVertically, + ), + ) { + Text( + text = stringResource(R.string.constraint_list_header), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + for ((index, chipModel) in model.content.constraints.withIndex()) { + ActionConstraintChip( + chipModel, + onFixClick = onFixClick, + ) + + if (index < model.content.constraints.lastIndex) { + when (model.content.constraintMode) { + ConstraintMode.AND -> Text( + text = stringResource(R.string.constraint_mode_and), + style = MaterialTheme.typography.labelMedium, + ) + + ConstraintMode.OR -> Text( + text = stringResource(R.string.constraint_mode_or), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } + } + + if (model.content.options.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.heightIn(min = chipHeight), + verticalAlignment = Alignment.CenterVertically, + ) { + OptionsDescription( + modifier = Modifier.fillMaxWidth(), + options = model.content.options, + ) + } + } + } + } + } +} + +val chipHeight = 28.dp + +@Composable +private fun TriggerDescription( + modifier: Modifier = Modifier, + triggerKeys: List, + separator: ImageVector, +) { + val text = buildAnnotatedString { + pushStyle( + MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold).toSpanStyle(), + ) + append(stringResource(R.string.trigger_header)) + pop() + append(" ") + + if (triggerKeys.isEmpty()) { + append(stringResource(R.string.trigger_header_none)) + } else { + for ((index, key) in triggerKeys.withIndex()) { + append(key) + + if (index < triggerKeys.lastIndex) { + append(" ") + appendInlineContent("separator") + append(" ") + } + } + } + } + + val inlineContent = mapOf( + "separator" to InlineTextContent( + placeholder = Placeholder( + width = 14.sp, + height = 14.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon(imageVector = separator, contentDescription = null) + }, + ) + + Text( + modifier = modifier, + text = text, + inlineContent = inlineContent, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun OptionsDescription(modifier: Modifier = Modifier, options: List) { + val dot = stringResource(R.string.middot) + val text = buildAnnotatedString { + pushStyle( + MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold).toSpanStyle(), + ) + append(stringResource(R.string.option_list_header)) + pop() + append(" ") + + for ((index, option) in options.withIndex()) { + append(option) + + if (index < options.lastIndex) { + append(" $dot ") + } + } + } + + Text( + modifier = modifier, + text = text, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun ActionConstraintChip(model: ComposeChipModel, onFixClick: () -> Unit) { + when (model) { + is ComposeChipModel.Normal -> { + // Grey out disabled chips using the Material 3 alpha for disabled content. + val contentAlpha = if (model.isEnabled) 1f else 0.38f + + CompactChip( + text = model.text, + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha), + icon = model.icon?.let { icon -> + { + when (icon) { + is ComposeIconInfo.Drawable -> Icon( + modifier = Modifier + .alpha(contentAlpha) + .fillMaxHeight(), + painter = rememberDrawablePainter(icon.drawable), + contentDescription = null, + tint = Color.Unspecified, + ) + + is ComposeIconInfo.Vector -> Icon( + modifier = Modifier + .fillMaxHeight(), + imageVector = icon.imageVector, + contentDescription = null, + ) + } + } + }, + ) + } + + is ComposeChipModel.Error -> ErrorCompactChip( + onClick = onFixClick, + text = model.text, + enabled = model.isFixable, + ) + } +} + +@PreviewLightDark +@Composable +private fun FullContentPreview() { + KeyMapperTheme { + KeyMapListItem( + isSelectable = false, + model = sameKeyMapListItems()[0], + onClickKeyMap = {}, + onLongClickKeyMap = {}, + onSelectedChange = {}, + onFixClick = {}, + ) + } +} + +@Preview +@Composable +private fun MultilineOptionsPreview() { + KeyMapperTheme { + KeyMapListItem( + isSelectable = false, + model = sameKeyMapListItems()[1], + onClickKeyMap = {}, + onLongClickKeyMap = {}, + onSelectedChange = {}, + onFixClick = {}, + ) + } +} + +@Preview +@Composable +private fun DisabledPreview() { + KeyMapperTheme { + KeyMapListItem( + isSelectable = false, + model = sameKeyMapListItems()[2], + onClickKeyMap = {}, + onLongClickKeyMap = {}, + onSelectedChange = {}, + onFixClick = {}, + ) + } +} + +@Preview +@Composable +private fun MinimalPreview() { + KeyMapperTheme { + KeyMapListItem( + isSelectable = false, + model = sameKeyMapListItems()[3], + onClickKeyMap = {}, + onLongClickKeyMap = {}, + onSelectedChange = {}, + onFixClick = {}, + ) + } +} + +@Preview +@Composable +private fun EmptyTriggerPreview() { + KeyMapperTheme { + KeyMapListItem( + isSelectable = false, + model = sameKeyMapListItems()[4], + onClickKeyMap = {}, + onLongClickKeyMap = {}, + onSelectedChange = {}, + onFixClick = {}, + ) + } +} + +@Preview +@Composable +private fun SelectableCheckedPreview() { + KeyMapperTheme { + KeyMapListItem( + isSelectable = true, + model = sameKeyMapListItems()[0], + onClickKeyMap = {}, + onLongClickKeyMap = {}, + onSelectedChange = {}, + onFixClick = {}, + ) + } +} + +@Preview +@Composable +private fun SelectableUncheckedPreview() { + KeyMapperTheme { + KeyMapListItem( + isSelectable = true, + model = sameKeyMapListItems()[4], + onClickKeyMap = {}, + onLongClickKeyMap = {}, + onSelectedChange = {}, + onFixClick = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt index 929038eb47..ab3735aa07 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListItemCreator.kt @@ -6,7 +6,10 @@ import androidx.compose.material.icons.outlined.Add import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.ActionErrorSnapshot import io.github.sds100.keymapper.base.actions.ActionUiHelper +import io.github.sds100.keymapper.base.constraints.Constraint import io.github.sds100.keymapper.base.constraints.ConstraintErrorSnapshot +import io.github.sds100.keymapper.base.constraints.ConstraintGroup +import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.constraints.ConstraintState import io.github.sds100.keymapper.base.constraints.ConstraintUiHelper import io.github.sds100.keymapper.base.keymaps.ClickType @@ -23,6 +26,7 @@ import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel import io.github.sds100.keymapper.base.trigger.Trigger import io.github.sds100.keymapper.base.trigger.TriggerErrorSnapshot +import io.github.sds100.keymapper.base.trigger.TriggerKey import io.github.sds100.keymapper.base.trigger.TriggerMode import io.github.sds100.keymapper.base.trigger.getCodeLabel import io.github.sds100.keymapper.base.utils.isFixable @@ -63,6 +67,7 @@ class KeyMapListItemCreator( is KeyEventTriggerKey -> keyEventTriggerKeyName( key, + keyMap.trigger.keys, showDeviceDescriptors, ) @@ -77,38 +82,31 @@ class KeyMapListItemCreator( val options = getTriggerOptionLabels(keyMap.trigger) val actionChipList = getActionChipList(keyMap, showDeviceDescriptors, actionErrorSnapshot) - val constraintChipList = - buildConstraintChipList(keyMap.constraintState, constraintErrorSnapshot) - - val extraInfo = buildString { - append(createExtraInfoString(keyMap, actionChipList, constraintChipList)) - - if (keyMap.trigger.keys.isEmpty()) { - if (this.isNotEmpty()) { - append(" $midDot ") - } + val (constraintChipList, constraintMode) = + buildConstraintChipList( + keyMap.constraintState, + constraintErrorSnapshot, + isEnabled = keyMap.isEnabled, + ) - append(getString(R.string.no_trigger)) - } - } + val hasTriggerError = + keyMap.trigger.keys.any { triggerErrorSnapshot.getTriggerError(keyMap, it) != null } - val triggerErrors = keyMap.trigger.keys.mapNotNull { key -> - triggerErrorSnapshot.getTriggerError( - keyMap, - key, - ) - }.distinct() + val hasError: Boolean = + hasTriggerError || + actionChipList.any { it is ComposeChipModel.Error } || + constraintChipList.any { it is ComposeChipModel.Error } return KeyMapListItemModel.Content( uid = keyMap.uid, triggerKeys = triggerKeys, triggerSeparatorIcon = triggerSeparator, - triggerErrors = triggerErrors, actions = actionChipList, constraints = constraintChipList, - constraintMode = keyMap.constraintState.mode, + constraintMode = constraintMode, options = options, - extraInfo = extraInfo.takeIf { it.isNotBlank() }, + isEnabled = keyMap.isEnabled, + hasError = hasError, ) } @@ -121,18 +119,17 @@ class KeyMapListItemCreator( ): List = sequence { val midDot = getString(R.string.middot) - val actionErrors = errorSnapshot.getErrors(keyMap.actionList.map { it.data }) + val actionErrors = if (keyMap.isEnabled) { + errorSnapshot.getErrors(keyMap.actionList.map { it.data }) + } else { + emptyMap() + } for (action in keyMap.actionList) { val actionTitle: String = if (action.multiplier != null) { - "${action.multiplier}x ${ - actionUiHelper.getTitle( - action.data, - showDeviceDescriptors, - ) - }" + "${action.multiplier}x ${actionUiHelper.getTitle(action, showDeviceDescriptors)}" } else { - actionUiHelper.getTitle(action.data, showDeviceDescriptors) + actionUiHelper.getTitle(action, showDeviceDescriptors) } val chipText = buildString { @@ -153,7 +150,7 @@ class KeyMapListItemCreator( append( getString( - R.string.action_title_wait, + R.string.action_title_wait_ms, action.delayBeforeNextAction, ), ) @@ -163,8 +160,14 @@ class KeyMapListItemCreator( val icon: ComposeIconInfo = actionUiHelper.getIcon(action.data) val error: KMError? = actionErrors[action.data] - val chip = if (error == null) { - ComposeChipModel.Normal(id = action.uid, text = chipText, icon = icon) + // Disabled actions or key maps are never performed so do not show their errors. + val chip = if (error == null || !keyMap.isEnabled || !action.isEnabled) { + ComposeChipModel.Normal( + id = action.uid, + text = chipText, + icon = icon, + isEnabled = keyMap.isEnabled && action.isEnabled, + ) } else { ComposeChipModel.Error(action.uid, chipText, error, isFixable = error.isFixable) } @@ -176,59 +179,112 @@ class KeyMapListItemCreator( fun buildConstraintChipList( constraintState: ConstraintState, errorSnapshot: ConstraintErrorSnapshot, - ): List = sequence { - for (constraint in constraintState.constraints) { - val text: String = constraintUiHelper.getTitle(constraint) - val icon: ComposeIconInfo = constraintUiHelper.getIcon(constraint) - val error: KMError? = errorSnapshot.getError(constraint) - - val chip: ComposeChipModel = if (error == null) { - ComposeChipModel.Normal( - id = constraint.uid, - text = text, - icon = icon, - ) - } else { - ComposeChipModel.Error(constraint.uid, text, error, error.isFixable) - } - - yield(chip) + isEnabled: Boolean, + ): Pair, ConstraintMode> { + if (constraintState.groups.isEmpty()) { + return Pair(emptyList(), constraintState.mode) } - }.toList() - private fun createExtraInfoString( - keyMap: KeyMap, - actionChipList: List, - constraintChipList: List, - ) = buildString { - val midDot by lazy { getString(R.string.middot) } + if (constraintState.groups.size == 1) { + // If only one group then show the list of constraints as normal + val firstGroup = constraintState.groups[0] + val chips = + firstGroup.constraints.map { buildConstraintChip(it, errorSnapshot, isEnabled) } - if (!keyMap.isEnabled) { - append(getString(R.string.switch_disabled)) + return Pair(chips, firstGroup.mode) + } else { + val chips = + constraintState.groups.mapNotNull { + buildGroupConstraintChip( + it, + errorSnapshot, + isEnabled, + ) + } + + return Pair(chips, constraintState.mode) } + } - if (actionChipList.any { it is ComposeChipModel.Error }) { - if (this.isNotEmpty()) { - append(" $midDot ") - } + private fun buildConstraintChip( + constraint: Constraint, + errorSnapshot: ConstraintErrorSnapshot, + isEnabled: Boolean, + ): ComposeChipModel { + val text: String = constraintUiHelper.getTitle(constraint) + val icon: ComposeIconInfo = constraintUiHelper.getIcon(constraint) + val error: KMError? = errorSnapshot.getError(constraint) + + // Constraints for disabled key maps are never checked so do not show their errors. + val chip: ComposeChipModel = if (error == null || !isEnabled) { + ComposeChipModel.Normal( + id = constraint.uid, + text = text, + icon = icon, + isEnabled = isEnabled, + ) + } else { + ComposeChipModel.Error( + constraint.uid, + text, + error, + error.isFixable, + ) + } + return chip + } - append(getString(R.string.tap_actions_to_fix)) + private fun buildGroupConstraintChip( + group: ConstraintGroup, + errorSnapshot: ConstraintErrorSnapshot, + isEnabled: Boolean, + ): ComposeChipModel? { + if (group.constraints.isEmpty()) { + return null } - if (constraintChipList.any { it is ComposeChipModel.Error }) { - if (this.isNotEmpty()) { - append(" $midDot ") + if (group.constraints.size == 1) { + return buildConstraintChip(group.constraints[0], errorSnapshot, isEnabled) + } else { + val text = if (group.name == null) { + when (group.mode) { + ConstraintMode.AND -> getPluralString( + R.plurals.constraint_group_title_and, + group.constraints.size, + group.constraints.size, + ) + + ConstraintMode.OR -> getPluralString( + R.plurals.constraint_group_title_or, + group.constraints.size, + group.constraints.size, + ) + } + } else { + group.name } - append(getString(R.string.tap_constraints_to_fix)) - } + val icon: ComposeIconInfo? = null + val error: KMError? = + group.constraints.firstNotNullOfOrNull { errorSnapshot.getError(it) } - if (actionChipList.isEmpty()) { - if (this.isNotEmpty()) { - append(" $midDot ") + // Constraints for disabled key maps are never checked so do not show their errors. + val chip: ComposeChipModel = if (error == null || !isEnabled) { + ComposeChipModel.Normal( + id = group.uid, + text = text, + icon = icon, + isEnabled = isEnabled, + ) + } else { + ComposeChipModel.Error( + id = group.uid, + text = text, + error = error, + isFixable = error.isFixable, + ) } - - append(getString(R.string.no_actions)) + return chip } } @@ -263,6 +319,7 @@ class KeyMapListItemCreator( private fun keyEventTriggerKeyName( key: KeyEventTriggerKey, + allKeys: List, showDeviceDescriptors: Boolean, ): String = buildString { when (key.clickType) { @@ -273,22 +330,7 @@ class KeyMapListItemCreator( append(key.getCodeLabel(this@KeyMapListItemCreator)) - val deviceName = when (key.device) { - is KeyEventTriggerDevice.Internal -> null - - is KeyEventTriggerDevice.Any -> getString(R.string.any_device) - - is KeyEventTriggerDevice.External -> { - if (showDeviceDescriptors) { - InputDeviceUtils.appendDeviceDescriptorToName( - key.device.descriptor, - key.device.name, - ) - } else { - key.device.name - } - } - } + val deviceName = getTriggerKeyDeviceName(key, allKeys, showDeviceDescriptors) val parts = mutableListOf() @@ -313,6 +355,39 @@ class KeyMapListItemCreator( } } + private fun getTriggerKeyDeviceName( + key: KeyEventTriggerKey, + allKeys: List, + showDeviceDescriptors: Boolean, + ): String? { + val keyEventKeys = allKeys.filterIsInstance() + + // If all the keys are Internal or Any, then do not show any extra text for brevity. + // Adding the extra information for each key is extra verbosity without any significance. + if (keyEventKeys.all { it.device == KeyEventTriggerDevice.Internal } || + keyEventKeys.all { it.device == KeyEventTriggerDevice.Any } + ) { + return null + } + + return when (key.device) { + is KeyEventTriggerDevice.Internal -> getString(R.string.this_device) + + is KeyEventTriggerDevice.Any -> getString(R.string.any_device) + + is KeyEventTriggerDevice.External -> { + if (showDeviceDescriptors) { + InputDeviceUtils.appendDeviceDescriptorToName( + key.device.descriptor, + key.device.name, + ) + } else { + key.device.name + } + } + } + } + private fun evdevTriggerKeyName(key: EvdevTriggerKey): String = buildString { when (key.clickType) { ClickType.LONG_PRESS -> append(longPressString).append(" ") @@ -387,10 +462,6 @@ class KeyMapListItemCreator( labels.add(getString(R.string.flag_long_press_double_vibration)) } - if (trigger.triggerFromOtherApps) { - labels.add(getString(R.string.flag_trigger_from_other_apps)) - } - if (trigger.showToast) { labels.add(getString(R.string.flag_show_toast)) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt index 4688989732..29f20bf363 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListScreen.kt @@ -1,76 +1,43 @@ package io.github.sds100.keymapper.base.home -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.text.InlineTextContent -import androidx.compose.foundation.text.appendInlineContent -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.ArrowForward -import androidx.compose.material.icons.outlined.Add -import androidx.compose.material.icons.outlined.FlashlightOn -import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.google.accompanist.drawablepainter.rememberDrawablePainter import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme -import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel -import io.github.sds100.keymapper.base.trigger.TriggerError -import io.github.sds100.keymapper.base.utils.ui.compose.CompactChip -import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel -import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo -import io.github.sds100.keymapper.base.utils.ui.compose.ErrorCompactChip -import io.github.sds100.keymapper.base.utils.ui.drawable -import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.State @Composable @@ -84,8 +51,7 @@ fun KeyMapList( onClickKeyMap: (String) -> Unit = {}, onLongClickKeyMap: (String) -> Unit = {}, onSelectedChange: (String, Boolean) -> Unit = { _, _ -> }, - onFixClick: (KMError) -> Unit = {}, - onTriggerErrorClick: (TriggerError) -> Unit = {}, + onFixClick: (String) -> Unit = {}, bottomListPadding: Dp = 100.dp, ) { val haptics = LocalHapticFeedback.current @@ -170,8 +136,7 @@ fun KeyMapList( onLongClickKeyMap(model.content.uid) }, onSelectedChange = { onSelectedChange(model.content.uid, it) }, - onFixClick = onFixClick, - onTriggerErrorClick = onTriggerErrorClick, + onFixClick = { onFixClick(model.content.uid) }, ) } @@ -232,483 +197,6 @@ private fun EmptyKeyMapList(modifier: Modifier = Modifier) { } } -val chipHeight = 28.dp - -@Composable -private fun KeyMapListItem( - modifier: Modifier = Modifier, - isSelectable: Boolean, - model: KeyMapListItemModel, - onClickKeyMap: () -> Unit, - onLongClickKeyMap: () -> Unit, - onSelectedChange: (Boolean) -> Unit, - onFixClick: (KMError) -> Unit, - onTriggerErrorClick: (TriggerError) -> Unit, -) { - OutlinedCard( - modifier = modifier, - onClick = onClickKeyMap, - ) { - Row( - modifier = Modifier.combinedClickable( - onClick = onClickKeyMap, - onLongClick = onLongClickKeyMap, - ), - ) { - if (isSelectable) { - CompositionLocalProvider( - LocalMinimumInteractiveComponentSize provides 16.dp, - ) { - Checkbox( - modifier = Modifier - .padding(start = 16.dp) - .align(Alignment.CenterVertically), - checked = model.isSelected, - onCheckedChange = onSelectedChange, - ) - } - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, top = 10.dp, end = 16.dp, bottom = 10.dp), - ) { - if (model.content.extraInfo != null) { - Row( - modifier = Modifier.heightIn(min = chipHeight), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = model.content.extraInfo, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - - if (model.content.triggerKeys.isNotEmpty()) { - Row( - modifier = Modifier.heightIn(min = chipHeight), - verticalAlignment = Alignment.CenterVertically, - ) { - TriggerDescription( - modifier = Modifier.fillMaxWidth(), - triggerKeys = model.content.triggerKeys, - separator = model.content.triggerSeparatorIcon, - ) - } - } - - if (model.content.triggerErrors.isNotEmpty()) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy( - 4.dp, - alignment = Alignment.CenterVertically, - ), - ) { - for (error in model.content.triggerErrors) { - ErrorCompactChip( - onClick = { onTriggerErrorClick(error) }, - text = getTriggerErrorMessage(error), - enabled = error.isFixable, - ) - } - } - } - - if (model.content.actions.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - itemVerticalAlignment = Alignment.CenterVertically, - verticalArrangement = Arrangement.spacedBy( - 8.dp, - alignment = Alignment.CenterVertically, - ), - ) { - Text( - text = stringResource(R.string.action_list_header), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - for (chipModel in model.content.actions) { - ActionConstraintChip( - chipModel, - onFixClick = onFixClick, - ) - } - } - } - - if (model.content.constraints.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - itemVerticalAlignment = Alignment.CenterVertically, - verticalArrangement = Arrangement.spacedBy( - 8.dp, - alignment = Alignment.CenterVertically, - ), - ) { - Text( - text = stringResource(R.string.constraint_list_header), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - for ((index, chipModel) in model.content.constraints.withIndex()) { - ActionConstraintChip( - chipModel, - onFixClick = onFixClick, - ) - - if (index < model.content.constraints.lastIndex) { - when (model.content.constraintMode) { - ConstraintMode.AND -> Text( - text = stringResource(R.string.constraint_mode_and), - style = MaterialTheme.typography.labelMedium, - ) - - ConstraintMode.OR -> Text( - text = stringResource(R.string.constraint_mode_or), - style = MaterialTheme.typography.labelMedium, - ) - } - } - } - } - } - - if (model.content.options.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - Row( - modifier = Modifier.heightIn(min = chipHeight), - verticalAlignment = Alignment.CenterVertically, - ) { - OptionsDescription( - modifier = Modifier.fillMaxWidth(), - options = model.content.options, - ) - } - } - } - } - } -} - -@Composable -private fun TriggerDescription( - modifier: Modifier = Modifier, - triggerKeys: List, - separator: ImageVector, -) { - val text = buildAnnotatedString { - pushStyle( - MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold).toSpanStyle(), - ) - append(stringResource(R.string.trigger_header)) - pop() - append(" ") - - for ((index, key) in triggerKeys.withIndex()) { - append(key) - - if (index < triggerKeys.lastIndex) { - append(" ") - appendInlineContent("separator") - append(" ") - } - } - } - - val inlineContent = mapOf( - "separator" to InlineTextContent( - placeholder = Placeholder( - width = 14.sp, - height = 14.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, - ), - ) { - Icon(imageVector = separator, contentDescription = null) - }, - ) - - Text( - modifier = modifier, - text = text, - inlineContent = inlineContent, - style = MaterialTheme.typography.bodyMedium, - ) -} - -@Composable -private fun OptionsDescription(modifier: Modifier = Modifier, options: List) { - val dot = stringResource(R.string.middot) - val text = buildAnnotatedString { - pushStyle( - MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold).toSpanStyle(), - ) - append(stringResource(R.string.option_list_header)) - pop() - append(" ") - - for ((index, option) in options.withIndex()) { - append(option) - - if (index < options.lastIndex) { - append(" $dot ") - } - } - } - - Text( - modifier = modifier, - text = text, - style = MaterialTheme.typography.bodyMedium, - ) -} - -@Composable -private fun ActionConstraintChip(model: ComposeChipModel, onFixClick: (KMError) -> Unit) { - when (model) { - is ComposeChipModel.Normal -> { - CompactChip( - text = model.text, - icon = model.icon?.let { icon -> - { - when (icon) { - is ComposeIconInfo.Drawable -> Icon( - modifier = Modifier.fillMaxHeight(), - painter = rememberDrawablePainter(icon.drawable), - contentDescription = null, - tint = Color.Unspecified, - ) - - is ComposeIconInfo.Vector -> Icon( - modifier = Modifier.fillMaxHeight(), - imageVector = icon.imageVector, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - }, - ) - } - - is ComposeChipModel.Error -> ErrorCompactChip( - onClick = { onFixClick(model.error) }, - model.text, - model.isFixable, - ) - } -} - -@Composable -private fun getTriggerErrorMessage(error: TriggerError): String { - return when (error) { - TriggerError.DND_ACCESS_DENIED -> stringResource(R.string.trigger_error_dnd_access_denied) - - TriggerError.CANT_DETECT_IN_PHONE_CALL -> stringResource( - R.string.trigger_error_cant_detect_in_phone_call, - ) - - TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> stringResource( - R.string.trigger_error_assistant_not_purchased, - ) - - TriggerError.DPAD_IME_NOT_SELECTED -> stringResource( - R.string.trigger_error_dpad_ime_not_selected, - ) - - TriggerError.FLOATING_BUTTON_DELETED -> stringResource( - R.string.trigger_error_floating_button_deleted, - ) - - TriggerError.FLOATING_BUTTONS_NOT_PURCHASED -> stringResource( - R.string.trigger_error_floating_buttons_not_purchased, - ) - - TriggerError.PURCHASE_VERIFICATION_FAILED -> stringResource( - R.string.trigger_error_product_verification_failed, - ) - - TriggerError.SYSTEM_BRIDGE_UNSUPPORTED -> stringResource( - R.string.trigger_error_system_bridge_unsupported, - ) - - TriggerError.SYSTEM_BRIDGE_DISCONNECTED -> stringResource( - R.string.trigger_error_system_bridge_disconnected, - ) - - TriggerError.EVDEV_DEVICE_NOT_FOUND -> stringResource( - R.string.trigger_error_evdev_device_not_found, - ) - - TriggerError.MIGRATE_SCREEN_OFF_TRIGGER -> stringResource( - R.string.trigger_error_migrate_screen_off_key_map, - ) - } -} - -@Composable -private fun sampleList(): List { - val context = LocalContext.current - - return listOf( - KeyMapListItemModel( - isSelected = true, - KeyMapListItemModel.Content( - uid = "0", - triggerKeys = listOf("Volume down", "Volume up", "Volume down"), - triggerSeparatorIcon = Icons.AutoMirrored.Outlined.ArrowForward, - actions = listOf( - ComposeChipModel.Normal( - id = "0", - ComposeIconInfo.Drawable( - drawable = context.drawable(R.drawable.ic_launcher_web), - ), - "Open Key Mapper", - ), - ComposeChipModel.Error( - id = "1", - text = "Input KEYCODE_0 • Repeat until released", - error = KMError.NoCompatibleImeChosen, - ), - ComposeChipModel.Normal( - id = "2", - text = "Input KEYCODE_Q", - icon = null, - ), - ComposeChipModel.Normal( - id = "3", - text = "Toggle flashlight", - icon = ComposeIconInfo.Vector(Icons.Outlined.FlashlightOn), - ), - ), - constraintMode = ConstraintMode.AND, - constraints = listOf( - ComposeChipModel.Normal( - id = "0", - ComposeIconInfo.Drawable( - drawable = context.drawable(R.drawable.ic_launcher_web), - ), - "Key Mapper is not open", - ), - ComposeChipModel.Error( - id = "1", - "Key Mapper is playing media", - error = KMError.AppNotFound(""), - ), - ), - options = listOf("Vibrate"), - triggerErrors = listOf(TriggerError.DND_ACCESS_DENIED), - extraInfo = "Disabled • No trigger", - ), - ), - KeyMapListItemModel( - isSelected = true, - KeyMapListItemModel.Content( - uid = "1", - triggerKeys = listOf("Volume down", "Volume up"), - triggerSeparatorIcon = Icons.Outlined.Add, - actions = listOf( - ComposeChipModel.Normal( - id = "0", - ComposeIconInfo.Drawable( - drawable = context.drawable(R.drawable.ic_launcher_web), - ), - "Open Key Mapper", - ), - ), - constraintMode = ConstraintMode.AND, - constraints = listOf( - ComposeChipModel.Normal( - id = "0", - ComposeIconInfo.Drawable( - drawable = context.drawable(R.drawable.ic_launcher_web), - ), - "Key Mapper is not open", - ), - ), - options = listOf( - "Vibrate", - "Vibrate when keys are initially pressed and again when long pressed", - ), - triggerErrors = emptyList(), - extraInfo = null, - ), - ), - KeyMapListItemModel( - isSelected = true, - KeyMapListItemModel.Content( - uid = "2", - triggerKeys = listOf("Volume down", "Volume up"), - triggerSeparatorIcon = Icons.Outlined.Add, - actions = listOf( - ComposeChipModel.Normal( - id = "0", - ComposeIconInfo.Drawable( - drawable = context.drawable(R.drawable.ic_launcher_web), - ), - "Open Key Mapper", - ), - ), - constraintMode = ConstraintMode.AND, - constraints = listOf( - ComposeChipModel.Normal( - id = "0", - ComposeIconInfo.Drawable( - drawable = context.drawable(R.drawable.ic_launcher_web), - ), - "Key Mapper is not open", - ), - ), - options = emptyList(), - triggerErrors = emptyList(), - extraInfo = null, - ), - ), - KeyMapListItemModel( - isSelected = true, - KeyMapListItemModel.Content( - uid = "3", - triggerKeys = listOf("Volume down", "Volume up"), - triggerSeparatorIcon = Icons.Outlined.Add, - actions = listOf( - ComposeChipModel.Normal( - id = "0", - ComposeIconInfo.Drawable( - drawable = context.drawable(R.drawable.ic_launcher_web), - ), - "Open Key Mapper", - ), - ), - constraintMode = ConstraintMode.AND, - constraints = emptyList(), - options = emptyList(), - triggerErrors = emptyList(), - extraInfo = null, - ), - ), - KeyMapListItemModel( - isSelected = false, - content = KeyMapListItemModel.Content( - uid = "4", - triggerKeys = emptyList(), - triggerSeparatorIcon = Icons.Outlined.Add, - actions = emptyList(), - constraintMode = ConstraintMode.OR, - constraints = emptyList(), - options = emptyList(), - triggerErrors = emptyList(), - extraInfo = "Disabled • No trigger", - ), - ), - ) -} - @Preview @Composable private fun ListPreview() { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt index 9633aca76a..759c990810 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/KeyMapListViewModel.kt @@ -11,12 +11,13 @@ import io.github.sds100.keymapper.base.backup.BackupRestoreMappingsUseCase import io.github.sds100.keymapper.base.backup.ExportedBackupLocation import io.github.sds100.keymapper.base.backup.ImportExportState import io.github.sds100.keymapper.base.backup.RestoreType +import io.github.sds100.keymapper.base.constraints.Constraint import io.github.sds100.keymapper.base.constraints.ConstraintErrorSnapshot import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.constraints.ConstraintUiHelper -import io.github.sds100.keymapper.base.groups.Group import io.github.sds100.keymapper.base.groups.GroupFamily import io.github.sds100.keymapper.base.groups.GroupListItemModel +import io.github.sds100.keymapper.base.groups.GroupWithState import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.keymaps.PauseKeyMapsUseCase import io.github.sds100.keymapper.base.onboarding.OnboardingTapTarget @@ -29,6 +30,7 @@ import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel import io.github.sds100.keymapper.base.trigger.TriggerError import io.github.sds100.keymapper.base.trigger.TriggerErrorSnapshot import io.github.sds100.keymapper.base.utils.getFullMessage +import io.github.sds100.keymapper.base.utils.isFixable import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.navigate @@ -143,6 +145,50 @@ class KeyMapListViewModel( ), ) + private val fixErrorsKeyMapUid = MutableStateFlow(null) + + val fixErrorsDialogState: StateFlow = + fixErrorsKeyMapUid.flatMapLatest { dialogId -> + if (dialogId == null) { + flowOf(null) + } else { + combine( + keyMapGroupStateFlow, + listKeyMaps.triggerErrorSnapshot, + listKeyMaps.actionErrorSnapshot, + listKeyMaps.constraintErrorSnapshot, + ) { keyMapGroup, triggerSnapshot, actionSnapshot, constraintSnapshot -> + + val errors: List = when (dialogId) { + is FixErrorsDialogId.Group -> { + buildGroupConstraintsErrors( + keyMapGroup.group?.constraintState?.allConstraints.orEmpty(), + constraintSnapshot, + ) + } + + is FixErrorsDialogId.KeyMap -> { + val keyMap = keyMapGroup.keyMaps.dataOrNull() + ?.firstOrNull { it.uid == dialogId.uid } + + keyMap + ?.let { + buildKeyMapErrors( + it, + triggerSnapshot, + actionSnapshot, + constraintSnapshot, + ) + } + .orEmpty() + } + } + + errors.takeIf { it.isNotEmpty() }?.let { FixErrorsDialogState(it) } + } + } + }.stateIn(coroutineScope, SharingStarted.WhileSubscribed(5000), null) + private val _importExportState = MutableStateFlow(ImportExportState.Idle) val importExportState: StateFlow = _importExportState.asStateFlow() @@ -446,15 +492,18 @@ class KeyMapListViewModel( val selectedKeyMapsEnabled: SelectedKeyMapsEnabled? = getKeyMapSelectedState(keyMapGroup.keyMaps.dataOrNull() ?: emptyList()) + val (constraints, constraintMode) = listItemCreator.buildConstraintChipList( + keyMapGroup.group.constraintState, + constraintErrorSnapshot, + isEnabled = true, + ) + return KeyMapAppBarState.ChildGroup( groupName = keyMapGroup.group.name, - constraints = listItemCreator.buildConstraintChipList( - keyMapGroup.group.constraintState, - constraintErrorSnapshot, - ), - constraintMode = keyMapGroup.group.constraintState.mode, + constraints = constraints, + constraintMode = constraintMode, parentConstraintCount = keyMapGroup.parents.sumOf { - it.constraintState.constraints.size + it.constraintState.allConstraints.size }, subGroups = subGroupListItems, breadcrumbs = breadcrumbs, @@ -465,18 +514,20 @@ class KeyMapListViewModel( } } - private fun buildGroupListItem(group: Group): GroupListItemModel { + private fun buildGroupListItem(groupState: GroupWithState): GroupListItemModel { var icon: ComposeIconInfo? = null - val constraint = group.constraintState.constraints.firstOrNull() + val constraint = groupState.group.constraintState.allConstraints.firstOrNull() if (constraint != null) { icon = constraintUiHelper.getIcon(constraint) } return GroupListItemModel( - uid = group.uid, - name = group.name, + uid = groupState.group.uid, + name = groupState.group.name, icon = icon, + isEnabled = groupState.isEnabled, + isError = groupState.isError, ) } @@ -500,6 +551,42 @@ class KeyMapListViewModel( } } + private fun buildKeyMapErrors( + keyMap: KeyMap, + triggerSnapshot: TriggerErrorSnapshot, + actionSnapshot: ActionErrorSnapshot, + constraintSnapshot: ConstraintErrorSnapshot, + ): List { + val triggerErrors = keyMap.trigger.keys + .mapNotNull { triggerSnapshot.getTriggerError(keyMap, it) } + .distinct() + .map { KeyMapError.Trigger(it, it.getFullMessage(this)) } + + val actionErrorsByData = actionSnapshot.getErrors(keyMap.actionList.map { it.data }) + val actionErrors = keyMap.actionList.filter { it.isEnabled } + .mapNotNull { actionErrorsByData[it.data] } + .map { KeyMapError.Action(it, it.getFullMessage(this), it.isFixable) } + + val constraintErrors = keyMap.constraintState.groups.flatMap { group -> + group.constraints.mapNotNull { constraint -> + constraintSnapshot.getError(constraint) + } + }.map { KeyMapError.Constraint(it, it.getFullMessage(this), it.isFixable) } + + return (triggerErrors + actionErrors + constraintErrors).distinct() + } + + private fun buildGroupConstraintsErrors( + constraints: List, + constraintSnapshot: ConstraintErrorSnapshot, + ): List { + val constraintErrors = constraints.mapNotNull { constraint -> + constraintSnapshot.getError(constraint) + }.map { KeyMapError.Constraint(it, it.getFullMessage(this), it.isFixable) } + + return constraintErrors.distinct() + } + fun onKeyMapCardClick(uid: String) { if (multiSelectProvider.state.value is SelectionState.Selecting) { multiSelectProvider.toggleSelection(uid) @@ -573,6 +660,38 @@ class KeyMapListViewModel( } } + fun onFixClick(keyMapUid: String) { + fixErrorsKeyMapUid.value = FixErrorsDialogId.KeyMap(keyMapUid) + } + + fun onFixGroupConstraintsClick() { + val groupUid = keyMapGroupStateFlow.value.group?.uid + + if (groupUid != null) { + fixErrorsKeyMapUid.value = FixErrorsDialogId.Group(groupUid) + } + } + + fun onDismissFixErrorsDialog() { + fixErrorsKeyMapUid.value = null + } + + fun onFixErrorClick(item: KeyMapError) { + when (item) { + is KeyMapError.Trigger -> onFixTriggerError(item.error) + is KeyMapError.Action -> fixKeyMapError(item.error) + is KeyMapError.Constraint -> fixKeyMapError(item.error) + } + } + + private fun fixKeyMapError(error: KMError) { + if (error is KMError.KeyEventActionError) { + showFixKeyEventActionBottomSheet() + } else { + coroutineScope.launch { listKeyMaps.fixError(error) } + } + } + fun onFixClick(error: KMError) { coroutineScope.launch { when (error) { @@ -911,6 +1030,12 @@ class KeyMapListViewModel( } } + fun onNotGroupConstraintClick(uid: String) { + coroutineScope.launch { + listKeyMaps.toggleGroupConstraintNot(uid) + } + } + fun onGroupConstraintModeChanged(mode: ConstraintMode) { coroutineScope.launch { listKeyMaps.setGroupConstraintMode(mode) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt index 351dc92631..da03180b3f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/ListKeyMapsUseCase.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.base.home import android.database.sqlite.SQLiteConstraintException import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.ActionErrorSnapshot import io.github.sds100.keymapper.base.backup.BackupManager import io.github.sds100.keymapper.base.backup.BackupManagerImpl import io.github.sds100.keymapper.base.backup.BackupRestoreMappingsUseCase @@ -10,11 +11,13 @@ import io.github.sds100.keymapper.base.backup.ExportedBackupLocation import io.github.sds100.keymapper.base.constraints.Constraint import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.constraints.ConstraintEntityMapper +import io.github.sds100.keymapper.base.constraints.ConstraintErrorSnapshot import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.constraints.ConstraintModeEntityMapper import io.github.sds100.keymapper.base.groups.Group import io.github.sds100.keymapper.base.groups.GroupEntityMapper import io.github.sds100.keymapper.base.groups.GroupFamily +import io.github.sds100.keymapper.base.groups.GroupWithState import io.github.sds100.keymapper.base.keymaps.DisplayKeyMapUseCase import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.keymaps.KeyMapEntityMapper @@ -34,13 +37,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext @@ -85,24 +86,80 @@ class ListKeyMapsUseCaseImpl @Inject constructor( } } - override val keyMapGroup: Flow = channelFlow { - keyMapListGroupUid - .flatMapLatest(::getGroupFamily) - .map { groupFamily -> - val parentGroups = getParentsRecursively(groupFamily.group?.uid) + override val keyMapGroup: Flow = keyMapListGroupUid + .flatMapLatest(::getGroupFamily) + .flatMapLatest { groupFamily -> + val parentGroups = getParentsRecursively(groupFamily.group?.uid) + getSubGroupsWithState(groupFamily.children).map { subGroups -> KeyMapGroup( group = groupFamily.group, - subGroups = groupFamily.children, + subGroups = subGroups, keyMaps = State.Loading, parents = parentGroups, ) - }.onEach { send(it) } - .flatMapLatest { keyMapGroup -> - getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) } - }.collect { - send(it) } + } + .flatMapLatest { keyMapGroup -> + getKeyMapsByGroup(keyMapGroup.group?.uid).map { keyMapGroup.copy(keyMaps = it) } + } + + /** + * Builds the enabled/error state of each subgroup from its own direct key maps and + * constraints only, not recursively through nested subgroups. + */ + private fun getSubGroupsWithState(groups: List): Flow> { + if (groups.isEmpty()) { + return flowOf(emptyList()) + } + + return combine( + groups.map { group -> + combine( + getKeyMapsByGroup(group.uid), + actionErrorSnapshot, + constraintErrorSnapshot, + ) { keyMapsState, actionSnapshot, constraintSnapshot -> + buildGroupWithState( + group, + keyMapsState.dataOrNull() ?: emptyList(), + actionSnapshot, + constraintSnapshot, + ) + } + }, + ) { it.toList() } + } + + private fun buildGroupWithState( + group: Group, + keyMaps: List, + actionErrorSnapshot: ActionErrorSnapshot, + constraintErrorSnapshot: ConstraintErrorSnapshot, + ): GroupWithState { + val isEnabled = keyMaps.isEmpty() || keyMaps.any { it.isEnabled } + + val hasOwnConstraintError = group.constraintState.allConstraints.any { + constraintErrorSnapshot.getError(it) != null + } + + val hasEnabledKeyMapError = keyMaps.any { keyMap -> + keyMap.isEnabled && + ( + actionErrorSnapshot.getErrors( + keyMap.actionList.filter { it.isEnabled }.map { it.data }, + ).values.any { it != null } || + keyMap.constraintState.allConstraints.any { + constraintErrorSnapshot.getError(it) != null + } + ) + } + + return GroupWithState( + group = group, + isEnabled = isEnabled, + isError = hasOwnConstraintError || hasEnabledKeyMapError, + ) } override val selectionGroupFamily: Flow = @@ -289,12 +346,46 @@ class ListKeyMapsUseCaseImpl @Inject constructor( val groupEntity = groupRepository.getGroup(groupUid) ?: return var group = GroupEntityMapper.fromEntity(groupEntity) - val constraints = group.constraintState.constraints - .filterNot { it.uid == constraintUid } - .toSet() + val constraintGroups = group.constraintState.groups + .map { constraintGroup -> + constraintGroup.copy( + constraints = constraintGroup.constraints.filterNot { + it.uid == constraintUid + }, + ) + } + .filter { it.constraints.isNotEmpty() } group = - group.copy(constraintState = group.constraintState.copy(constraints = constraints)) + group.copy(constraintState = group.constraintState.copy(groups = constraintGroups)) + + try { + groupRepository.update(GroupEntityMapper.toEntity(group)) + } catch (_: SQLiteConstraintException) { + return + } + } + } + + override suspend fun toggleGroupConstraintNot(constraintUid: String) { + keyMapListGroupUid.value?.also { groupUid -> + val groupEntity = groupRepository.getGroup(groupUid) ?: return + var group = GroupEntityMapper.fromEntity(groupEntity) + + val constraintGroups = group.constraintState.groups.map { constraintGroup -> + constraintGroup.copy( + constraints = constraintGroup.constraints.map { constraint -> + if (constraint.uid == constraintUid) { + constraint.copy(isNot = !constraint.isNot) + } else { + constraint + } + }, + ) + } + + group = + group.copy(constraintState = group.constraintState.copy(groups = constraintGroups)) try { groupRepository.update(GroupEntityMapper.toEntity(group)) @@ -324,20 +415,18 @@ class ListKeyMapsUseCaseImpl @Inject constructor( } } - private fun getKeyMapsByGroup(groupUid: String?): Flow>> = channelFlow { - send(State.Loading) - - combine( + private fun getKeyMapsByGroup(groupUid: String?): Flow>> { + return combine( keyMapRepository.getByGroup(groupUid), floatingButtonRepository.buttonsList, ) { keyMapList, buttonListState -> - Pair(keyMapList, buttonListState) - }.collectLatest { (keyMapList, buttonListState) -> - if (buttonListState is State.Loading) { - send(State.Loading) - } + val buttonList = when (buttonListState) { + is State.Loading -> { + return@combine State.Loading + } - val buttonList = buttonListState.dataOrNull() ?: return@collectLatest + is State.Data -> buttonListState.data + } val keyMaps = withContext(Dispatchers.Default) { keyMapList.map { keyMap -> @@ -345,7 +434,7 @@ class ListKeyMapsUseCaseImpl @Inject constructor( } } - send(State.Data(keyMaps)) + State.Data(keyMaps) } } @@ -390,6 +479,7 @@ interface ListKeyMapsUseCase : DisplayKeyMapUseCase { suspend fun renameGroup(name: String): Boolean suspend fun addGroupConstraint(constraintData: ConstraintData) suspend fun removeGroupConstraint(constraintUid: String) + suspend fun toggleGroupConstraintNot(constraintUid: String) suspend fun setGroupConstraintMode(mode: ConstraintMode) fun getGroups(parentUid: String?): Flow> fun enableGroupKeyMaps() diff --git a/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt index 6e327ab851..756cc3e2fd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/home/WhatsNewDialog.kt @@ -74,7 +74,7 @@ fun WhatsNewDialogContent( Column( modifier = modifier.verticalScroll(rememberScrollState()).weight(1f, fill = false), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { WhatsNewHeader( modifier = Modifier.padding(horizontal = 24.dp), @@ -178,7 +178,6 @@ private fun WhatsNewSection( if (item.isSpotlight) { Surface( modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.surfaceContainerHighest, border = BorderStroke( width = 1.dp, @@ -186,7 +185,7 @@ private fun WhatsNewSection( ), shape = MaterialTheme.shapes.small, ) { - WhatsNewItemText(Modifier.padding(vertical = 4.dp, horizontal = 8.dp), item) + WhatsNewItemText(Modifier.padding(vertical = 8.dp, horizontal = 12.dp), item) } } else { WhatsNewItemText( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt index b51cb76ec8..6a55f17cb7 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapOptionsViewModel.kt @@ -2,7 +2,9 @@ package io.github.sds100.keymapper.base.keymaps import android.graphics.Color import android.graphics.drawable.Drawable -import io.github.sds100.keymapper.base.R +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import io.github.sds100.keymapper.base.actions.ActionUiHelper import io.github.sds100.keymapper.base.shortcuts.CreateKeyMapShortcutUseCase import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCase @@ -11,11 +13,11 @@ import io.github.sds100.keymapper.base.utils.getFullMessage import io.github.sds100.keymapper.base.utils.navigation.NavDestination import io.github.sds100.keymapper.base.utils.navigation.NavigationProvider import io.github.sds100.keymapper.base.utils.navigation.navigate -import io.github.sds100.keymapper.base.utils.ui.DialogModel import io.github.sds100.keymapper.base.utils.ui.DialogProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.TintType -import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate +import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.dataOrNull import io.github.sds100.keymapper.common.utils.mapData @@ -40,13 +42,18 @@ class ConfigKeyMapOptionsViewModel( private val dialogProvider: DialogProvider, navigationProvider: NavigationProvider, resourceProvider: ResourceProvider, + vibrateConfigDelegate: VibrateConfigDelegate, ) : ResourceProvider by resourceProvider, DialogProvider by dialogProvider, NavigationProvider by navigationProvider, + VibrateConfigDelegate by vibrateConfigDelegate, KeyMapOptionsCallback { private val actionUiHelper = ActionUiHelper(displayUseCase, resourceProvider) + var createShortcutDialogState: CreateShortcutDialogState? by mutableStateOf(null) + private set + val state: StateFlow> = combine( config.keyMap, systemBridgeConnectionManager.connectionState, @@ -66,8 +73,15 @@ class ConfigKeyMapOptionsViewModel( config.setSequenceTriggerTimeout(timeout) } - override fun onVibrateDurationChanged(duration: Int) { - config.setVibrationDuration(duration) + override fun onVibrateSettingsClick() { + val currentState = state.value.dataOrNull() ?: return + openVibrateConfig(currentState.vibrateEffect, currentState.defaultVibrateDuration) + } + + fun onDoneVibrateConfigClick() { + val effect = buildVibrateEffect() ?: return + closeVibrateConfig() + config.setVibrateEffect(effect) } override fun onVibrateChanged(checked: Boolean) { @@ -82,22 +96,31 @@ class ConfigKeyMapOptionsViewModel( config.setShowToastEnabled(checked) } - override fun onTriggerFromOtherAppsChanged(checked: Boolean) { - config.setTriggerFromOtherAppsEnabled(checked) - } - override fun onOpenExpertModeSettings() { coroutineScope.launch { navigate("screen_off_trigger_tip", NavDestination.ExpertMode) } } + override fun onTriggerByIntentClick() { + coroutineScope.launch { + val keyMapUid = config.keyMap.firstOrNull()?.dataOrNull()?.uid ?: return@launch + navigate("trigger_by_intent", NavDestination.TriggerByIntent(keyMapUid)) + } + } + + override fun onEnableByIntentClick() { + coroutineScope.launch { + val keyMapUid = config.keyMap.firstOrNull()?.dataOrNull()?.uid ?: return@launch + navigate("enable_by_intent", NavDestination.EnableByIntent(keyMapUid)) + } + } + override fun onCreateShortcutClick() { coroutineScope.launch { val mapping = config.keyMap.firstOrNull()?.dataOrNull() ?: return@launch val keyMapUid = mapping.uid - val key = "create_launcher_shortcut" val defaultShortcutName: String val icon: Drawable? @@ -131,25 +154,34 @@ class ConfigKeyMapOptionsViewModel( icon = null } - val shortcutName = showDialog( - key, - DialogModel.Text( - getString(R.string.hint_shortcut_name), - allowEmpty = false, - text = defaultShortcutName, - ), - ) ?: return@launch + createShortcutDialogState = CreateShortcutDialogState( + keyMapUid = keyMapUid, + defaultName = defaultShortcutName, + icon = icon, + ) + } + } - val result = createKeyMapShortcut.pinShortcut(keyMapUid, shortcutName, icon) + suspend fun onConfirmCreateShortcut(name: String): String? { + val dialogState = createShortcutDialogState ?: return null - result.onFailure { error -> - val snackBar = DialogModel.SnackBar( - message = error.getFullMessage(this@ConfigKeyMapOptionsViewModel), - ) + val result = createKeyMapShortcut.pinShortcut(dialogState.keyMapUid, name, dialogState.icon) - showDialog("create_shortcut_result", snackBar) - } + var errorMessage: String? = null + + result.onFailure { error -> + errorMessage = error.getFullMessage(this@ConfigKeyMapOptionsViewModel) + } + + if (errorMessage == null) { + createShortcutDialogState = null } + + return errorMessage + } + + fun onDismissCreateShortcutDialog() { + createShortcutDialogState = null } private suspend fun buildState( @@ -175,8 +207,8 @@ class ConfigKeyMapOptionsViewModel( ?: defaultSequenceTriggerTimeout, defaultSequenceTriggerTimeout = defaultSequenceTriggerTimeout, - showVibrateDuration = keyMap.trigger.isChangingVibrationDurationAllowed(), - vibrateDuration = keyMap.trigger.vibrateDuration ?: defaultVibrateDuration, + showVibrateSettings = keyMap.trigger.isChangingVibrationDurationAllowed(), + vibrateEffect = keyMap.trigger.vibrateEffect, defaultVibrateDuration = defaultVibrateDuration, showVibrate = keyMap.trigger.isVibrateAllowed(), @@ -185,7 +217,6 @@ class ConfigKeyMapOptionsViewModel( showLongPressDoubleVibration = keyMap.trigger.isLongPressDoubleVibrationAllowed(), longPressDoubleVibration = keyMap.trigger.longPressDoubleVibration, - triggerFromOtherApps = keyMap.trigger.triggerFromOtherApps, keyMapUid = keyMap.uid, isLauncherShortcutButtonEnabled = createKeyMapShortcut.isSupported, @@ -197,6 +228,12 @@ class ConfigKeyMapOptionsViewModel( } } +data class CreateShortcutDialogState( + val keyMapUid: String, + val defaultName: String, + val icon: Drawable?, +) + data class KeyMapOptionsState( val showLongPressDelay: Boolean, val longPressDelay: Int, @@ -210,8 +247,8 @@ data class KeyMapOptionsState( val sequenceTriggerTimeout: Int, val defaultSequenceTriggerTimeout: Int, - val showVibrateDuration: Boolean, - val vibrateDuration: Int, + val showVibrateSettings: Boolean, + val vibrateEffect: VibrateEffect?, val defaultVibrateDuration: Int, val showVibrate: Boolean, @@ -220,7 +257,6 @@ data class KeyMapOptionsState( val showLongPressDoubleVibration: Boolean, val longPressDoubleVibration: Boolean, - val triggerFromOtherApps: Boolean, val keyMapUid: String, val isLauncherShortcutButtonEnabled: Boolean, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt index d9f3ddf70c..c2509d11f1 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/ConfigKeyMapState.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -115,8 +116,8 @@ class ConfigKeyMapStateImpl @Inject constructor( } } - override fun update(block: (keyMap: KeyMap) -> KeyMap) { - _keyMap.update { value -> value.mapData { block.invoke(it) } } + override fun update(block: (keyMap: KeyMap) -> KeyMap): State { + return _keyMap.updateAndGet { value -> value.mapData { block.invoke(it) } } } } @@ -124,7 +125,7 @@ interface ConfigKeyMapState { val keyMap: StateFlow> val isEdited: Boolean - fun update(block: (keyMap: KeyMap) -> KeyMap) + fun update(block: (keyMap: KeyMap) -> KeyMap): State fun save() suspend fun loadKeyMap(uid: String) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableByIntentScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableByIntentScreen.kt new file mode 100644 index 0000000000..137be1786f --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/EnableByIntentScreen.kt @@ -0,0 +1,117 @@ +package io.github.sds100.keymapper.base.keymaps + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.CodeBlock + +// Duplicated from the :api module because :api depends on :base and not the other +// way around. CreateKeyMapShortcutUseCase duplicates them in the same way. +private const val ACTION_ENABLE_KEY_MAP = "io.github.sds100.keymapper.ACTION_ENABLE_KEY_MAP" +private const val ACTION_DISABLE_KEY_MAP = "io.github.sds100.keymapper.ACTION_DISABLE_KEY_MAP" +private const val ACTION_TOGGLE_KEY_MAP = "io.github.sds100.keymapper.ACTION_TOGGLE_KEY_MAP" +private const val ENABLE_RECEIVER_CLASS = + "io.github.sds100.keymapper.api.EnableKeyMapsBroadcastReceiver" + +@Composable +fun EnableByIntentScreen( + modifier: Modifier = Modifier, + keyMapUid: String, + onBackClick: () -> Unit, +) { + val packageName = LocalContext.current.packageName + val clipboardLabel = stringResource(R.string.intent_screen_clipboard_label) + + IntentScreen( + modifier = modifier, + title = stringResource(R.string.key_map_options_enable_by_intent_title), + text = stringResource(R.string.intent_screen_enable_message), + onBackClick = onBackClick, + ) { + Text( + text = stringResource(R.string.intent_screen_target_explanation), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_package), + code = packageName, + clipboardLabel = clipboardLabel, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_class), + code = ENABLE_RECEIVER_CLASS, + clipboardLabel = clipboardLabel, + ) + + Text( + text = stringResource(R.string.intent_screen_action_explanation), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_action_enable), + code = ACTION_ENABLE_KEY_MAP, + clipboardLabel = clipboardLabel, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_action_disable), + code = ACTION_DISABLE_KEY_MAP, + clipboardLabel = clipboardLabel, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_action_toggle), + code = ACTION_TOGGLE_KEY_MAP, + clipboardLabel = clipboardLabel, + ) + + Text( + text = stringResource(R.string.intent_screen_extras_explanation), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_extra_name), + code = EXTRA_KEYMAP_UID, + clipboardLabel = clipboardLabel, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_extra_value), + code = keyMapUid, + clipboardLabel = clipboardLabel, + ) + + AdbSection( + command = buildAdbCommand( + packageName = packageName, + receiverClass = ENABLE_RECEIVER_CLASS, + action = ACTION_TOGGLE_KEY_MAP, + keyMapUid = keyMapUid, + ), + ) + } +} + +@Preview(heightDp = 1200) +@Composable +private fun EnableByIntentScreenPreview() { + KeyMapperTheme { + EnableByIntentScreen( + keyMapUid = "beea7ef5-e33e-4bd3-9987-9002e5035f23", + onBackClick = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/IntentScreenComponents.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/IntentScreenComponents.kt new file mode 100644 index 0000000000..6df94ecb41 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/IntentScreenComponents.kt @@ -0,0 +1,127 @@ +package io.github.sds100.keymapper.base.keymaps + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.displayCutoutPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.utils.ui.compose.CodeBlock + +// Duplicated from the :api module because :api depends on :base and not the other +// way around. CreateKeyMapShortcutUseCase duplicates them in the same way. +internal const val EXTRA_KEYMAP_UID = "io.github.sds100.keymapper.EXTRA_KEYMAP_UID" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun IntentScreen( + modifier: Modifier = Modifier, + title: String, + text: String, + onBackClick: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + Scaffold( + modifier = modifier.displayCutoutPadding(), + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.action_go_back), + ) + } + }, + ) + }, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + val startPadding = innerPadding.calculateStartPadding(layoutDirection) + val endPadding = innerPadding.calculateEndPadding(layoutDirection) + + Surface( + modifier = Modifier + .fillMaxSize() + .padding( + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding(), + start = startPadding, + end = endPadding, + ), + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + content() + } + } + } +} + +@Composable +internal fun AdbSection(modifier: Modifier = Modifier, command: String) { + val clipboardLabel = stringResource(R.string.intent_screen_clipboard_label) + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.intent_screen_adb_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + Text( + text = stringResource(R.string.intent_screen_adb_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_command), + code = command, + clipboardLabel = clipboardLabel, + ) + } +} + +internal fun buildAdbCommand( + packageName: String, + receiverClass: String, + action: String, + keyMapUid: String, +): String { + return "adb shell am broadcast -n $packageName/$receiverClass " + + "-a $action --es $EXTRA_KEYMAP_UID $keyMapUid" +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt index f75bbf55c6..33e30c37dc 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMap.kt @@ -5,13 +5,15 @@ import io.github.sds100.keymapper.base.actions.Action import io.github.sds100.keymapper.base.actions.ActionData import io.github.sds100.keymapper.base.actions.ActionEntityMapper import io.github.sds100.keymapper.base.actions.canBeHeldDown -import io.github.sds100.keymapper.base.constraints.ConstraintEntityMapper -import io.github.sds100.keymapper.base.constraints.ConstraintModeEntityMapper import io.github.sds100.keymapper.base.constraints.ConstraintState +import io.github.sds100.keymapper.base.constraints.ConstraintStateEntityMapper import io.github.sds100.keymapper.base.detection.KeyMapAlgorithm +import io.github.sds100.keymapper.base.trigger.AssistantTriggerKey +import io.github.sds100.keymapper.base.trigger.FingerprintTriggerKey import io.github.sds100.keymapper.base.trigger.Trigger import io.github.sds100.keymapper.base.trigger.TriggerEntityMapper import io.github.sds100.keymapper.base.trigger.TriggerKey +import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.data.entities.FloatingButtonEntityWithLayout import io.github.sds100.keymapper.data.entities.KeyMapEntity import java.util.UUID @@ -34,8 +36,8 @@ data class KeyMap( val vibrate: Boolean get() = trigger.vibrate - val vibrateDuration: Int? - get() = trigger.vibrateDuration + val vibrateEffect: VibrateEffect? + get() = trigger.vibrateEffect fun isRepeatingActionsAllowed(): Boolean = KeyMapAlgorithm.performActionOnDown(trigger) @@ -57,6 +59,13 @@ data class KeyMap( fun isChangingRepeatLimitAllowed(action: Action): Boolean = action.repeat && isRepeatingActionsAllowed() + /** + * The release of assistant and fingerprint gesture triggers can not be detected. + */ + fun isRepeatUntilReleasedAllowed(): Boolean = trigger.keys.none { + it is AssistantTriggerKey || it is FingerprintTriggerKey + } + fun isStopHoldingDownActionWhenTriggerPressedAgainAllowed(action: Action): Boolean = action.holdDown && !action.repeat && isHoldingDownActionAllowed(action) @@ -116,17 +125,18 @@ object KeyMapEntityMapper { .filterNotNull() .mapNotNull { ActionEntityMapper.fromEntity(it) } - val constraintList = - entity.constraintList.map { ConstraintEntityMapper.fromEntity(it) }.toSet() - - val constraintMode = ConstraintModeEntityMapper.fromEntity(entity.constraintMode) + val constraintState = ConstraintStateEntityMapper.fromEntityWithGroups( + entity.constraintList, + entity.constraintGroups, + entity.constraintMode, + ) return KeyMap( dbId = entity.id, uid = entity.uid, trigger = TriggerEntityMapper.fromEntity(entity.trigger, floatingButtons), actionList = actionList, - constraintState = ConstraintState(constraintList, constraintMode), + constraintState = constraintState, isEnabled = entity.isEnabled, groupUid = entity.groupUid, ) @@ -134,17 +144,16 @@ object KeyMapEntityMapper { fun toEntity(keyMap: KeyMap, dbId: Long): KeyMapEntity { val actionEntityList = ActionEntityMapper.toEntity(keyMap) + val (constraintList, constraintGroups, constraintMode) = + ConstraintStateEntityMapper.toEntityWithGroups(keyMap.constraintState) return KeyMapEntity( id = dbId, trigger = TriggerEntityMapper.toEntity(keyMap.trigger), actionList = actionEntityList, - constraintList = keyMap.constraintState.constraints.map { - ConstraintEntityMapper.toEntity( - it, - ) - }, - constraintMode = ConstraintModeEntityMapper.toEntity(keyMap.constraintState.mode), + constraintList = constraintList, + constraintGroups = constraintGroups, + constraintMode = constraintMode, isEnabled = keyMap.isEnabled, uid = keyMap.uid, groupUid = keyMap.groupUid, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt index e2c7e887f0..b1bf1f5e7e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/KeyMapOptionsScreen.kt @@ -1,8 +1,6 @@ package io.github.sds100.keymapper.base.keymaps import android.content.ClipData -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,14 +14,20 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddHome +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Message +import androidx.compose.material.icons.outlined.RocketLaunch +import androidx.compose.material.icons.outlined.Route +import androidx.compose.material.icons.outlined.Sensors +import androidx.compose.material.icons.outlined.Timer +import androidx.compose.material.icons.outlined.Vibration import androidx.compose.material.icons.rounded.ContentCopy -import androidx.compose.material3.Checkbox +import androidx.compose.material.icons.rounded.PowerSettingsNew import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton 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 @@ -31,13 +35,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -47,9 +48,14 @@ import io.github.sds100.keymapper.base.onboarding.TipCard import io.github.sds100.keymapper.base.utils.ui.SliderMaximums import io.github.sds100.keymapper.base.utils.ui.SliderMinimums import io.github.sds100.keymapper.base.utils.ui.SliderStepSizes -import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText +import io.github.sds100.keymapper.base.utils.ui.compose.OptionPageButton +import io.github.sds100.keymapper.base.utils.ui.compose.OptionsHeaderRow import io.github.sds100.keymapper.base.utils.ui.compose.SliderOptionText -import io.github.sds100.keymapper.base.utils.ui.compose.openUriSafe +import io.github.sds100.keymapper.base.utils.ui.compose.SwitchPreferenceCompose +import io.github.sds100.keymapper.base.utils.ui.compose.SwitchSettingsPreferenceCompose +import io.github.sds100.keymapper.base.utils.ui.compose.TextFieldDialog +import io.github.sds100.keymapper.base.utils.ui.compose.icons.KeyMapperIcons +import io.github.sds100.keymapper.base.utils.ui.compose.icons.MobileSensorHi import io.github.sds100.keymapper.common.utils.State import kotlinx.coroutines.launch @@ -62,6 +68,20 @@ fun KeyMapOptionsScreen(modifier: Modifier = Modifier, viewModel: ConfigKeyMapOp state = state, callback = viewModel, ) + + val createShortcutDialogState = viewModel.createShortcutDialogState + + if (createShortcutDialogState != null) { + TextFieldDialog( + title = stringResource(R.string.key_map_options_create_shortcut_title), + submitButtonText = stringResource(R.string.pos_save), + initialText = createShortcutDialogState.defaultName, + onSubmitClick = { newText -> viewModel.onConfirmCreateShortcut(newText) }, + onDismissRequest = viewModel::onDismissCreateShortcutDialog, + ) + } + + TriggerVibrationBottomSheet(viewModel) } @Composable @@ -98,16 +118,15 @@ private fun Loaded( Column( modifier = modifier .verticalScroll(rememberScrollState()) - .fillMaxWidth(), + .fillMaxWidth() + .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Spacer(modifier = Modifier.height(8.dp)) if (state.showScreenOffTip) { TipCard( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), + modifier = Modifier.fillMaxWidth(), title = stringResource(R.string.tip_screen_off_trigger_title), message = stringResource(R.string.tip_screen_off_trigger_message), isDismissable = false, @@ -120,72 +139,77 @@ private fun Loaded( ) } - TriggerFromOtherAppsSection( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - isChecked = state.triggerFromOtherApps, - onCheckedChange = callback::onTriggerFromOtherAppsChanged, - isLauncherShortcutEnabled = state.isLauncherShortcutButtonEnabled, - keyMapUid = state.keyMapUid, - onCreateShortcutClick = callback::onCreateShortcutClick, + Spacer(modifier = Modifier.height(8.dp)) + + OptionsHeaderRow( + icon = Icons.Outlined.Info, + text = stringResource(R.string.key_map_options_header_information), ) - CheckBoxText( + KeyMapUidRow( modifier = Modifier - .padding(horizontal = 8.dp) .fillMaxWidth(), - text = stringResource(R.string.flag_show_toast), - isChecked = state.showToast, - onCheckedChange = callback::onShowToastChanged, + keyMapUid = state.keyMapUid, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + OptionsHeaderRow( + icon = Icons.Outlined.Sensors, + text = stringResource(R.string.key_map_options_header_feedback), ) if (state.showVibrate) { - CheckBoxText( - modifier = Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - text = stringResource(R.string.flag_vibrate), + SwitchSettingsPreferenceCompose( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.flag_vibrate), + text = stringResource(R.string.key_map_options_vibrate_summary), + icon = Icons.Outlined.Vibration, isChecked = state.vibrate, onCheckedChange = callback::onVibrateChanged, - ) - } - - if (state.showVibrateDuration) { - val vibrateDurationMin = SliderMinimums.VIBRATION_DURATION - val vibrateDurationMax = SliderMaximums.VIBRATION_DURATION - SliderOptionText( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - title = stringResource(R.string.extra_label_vibration_duration), - defaultValue = state.defaultVibrateDuration.toFloat(), - value = state.vibrateDuration.toFloat(), - valueText = { "${it.toInt()} ms" }, - onValueChange = { callback.onVibrateDurationChanged(it.toInt()) }, - valueRange = vibrateDurationMin.toFloat()..vibrateDurationMax.toFloat(), - stepSize = SliderStepSizes.VIBRATION_DURATION, + // Always allow users to play around with the vibration settings. + isSettingsEnabled = true, + onSettingsClick = callback::onVibrateSettingsClick, ) } if (state.showLongPressDoubleVibration) { - CheckBoxText( - modifier = Modifier - .padding(horizontal = 8.dp) - .fillMaxWidth(), - text = stringResource(R.string.flag_long_press_double_vibration), + SwitchPreferenceCompose( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.flag_long_press_double_vibration), + text = null, + icon = KeyMapperIcons.MobileSensorHi, isChecked = state.longPressDoubleVibration, onCheckedChange = callback::onLongPressDoubleVibrationChanged, ) } + SwitchPreferenceCompose( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.flag_show_toast), + text = stringResource(R.string.key_map_options_show_toast_summary), + icon = Icons.Outlined.Message, + isChecked = state.showToast, + onCheckedChange = callback::onShowToastChanged, + ) + + val showTimingSection = state.showLongPressDelay || + state.showDoublePressDelay || + state.showSequenceTriggerTimeout + + if (showTimingSection) { + OptionsHeaderRow( + icon = Icons.Outlined.Timer, + text = stringResource(R.string.key_map_options_header_timing), + ) + } + if (state.showLongPressDelay) { val longPressDelayMin = SliderMinimums.TRIGGER_LONG_PRESS_DELAY val longPressDelayMax = SliderMaximums.TRIGGER_LONG_PRESS_DELAY SliderOptionText( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .fillMaxWidth(), title = stringResource(R.string.extra_label_long_press_delay_timeout), defaultValue = state.defaultLongPressDelay.toFloat(), value = state.longPressDelay.toFloat(), @@ -201,8 +225,7 @@ private fun Loaded( val doublePressDelayMax = SliderMaximums.TRIGGER_DOUBLE_PRESS_DELAY SliderOptionText( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .fillMaxWidth(), title = stringResource(R.string.extra_label_double_press_delay_timeout), defaultValue = state.defaultDoublePressDelay.toFloat(), value = state.doublePressDelay.toFloat(), @@ -220,8 +243,7 @@ private fun Loaded( SliderMaximums.TRIGGER_SEQUENCE_TRIGGER_TIMEOUT.toFloat() SliderOptionText( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .fillMaxWidth(), title = stringResource(R.string.extra_label_sequence_trigger_timeout), defaultValue = state.defaultSequenceTriggerTimeout.toFloat(), value = state.sequenceTriggerTimeout.toFloat(), @@ -232,129 +254,84 @@ private fun Loaded( ) } + OptionsHeaderRow( + icon = Icons.Outlined.Route, + text = stringResource(R.string.key_map_options_header_integration), + ) + + OptionPageButton( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.key_map_options_enable_by_intent_title), + text = stringResource(R.string.key_map_options_intent_summary), + icon = Icons.Rounded.PowerSettingsNew, + onClick = callback::onEnableByIntentClick, + ) + OptionPageButton( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.key_map_options_trigger_by_intent_title), + text = stringResource(R.string.key_map_options_intent_summary), + icon = Icons.Outlined.RocketLaunch, + onClick = callback::onTriggerByIntentClick, + ) + + OptionPageButton( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.key_map_options_create_shortcut_title), + text = if (state.isLauncherShortcutButtonEnabled) { + stringResource(R.string.key_map_options_create_shortcut_summary) + } else { + stringResource(R.string.key_map_options_create_shortcut_summary_unsupported) + }, + icon = Icons.Outlined.AddHome, + enabled = state.isLauncherShortcutButtonEnabled, + onClick = callback::onCreateShortcutClick, + ) + Spacer(Modifier.height(8.dp)) } } @Composable -private fun TriggerFromOtherAppsSection( - modifier: Modifier = Modifier, - isChecked: Boolean, - onCheckedChange: (Boolean) -> Unit, - onCreateShortcutClick: () -> Unit, - isLauncherShortcutEnabled: Boolean, - keyMapUid: String, -) { - Column(modifier) { - Row { - Surface( - modifier = Modifier.weight(1f), - shape = MaterialTheme.shapes.medium, - color = Color.Transparent, - ) { - Row( - modifier = Modifier - .clickable { onCheckedChange(!isChecked) } - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Checkbox( - checked = isChecked, - // This is null so tapping on the checkbox highlights the whole row. - onCheckedChange = null, - ) - - Text( - modifier = Modifier.padding(horizontal = 12.dp), +private fun KeyMapUidRow(modifier: Modifier = Modifier, keyMapUid: String) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.key_map_options_key_map_id), + style = MaterialTheme.typography.bodyLarge, + ) - text = stringResource(R.string.flag_trigger_from_other_apps), - style = MaterialTheme.typography.bodyLarge, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } + Text( + text = keyMapUid, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - AnimatedVisibility(isChecked) { - Column { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - modifier = Modifier - .padding(horizontal = 16.dp) - .weight(1f), - text = keyMapUid, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.labelLarge, - ) - - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - val clipboardLabel = stringResource(R.string.clipboard_label_keymap_uid) - - IconButton( - modifier = Modifier - .padding(horizontal = 8.dp) - .size(36.dp), - onClick = { - scope.launch { - clipboard.setClipEntry( - ClipEntry(ClipData.newPlainText(clipboardLabel, keyMapUid)), - ) - } - }, - ) { - Icon( - imageVector = Icons.Rounded.ContentCopy, - contentDescription = stringResource( - R.string.flag_trigger_from_other_apps_copy_uid, - ), - ) - } - } - - Spacer(Modifier.height(8.dp)) - - OutlinedButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - onClick = onCreateShortcutClick, - enabled = isLauncherShortcutEnabled, - ) { - val text = if (isLauncherShortcutEnabled) { - stringResource(R.string.button_create_keymap_shortcut_in_launcher_enabled) - } else { - stringResource(R.string.button_create_keymap_shortcut_in_launcher_disabled) - } - - Text(text = text) - } + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val clipboardLabel = stringResource(R.string.clipboard_label_keymap_uid) - val uriHandler = LocalUriHandler.current - val ctx = LocalContext.current - val intentGuideUrl = stringResource(R.string.url_trigger_by_intent_guide) - - FilledTonalButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - onClick = { - uriHandler.openUriSafe(ctx, intentGuideUrl) - }, - ) { - Text( - text = stringResource( - R.string.button_open_trigger_keymap_from_intent_guide, - ), + IconButton( + modifier = Modifier.size(36.dp), + onClick = { + scope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(clipboardLabel, keyMapUid)), ) } - } + }, + ) { + Icon( + imageVector = Icons.Rounded.ContentCopy, + contentDescription = stringResource( + R.string.flag_trigger_from_other_apps_copy_uid, + ), + ) } } } @@ -363,16 +340,17 @@ interface KeyMapOptionsCallback { fun onLongPressDelayChanged(delay: Int) = run { } fun onDoublePressDelayChanged(delay: Int) = run { } fun onSequenceTriggerTimeoutChanged(timeout: Int) = run { } - fun onVibrateDurationChanged(duration: Int) = run { } + fun onVibrateSettingsClick() = run { } fun onVibrateChanged(checked: Boolean) = run { } fun onLongPressDoubleVibrationChanged(checked: Boolean) = run { } fun onShowToastChanged(checked: Boolean) = run { } - fun onTriggerFromOtherAppsChanged(checked: Boolean) = run {} fun onCreateShortcutClick() = run { } fun onOpenExpertModeSettings() = run {} + fun onTriggerByIntentClick() = run {} + fun onEnableByIntentClick() = run {} } -@Preview +@Preview(heightDp = 1300) @Composable private fun Preview() { KeyMapperTheme { @@ -392,8 +370,8 @@ private fun Preview() { sequenceTriggerTimeout = 1000, defaultSequenceTriggerTimeout = 1000, - showVibrateDuration = true, - vibrateDuration = 100, + showVibrateSettings = true, + vibrateEffect = null, defaultVibrateDuration = 100, showVibrate = true, @@ -402,8 +380,7 @@ private fun Preview() { showLongPressDoubleVibration = true, longPressDoubleVibration = false, - triggerFromOtherApps = true, - keyMapUid = "00000-00000-00000-0000000000000000000000000000000000", + keyMapUid = "beea7ef5-e33e-4bd3-9987-9002e5035f23", isLauncherShortcutButtonEnabled = false, showToast = true, @@ -416,3 +393,47 @@ private fun Preview() { } } } + +@Preview +@Composable +private fun PreviewEvdevTrigger() { + KeyMapperTheme { + Surface { + KeyMapOptionsScreen( + state = State.Data( + KeyMapOptionsState( + showLongPressDelay = false, + longPressDelay = 300, + defaultLongPressDelay = 400, + + showDoublePressDelay = false, + doublePressDelay = 100, + defaultDoublePressDelay = 100, + + showSequenceTriggerTimeout = false, + sequenceTriggerTimeout = 1000, + defaultSequenceTriggerTimeout = 1000, + + showVibrateSettings = false, + vibrateEffect = null, + defaultVibrateDuration = 100, + + showVibrate = true, + vibrate = false, + + showLongPressDoubleVibration = false, + longPressDoubleVibration = false, + + keyMapUid = "beea7ef5-e33e-4bd3-9987-9002e5035f23", + isLauncherShortcutButtonEnabled = true, + + showToast = false, + showScreenOffTip = false, + isExpertModeStarted = true, + ), + ), + callback = object : KeyMapOptionsCallback {}, + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerByIntentScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerByIntentScreen.kt new file mode 100644 index 0000000000..0fd3092c8a --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerByIntentScreen.kt @@ -0,0 +1,87 @@ +package io.github.sds100.keymapper.base.keymaps + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import io.github.sds100.keymapper.base.IntentApi +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.CodeBlock + +@Composable +fun TriggerByIntentScreen( + modifier: Modifier = Modifier, + keyMapUid: String, + onBackClick: () -> Unit, +) { + // Use the package name of this build so the .debug and .ci builds show the correct one. + val packageName = LocalContext.current.packageName + val clipboardLabel = stringResource(R.string.intent_screen_clipboard_label) + + IntentScreen( + modifier = modifier, + title = stringResource(R.string.key_map_options_trigger_by_intent_title), + text = stringResource(R.string.intent_screen_trigger_message), + onBackClick = onBackClick, + ) { + Text( + text = stringResource(R.string.intent_screen_target_explanation), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_package), + code = packageName, + clipboardLabel = clipboardLabel, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_class), + code = IntentApi.TRIGGER_RECEIVER_CLASS, + clipboardLabel = clipboardLabel, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_action), + code = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID, + clipboardLabel = clipboardLabel, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_extra_name), + code = EXTRA_KEYMAP_UID, + clipboardLabel = clipboardLabel, + ) + + CodeBlock( + label = stringResource(R.string.intent_screen_label_extra_value), + code = keyMapUid, + clipboardLabel = clipboardLabel, + ) + + AdbSection( + command = buildAdbCommand( + packageName = packageName, + receiverClass = IntentApi.TRIGGER_RECEIVER_CLASS, + action = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID, + keyMapUid = keyMapUid, + ), + ) + } +} + +@Preview +@Composable +private fun TriggerByIntentScreenPreview() { + KeyMapperTheme { + TriggerByIntentScreen( + keyMapUid = "beea7ef5-e33e-4bd3-9987-9002e5035f23", + onBackClick = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerVibrationBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerVibrationBottomSheet.kt new file mode 100644 index 0000000000..67a554b854 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/keymaps/TriggerVibrationBottomSheet.kt @@ -0,0 +1,156 @@ +package io.github.sds100.keymapper.base.keymaps + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.VibrationEffectMode +import io.github.sds100.keymapper.base.utils.ui.compose.VibrationEffectPickerContent +import io.github.sds100.keymapper.base.vibration.VibrateConfigState +import io.github.sds100.keymapper.base.vibration.VibrateEffect +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TriggerVibrationBottomSheet(viewModel: ConfigKeyMapOptionsViewModel) { + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val state = viewModel.vibrateConfigState + + if (state != null) { + TriggerVibrationBottomSheet( + sheetState = sheetState, + state = state, + onDismissRequest = viewModel::closeVibrateConfig, + onModeChange = viewModel::onVibrateModeChange, + onDurationChange = viewModel::onVibrateDurationChange, + onPredefinedTypeChange = viewModel::onVibratePredefinedTypeChange, + onDoneClick = { + scope.launch { + sheetState.hide() + viewModel.onDoneVibrateConfigClick() + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TriggerVibrationBottomSheet( + sheetState: SheetState, + state: VibrateConfigState, + onDismissRequest: () -> Unit = {}, + onModeChange: (VibrationEffectMode) -> Unit = {}, + onDurationChange: (Int) -> Unit = {}, + onPredefinedTypeChange: (VibrateEffect.PredefinedType) -> Unit = {}, + onDoneClick: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + ) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + textAlign = TextAlign.Center, + text = stringResource(R.string.flag_vibrate), + style = MaterialTheme.typography.headlineMedium, + ) + + VibrationEffectPickerContent( + modifier = Modifier.fillMaxWidth(), + mode = state.mode, + onModeChange = onModeChange, + durationMs = state.durationMs, + onDurationChange = onDurationChange, + defaultDurationMs = state.defaultDurationMs, + predefinedType = state.predefinedType, + onPredefinedTypeChange = onPredefinedTypeChange, + isPredefinedSupported = state.isPredefinedSupported, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + scope.launch { + sheetState.hide() + onDismissRequest() + } + }, + ) { + Text(stringResource(R.string.neg_cancel)) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Button( + modifier = Modifier.weight(1f), + onClick = onDoneClick, + ) { + Text(stringResource(R.string.pos_done)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showSystemUi = true) +@Composable +private fun TriggerVibrationBottomSheetPreview() { + KeyMapperTheme { + val sheetState = SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + initialValue = SheetValue.Expanded, + ) + + TriggerVibrationBottomSheet( + sheetState = sheetState, + state = VibrateConfigState(durationMs = 200, defaultDurationMs = 200), + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt index 1448d46ea7..37c8f4b83e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutScreen.kt @@ -46,7 +46,6 @@ import io.github.sds100.keymapper.base.home.KeyMapAppBarState import io.github.sds100.keymapper.base.home.KeyMapList import io.github.sds100.keymapper.base.home.KeyMapListState import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel -import io.github.sds100.keymapper.base.trigger.TriggerError import io.github.sds100.keymapper.base.utils.ui.UnsavedChangesDialog import io.github.sds100.keymapper.base.utils.ui.compose.ComposeChipModel import io.github.sds100.keymapper.base.utils.ui.compose.ComposeIconInfo @@ -308,8 +307,8 @@ private fun keyMapSampleList(): List { ), ), options = listOf("Vibrate"), - triggerErrors = listOf(TriggerError.DND_ACCESS_DENIED), - extraInfo = null, + isEnabled = false, + hasError = true, ), ), KeyMapListItemModel( @@ -322,8 +321,8 @@ private fun keyMapSampleList(): List { constraintMode = ConstraintMode.OR, constraints = emptyList(), options = emptyList(), - triggerErrors = emptyList(), - extraInfo = "Disabled • No trigger", + isEnabled = true, + hasError = false, ), ), ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutUseCase.kt index 53a8734296..f5be0de7d3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutUseCase.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.graphics.drawable.Drawable import androidx.core.os.bundleOf import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.base.IntentApi import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.system.apps.AppShortcutAdapter @@ -14,12 +15,6 @@ class CreateKeyMapShortcutUseCaseImpl @Inject constructor( private val appShortcutAdapter: AppShortcutAdapter, ) : CreateKeyMapShortcutUseCase { - companion object { - private const val ACTION_TRIGGER_KEYMAP_BY_UID = - "io.github.sds100.keymapper.ACTION_TRIGGER_KEYMAP_BY_UID" - private const val EXTRA_KEYMAP_UID = "io.github.sds100.keymapper.EXTRA_KEYMAP_UID" - } - override val isSupported: Boolean get() = appShortcutAdapter.areLauncherShortcutsSupported @@ -32,15 +27,15 @@ class CreateKeyMapShortcutUseCaseImpl @Inject constructor( appShortcutAdapter.createLauncherShortcut( iconResId = R.mipmap.ic_launcher_round, label = shortcutLabel, - intentAction = ACTION_TRIGGER_KEYMAP_BY_UID, - bundleOf(EXTRA_KEYMAP_UID to keyMapUid), + intentAction = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID, + bundleOf(IntentApi.EXTRA_KEYMAP_UID to keyMapUid), ) } else { appShortcutAdapter.createLauncherShortcut( icon = icon, label = shortcutLabel, - intentAction = ACTION_TRIGGER_KEYMAP_BY_UID, - bundleOf(EXTRA_KEYMAP_UID to keyMapUid), + intentAction = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID, + bundleOf(IntentApi.EXTRA_KEYMAP_UID to keyMapUid), ) } return appShortcutAdapter.pinShortcut(shortcut) @@ -51,15 +46,15 @@ class CreateKeyMapShortcutUseCaseImpl @Inject constructor( appShortcutAdapter.createLauncherShortcut( iconResId = R.mipmap.ic_launcher_round, label = shortcutLabel, - intentAction = ACTION_TRIGGER_KEYMAP_BY_UID, - bundleOf(EXTRA_KEYMAP_UID to keyMapUid), + intentAction = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID, + bundleOf(IntentApi.EXTRA_KEYMAP_UID to keyMapUid), ) } else { appShortcutAdapter.createLauncherShortcut( icon = icon, label = shortcutLabel, - intentAction = ACTION_TRIGGER_KEYMAP_BY_UID, - bundleOf(EXTRA_KEYMAP_UID to keyMapUid), + intentAction = IntentApi.ACTION_TRIGGER_KEYMAP_BY_UID, + bundleOf(IntentApi.EXTRA_KEYMAP_UID to keyMapUid), ) } return appShortcutAdapter.createShortcutResultIntent(shortcut) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutViewModel.kt index 0c692779c2..28f8d85367 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/shortcuts/CreateKeyMapShortcutViewModel.kt @@ -21,7 +21,6 @@ import io.github.sds100.keymapper.base.home.KeyMapListItemCreator import io.github.sds100.keymapper.base.home.KeyMapListState import io.github.sds100.keymapper.base.home.ListKeyMapsUseCase import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState -import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCase import io.github.sds100.keymapper.base.trigger.KeyMapListItemModel import io.github.sds100.keymapper.base.trigger.TriggerErrorSnapshot import io.github.sds100.keymapper.base.utils.ui.ResourceProvider @@ -43,7 +42,6 @@ import kotlinx.coroutines.launch @HiltViewModel class CreateKeyMapShortcutViewModel @Inject constructor( private val configKeyMapState: ConfigKeyMapState, - private val configTrigger: ConfigTriggerUseCase, private val listKeyMaps: ListKeyMapsUseCase, private val createKeyMapShortcut: CreateKeyMapShortcutUseCase, private val resourceProvider: ResourceProvider, @@ -120,10 +118,10 @@ class CreateKeyMapShortcutViewModel @Inject constructor( } } - val subGroupListItems = keyMapGroup.subGroups.map { group -> + val subGroupListItems = keyMapGroup.subGroups.map { (group, _, _) -> var icon: ComposeIconInfo? = null - val constraint = group.constraintState.constraints.firstOrNull() + val constraint = group.constraintState.allConstraints.firstOrNull() if (constraint != null) { icon = constraintUiHelper.getIcon(constraint) } @@ -159,7 +157,7 @@ class CreateKeyMapShortcutViewModel @Inject constructor( isEditingGroupName = false, isNewGroup = false, parentConstraintCount = keyMapGroup.parents.sumOf { - it.constraintState.constraints.size + it.constraintState.allConstraints.size }, keyMapsEnabled = null, ) @@ -175,7 +173,6 @@ class CreateKeyMapShortcutViewModel @Inject constructor( if (state.keyMaps !is State.Data) return@launch configKeyMapState.loadKeyMap(uid) - configTrigger.setTriggerFromOtherAppsEnabled(true) val keyMapState = configKeyMapState.keyMap.first() @@ -204,7 +201,9 @@ class CreateKeyMapShortcutViewModel @Inject constructor( // background is white. Also, getting the colorOnSurface attribute // from the application context doesn't seem to work correctly. TintType.OnSurface -> iconInfo.drawable.setTint(Color.BLACK) + is TintType.Color -> iconInfo.drawable.setTint(iconInfo.tintType.color) + else -> {} } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt index fa3e7d9a7a..c095902c40 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/SortBottomSheetContent.kt @@ -286,8 +286,10 @@ private fun SortDraggableList( onSortFieldClick: (SortField) -> Unit, ) { val lazyListState = rememberLazyListState() + val sortFields = remember(sortFieldOrderList) { sortFieldOrderList.map { it.field } } val dragDropState = rememberDragDropState( lazyListState = lazyListState, + keys = sortFields, onMove = onMove, ) @@ -296,13 +298,13 @@ private fun SortDraggableList( state = lazyListState, ) { itemsIndexed( - items = sortFieldOrderList, + items = dragDropState.ordered(sortFieldOrderList) { it.field }, key = { _, item -> item.field }, ) { index, item -> DraggableItem( dragDropState = dragDropState, - index = index, + key = item.field, ) { isDragging -> SortFieldListItem( index = index + 1, @@ -311,17 +313,7 @@ private fun SortDraggableList( onToggle = { onSortFieldClick(item.field) }, isDragging = isDragging, onDrag = { dragDropState.onDrag(it) }, - onDragStarted = { offset -> - // Calculate the offset of the item in the list - val lazyItem = lazyListState.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == index } ?: return@SortFieldListItem - - val initialOffset = lazyItem.offset - - val finalOffset = offset + Offset(0f, initialOffset.toFloat()) - - dragDropState.onDragStart(finalOffset) - }, + onDragStarted = { dragDropState.onDragStart(item.field) }, onDragStopped = { dragDropState.onDragInterrupted() }, ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt index 21cfdde574..2d7218b399 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapConstraintsComparator.kt @@ -24,14 +24,14 @@ class KeyMapConstraintsComparator( return 0 } - val keyMapConstraintsLength = keyMap.constraintState.constraints.size - val otherKeyMapConstraintsLength = otherKeyMap.constraintState.constraints.size + val keyMapConstraintsLength = keyMap.constraintState.allConstraints.size + val otherKeyMapConstraintsLength = otherKeyMap.constraintState.allConstraints.size val maxLength = keyMapConstraintsLength.coerceAtMost(otherKeyMapConstraintsLength) // Compare constraints one by one for (i in 0 until maxLength) { - val constraint = keyMap.constraintState.constraints.elementAt(i) - val otherConstraint = otherKeyMap.constraintState.constraints.elementAt(i) + val constraint = keyMap.constraintState.allConstraints.elementAt(i) + val otherConstraint = otherKeyMap.constraintState.allConstraints.elementAt(i) val result = compareConstraints(constraint, otherConstraint) @@ -85,55 +85,63 @@ class KeyMapConstraintsComparator( is ConstraintData.AppInForeground -> displayConstraints.getAppName( constraint.data.packageName, ) - is ConstraintData.AppNotInForeground -> displayConstraints.getAppName( - constraint.data.packageName, - ) - is ConstraintData.AppNotPlayingMedia -> displayConstraints.getAppName( - constraint.data.packageName, - ) + is ConstraintData.AppPlayingMedia -> displayConstraints.getAppName( constraint.data.packageName, ) + is ConstraintData.BtDeviceConnected -> Success(constraint.data.deviceName) - is ConstraintData.BtDeviceDisconnected -> Success(constraint.data.deviceName) + is ConstraintData.Charging -> Success("") + is ConstraintData.DeviceIsLocked -> Success("") - is ConstraintData.DeviceIsUnlocked -> Success("") - is ConstraintData.Discharging -> Success("") - is ConstraintData.FlashlightOff -> Success(constraint.data.lens.toString()) + is ConstraintData.FlashlightOn -> Success(constraint.data.lens.toString()) + is ConstraintData.ImeChosen -> Success(constraint.data.imeLabel) - is ConstraintData.ImeNotChosen -> Success(constraint.data.imeLabel) + is ConstraintData.InPhoneCall -> Success("") + is ConstraintData.MediaPlaying -> Success("") - is ConstraintData.NoMediaPlaying -> Success("") + + is ConstraintData.NotificationPosted -> Success( + when (constraint.data) { + is ConstraintData.NotificationPosted.FromApp -> + displayConstraints.getAppName(constraint.data.packageName) + .valueOrNull() ?: constraint.data.packageName + + is ConstraintData.NotificationPosted.TextMatch -> constraint.data.text + }, + ) + is ConstraintData.NotInPhoneCall -> Success("") + is ConstraintData.OrientationCustom -> Success(constraint.data.orientation.toString()) + is ConstraintData.OrientationLandscape -> Success("") + is ConstraintData.OrientationPortrait -> Success("") + is ConstraintData.PhoneRinging -> Success("") + is ConstraintData.RingerMode -> Success("") - is ConstraintData.ScreenOff -> Success("") + is ConstraintData.ScreenOn -> Success("") + is ConstraintData.DisplayResolution -> Success( "${constraint.data.width}x${constraint.data.height}", ) - is ConstraintData.WifiConnected -> if (constraint.data.ssid == null) { - Success("") - } else { - Success(constraint.data.ssid) - } - is ConstraintData.WifiDisconnected -> if (constraint.data.ssid == null) { + is ConstraintData.WifiConnected -> if (constraint.data.ssid == null) { Success("") } else { Success(constraint.data.ssid) } - is ConstraintData.WifiOff -> Success("") is ConstraintData.WifiOn -> Success("") - is ConstraintData.LockScreenNotShowing -> Success("") + is ConstraintData.LockScreenShowing -> Success("") + is ConstraintData.Time -> Success( constraint.data.startTime .toEpochSecond(LocalDate.now(), ZoneOffset.UTC) @@ -141,14 +149,16 @@ class KeyMapConstraintsComparator( ) ConstraintData.HingeClosed -> Success("") + ConstraintData.HingeOpen -> Success("") - ConstraintData.KeyboardNotShowing -> Success("") + ConstraintData.KeyboardShowing -> Success("") + is ConstraintData.PhysicalOrientation -> Success( constraint.data.physicalOrientation.toString(), ) + ConstraintData.NotificationPanelShowing -> Success("") - ConstraintData.NotificationPanelNotShowing -> Success("") } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapOptionsComparator.kt b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapOptionsComparator.kt index 130f74f0a1..b09cf0cb8e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapOptionsComparator.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/sorting/comparators/KeyMapOptionsComparator.kt @@ -18,7 +18,6 @@ class KeyMapOptionsComparator( keyMap, otherKeyMap, { it.vibrate }, - { it.trigger.triggerFromOtherApps }, { it.showToast }, ) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppFragment.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppFragment.kt index 60de372248..b5afb37451 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppFragment.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/apps/ChooseAppFragment.kt @@ -19,12 +19,13 @@ import io.github.sds100.keymapper.common.utils.State import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.map +import kotlinx.serialization.json.Json @AndroidEntryPoint class ChooseAppFragment : RecyclerViewFragment() { companion object { - const val EXTRA_PACKAGE_NAME = "extra_package_name" + const val EXTRA_RESULT = "extra_choose_app_result" const val SEARCH_STATE_KEY = "key_app_search_state" } @@ -48,7 +49,7 @@ class ChooseAppFragment : RecyclerViewFragment() + private val _returnResult = MutableSharedFlow() val returnResult = _returnResult.asSharedFlow() var allowHiddenApps: Boolean = false @@ -106,8 +106,9 @@ class ChooseAppViewModel @Inject constructor(private val useCase: DisplayAppsUse fun onListItemClick(id: String) { viewModelScope.launch { val packageName = id + val appName = useCase.getAppName(packageName).valueOrNull() ?: packageName - _returnResult.emit(packageName) + _returnResult.emit(ChooseAppResult(packageName = packageName, appName = appName)) } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt index 60942a8d8b..2db49676cf 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseConfigTriggerViewModel.kt @@ -30,6 +30,7 @@ import io.github.sds100.keymapper.base.utils.ui.LinkType import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.ViewModelHelper import io.github.sds100.keymapper.base.utils.ui.showDialog +import io.github.sds100.keymapper.base.vibration.VibrateConfigDelegate import io.github.sds100.keymapper.common.models.EvdevDeviceInfo import io.github.sds100.keymapper.common.utils.AccessibilityServiceError import io.github.sds100.keymapper.common.utils.InputDeviceUtils @@ -64,6 +65,7 @@ abstract class BaseConfigTriggerViewModel( private val systemBridgeConnectionManager: SystemBridgeConnectionManager, onboardingTipDelegate: OnboardingTipDelegate, triggerSetupDelegate: TriggerSetupDelegate, + private val vibrateConfigDelegate: VibrateConfigDelegate, resourceProvider: ResourceProvider, navigationProvider: NavigationProvider, dialogProvider: DialogProvider, @@ -101,6 +103,7 @@ abstract class BaseConfigTriggerViewModel( dialogProvider, navigationProvider, resourceProvider, + vibrateConfigDelegate, ) private val _state: MutableStateFlow> = diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt index e32b627772..f2faea5c83 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/BaseTriggerScreen.kt @@ -1,6 +1,5 @@ package io.github.sds100.keymapper.base.trigger -import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,12 +25,13 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.window.core.layout.WindowHeightSizeClass @@ -127,7 +127,7 @@ fun BaseTriggerScreen( onButtonClick = { viewModel.onTipButtonClick(tip.id) }, ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) } } @@ -246,8 +246,6 @@ private fun TriggerScreenVertical( } is ConfigTriggerState.Loaded -> { - tipContent() - TriggerList( modifier = Modifier.weight(1f), triggerList = configState.triggerKeys, @@ -257,6 +255,7 @@ private fun TriggerScreenVertical( onMove = onMoveTriggerKey, onFixErrorClick = onFixErrorClick, onAddMoreClick = onAddMoreTriggerKeysClick, + tipContent = tipContent, ) if (configState.clickTypeButtons.isNotEmpty()) { @@ -435,18 +434,18 @@ private fun TriggerList( onFixErrorClick: (TriggerError) -> Unit, onMove: (fromIndex: Int, toIndex: Int) -> Unit, onAddMoreClick: () -> Unit, + tipContent: @Composable () -> Unit = {}, ) { val lazyListState = rememberLazyListState() + val triggerKeyIds = remember(triggerList) { triggerList.map { it.id } } + + // Only the trigger keys can be dragged. Not the "add more" button. val dragDropState = rememberDragDropState( lazyListState = lazyListState, + keys = triggerKeyIds, onMove = onMove, - // Do not drag and drop the "add more" button - ignoreLastItems = if (triggerList.isEmpty()) { - 0 - } else { - 1 - }, ) + val orderedTriggerList = dragDropState.ordered(triggerList) { it.id } // Use dragContainer rather than .draggable() modifier because that causes // dragging the first item to be always be dropped in the next position. @@ -456,20 +455,24 @@ private fun TriggerList( contentPadding = PaddingValues(vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + item(key = "tip", contentType = "tip") { + tipContent() + } + itemsIndexed( - triggerList, + orderedTriggerList, key = { _, item -> item.id }, contentType = { _, _ -> "key" }, ) { index, model -> DraggableItem( dragDropState = dragDropState, - index = index, + key = model.id, ) { isDragging -> TriggerKeyListItem( modifier = Modifier.fillMaxWidth(), model = model, index = index, - isDraggingEnabled = triggerList.size > 1, + isDraggingEnabled = orderedTriggerList.size > 1, isDragging = isDragging, isReorderingEnabled = isReorderingEnabled, dragDropState = dragDropState, @@ -481,7 +484,7 @@ private fun TriggerList( } else { null }, - onMoveDown = if (isReorderingEnabled && index < triggerList.size - 1) { + onMoveDown = if (isReorderingEnabled && index < orderedTriggerList.size - 1) { { onMove(index, index + 1) } } else { null @@ -625,7 +628,7 @@ private val previewState = triggerModeButtonsVisible = true, ) -@Preview(device = Devices.PIXEL) +@PreviewLightDark @Composable private fun VerticalPreview() { KeyMapperTheme { @@ -640,24 +643,20 @@ private fun VerticalPreview() { discoverScreenContent = { TriggerDiscoverScreen() }, - ) - } -} + tipContent = { + TipCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + title = "Tip Title", + message = """ + This is a tip message to help the user understand something about the + current screen. It can be quite long so it should wrap properly. + """.trimIndent(), + onDismiss = {}, + ) -@Preview(device = Devices.PIXEL) -@Composable -private fun VerticalPreviewDark() { - KeyMapperTheme(darkTheme = true) { - TriggerScreenVertical( - configState = previewState, - recordTriggerState = RecordTriggerState.Idle, - expertModeSwitchState = ExpertModeRecordSwitchState( - isVisible = true, - isChecked = false, - isEnabled = true, - ), - discoverScreenContent = { - TriggerDiscoverScreen() + Spacer(Modifier.height(16.dp)) }, ) } @@ -725,7 +724,7 @@ private fun PreviewSquareRectangle() { } } -@Preview(device = Devices.PIXEL) +@PreviewLightDark @Composable private fun VerticalEmptyPreview() { KeyMapperTheme { @@ -744,25 +743,6 @@ private fun VerticalEmptyPreview() { } } -@Preview(device = Devices.PIXEL, uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun VerticalEmptyDarkPreview() { - KeyMapperTheme { - TriggerScreenVertical( - configState = ConfigTriggerState.Empty, - recordTriggerState = RecordTriggerState.Idle, - expertModeSwitchState = ExpertModeRecordSwitchState( - isVisible = true, - isChecked = true, - isEnabled = false, - ), - discoverScreenContent = { - TriggerDiscoverScreen() - }, - ) - } -} - @Preview(widthDp = 800, heightDp = 300) @Composable private fun HorizontalPreview() { @@ -791,7 +771,7 @@ private fun HorizontalPreview() { onDismiss = {}, ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) }, ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt index 8839dccb64..7c6ab99c09 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegate.kt @@ -3,6 +3,7 @@ package io.github.sds100.keymapper.base.trigger import io.github.sds100.keymapper.base.floating.FloatingButtonData import io.github.sds100.keymapper.base.keymaps.ClickType import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.models.EvdevDeviceInfo import io.github.sds100.keymapper.system.inputevents.KeyEventUtils @@ -435,16 +436,23 @@ class ConfigTriggerDelegate { return trigger.copy(vibrate = enabled).validate() } - fun setVibrationDuration( + fun setVibrateEffect( trigger: Trigger, - duration: Int, + effect: VibrateEffect?, defaultVibrateDuration: Int, ): Trigger { - return if (duration == defaultVibrateDuration) { - trigger.copy(vibrateDuration = null).validate() + // Store null (use the app-wide default, which can change later) rather than a + // custom duration that happens to currently match it. + val normalizedEffect = if ( + effect is VibrateEffect.CustomDuration && + effect.durationMs == defaultVibrateDuration.toLong() + ) { + null } else { - trigger.copy(vibrateDuration = duration).validate() + effect } + + return trigger.copy(vibrateEffect = normalizedEffect).validate() } fun setLongPressDelay(trigger: Trigger, delay: Int, defaultLongPressDelay: Int): Trigger { @@ -479,10 +487,6 @@ class ConfigTriggerDelegate { return trigger.copy(longPressDoubleVibration = enabled).validate() } - fun setTriggerFromOtherAppsEnabled(trigger: Trigger, enabled: Boolean): Trigger { - return trigger.copy(triggerFromOtherApps = enabled).validate() - } - fun setShowToastEnabled(trigger: Trigger, enabled: Boolean): Trigger { return trigger.copy(showToast = enabled).validate() } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerUseCase.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerUseCase.kt index 1b348dc47e..05bb1e01e3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerUseCase.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerUseCase.kt @@ -7,6 +7,7 @@ import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapState import io.github.sds100.keymapper.base.keymaps.GetDefaultKeyMapOptionsUseCase import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType +import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.models.EvdevDeviceInfo import io.github.sds100.keymapper.common.utils.InputDeviceUtils import io.github.sds100.keymapper.common.utils.State @@ -212,8 +213,8 @@ class ConfigTriggerUseCaseImpl @Inject constructor( delegate.setVibrateEnabled(trigger, enabled) } - override fun setVibrationDuration(duration: Int) = updateTrigger { trigger -> - delegate.setVibrationDuration(trigger, duration, defaultVibrateDuration.value) + override fun setVibrateEffect(effect: VibrateEffect?) = updateTrigger { trigger -> + delegate.setVibrateEffect(trigger, effect, defaultVibrateDuration.value) } override fun setLongPressDelay(delay: Int) = updateTrigger { trigger -> @@ -238,12 +239,6 @@ class ConfigTriggerUseCaseImpl @Inject constructor( } } - override fun setTriggerFromOtherAppsEnabled(enabled: Boolean) { - updateTrigger { trigger -> - delegate.setTriggerFromOtherAppsEnabled(trigger, enabled) - } - } - override fun setShowToastEnabled(enabled: Boolean) { updateTrigger { trigger -> delegate.setShowToastEnabled(trigger, enabled) @@ -333,12 +328,11 @@ interface ConfigTriggerUseCase : GetDefaultKeyMapOptionsUseCase { fun setFingerprintGestureType(keyUid: String, type: FingerprintGestureType) fun setVibrateEnabled(enabled: Boolean) - fun setVibrationDuration(duration: Int) + fun setVibrateEffect(effect: VibrateEffect?) fun setLongPressDelay(delay: Int) fun setDoublePressDelay(delay: Int) fun setSequenceTriggerTimeout(delay: Int) fun setLongPressDoubleVibrationEnabled(enabled: Boolean) - fun setTriggerFromOtherAppsEnabled(enabled: Boolean) fun setShowToastEnabled(enabled: Boolean) fun setScanCodeDetectionEnabled(keyUid: String, enabled: Boolean) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt index 8d2dd8ea67..34f80b2666 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/KeyMapListItemModel.kt @@ -10,12 +10,12 @@ data class KeyMapListItemModel(val isSelected: Boolean, val content: Content) { data class Content( val uid: String, val triggerKeys: List, - val triggerErrors: List, val triggerSeparatorIcon: ImageVector, val actions: List, val constraintMode: ConstraintMode, val constraints: List, val options: List, - val extraInfo: String?, + val isEnabled: Boolean, + val hasError: Boolean, ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt index 4150648257..6c2e2b944b 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/Trigger.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.base.trigger import io.github.sds100.keymapper.base.floating.FloatingButtonEntityMapper import io.github.sds100.keymapper.base.keymaps.ClickType +import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.utils.hasFlag import io.github.sds100.keymapper.common.utils.valueOrNull import io.github.sds100.keymapper.common.utils.withFlag @@ -24,9 +25,8 @@ data class Trigger( val longPressDoubleVibration: Boolean = false, val longPressDelay: Int? = null, val doublePressDelay: Int? = null, - val vibrateDuration: Int? = null, + val vibrateEffect: VibrateEffect? = null, val sequenceTriggerTimeout: Int? = null, - val triggerFromOtherApps: Boolean = false, val showToast: Boolean = false, /** @@ -90,24 +90,29 @@ object TriggerEntityMapper { val keys = entity.keys.map { key -> when (key) { is AssistantTriggerKeyEntity -> AssistantTriggerKey.fromEntity(key) + is KeyEventTriggerKeyEntity -> KeyEventTriggerKey.fromEntity( key, ) + is FloatingButtonKeyEntity -> { val floatingButton = floatingButtons.find { it.button.uid == key.buttonUid } FloatingButtonKey.fromEntity(key, floatingButton) } is FingerprintTriggerKeyEntity -> FingerprintTriggerKey.fromEntity(key) + is EvdevTriggerKeyEntity -> EvdevTriggerKey.fromEntity(key) } } val mode = when { entity.mode == TriggerEntity.SEQUENCE && keys.size > 1 -> TriggerMode.Sequence + entity.mode == TriggerEntity.PARALLEL && keys.size > 1 -> TriggerMode.Parallel( keys[0].clickType, ) + else -> TriggerMode.Undefined } @@ -127,15 +132,13 @@ object TriggerEntityMapper { doublePressDelay = entity.extras.getData(TriggerEntity.EXTRA_DOUBLE_PRESS_DELAY) .valueOrNull()?.toIntOrNull(), - vibrateDuration = entity.extras.getData(TriggerEntity.EXTRA_VIBRATION_DURATION) - .valueOrNull()?.toIntOrNull(), + vibrateEffect = readVibrateEffect(entity), sequenceTriggerTimeout = entity.extras.getData( TriggerEntity.EXTRA_SEQUENCE_TRIGGER_TIMEOUT, ) .valueOrNull()?.toIntOrNull(), - triggerFromOtherApps = entity.flags.hasFlag(TriggerEntity.TRIGGER_FLAG_FROM_OTHER_APPS), showToast = entity.flags.hasFlag(TriggerEntity.TRIGGER_FLAG_SHOW_TOAST), legacyDetectScreenOff = entity.flags.hasFlag( TriggerEntity.TRIGGER_FLAG_SCREEN_OFF_TRIGGERS, @@ -175,13 +178,40 @@ object TriggerEntityMapper { ) } - if (trigger.isChangingVibrationDurationAllowed() && trigger.vibrateDuration != null) { - extras.add( - EntityExtra( - TriggerEntity.EXTRA_VIBRATION_DURATION, - trigger.vibrateDuration.toString(), - ), - ) + if (trigger.isChangingVibrationDurationAllowed()) { + when (val effect = trigger.vibrateEffect) { + is VibrateEffect.CustomDuration -> { + extras.add( + EntityExtra( + TriggerEntity.EXTRA_VIBRATE_MODE, + TriggerEntity.VIBRATE_MODE_DURATION, + ), + ) + extras.add( + EntityExtra( + TriggerEntity.EXTRA_VIBRATION_DURATION, + effect.durationMs.toString(), + ), + ) + } + + is VibrateEffect.Predefined -> { + extras.add( + EntityExtra( + TriggerEntity.EXTRA_VIBRATE_MODE, + TriggerEntity.VIBRATE_MODE_PREDEFINED, + ), + ) + extras.add( + EntityExtra( + TriggerEntity.EXTRA_VIBRATE_EFFECT_TYPE, + effect.predefinedType.name, + ), + ) + } + + null -> {} + } } val mode = when (trigger.mode) { @@ -200,10 +230,6 @@ object TriggerEntityMapper { flags = flags.withFlag(TriggerEntity.TRIGGER_FLAG_LONG_PRESS_DOUBLE_VIBRATION) } - if (trigger.triggerFromOtherApps) { - flags = flags.withFlag(TriggerEntity.TRIGGER_FLAG_FROM_OTHER_APPS) - } - if (trigger.showToast) { flags = flags.withFlag(TriggerEntity.TRIGGER_FLAG_SHOW_TOAST) } @@ -218,11 +244,15 @@ object TriggerEntityMapper { val keys = trigger.keys.map { key -> when (key) { is AssistantTriggerKey -> AssistantTriggerKey.toEntity(key) + is KeyEventTriggerKey -> KeyEventTriggerKey.toEntity( key, ) + is FloatingButtonKey -> FloatingButtonKey.toEntity(key) + is FingerprintTriggerKey -> FingerprintTriggerKey.toEntity(key) + is EvdevTriggerKey -> EvdevTriggerKey.toEntity(key) } } @@ -234,4 +264,42 @@ object TriggerEntityMapper { flags = flags, ) } + + private fun readVibrateEffect(entity: TriggerEntity): VibrateEffect? { + val mode = entity.extras.getData(TriggerEntity.EXTRA_VIBRATE_MODE).valueOrNull() + + if (mode != null) { + return when (mode) { + TriggerEntity.VIBRATE_MODE_DURATION -> { + val durationMs = entity.extras.getData( + TriggerEntity.EXTRA_VIBRATION_DURATION, + ).valueOrNull()?.toLongOrNull() ?: return null + + VibrateEffect.CustomDuration(durationMs) + } + + TriggerEntity.VIBRATE_MODE_PREDEFINED -> { + val typeString = entity.extras.getData( + TriggerEntity.EXTRA_VIBRATE_EFFECT_TYPE, + ).valueOrNull() ?: return null + + val type = try { + VibrateEffect.PredefinedType.valueOf(typeString) + } catch (_: IllegalArgumentException) { + return null + } + + VibrateEffect.Predefined(type) + } + + else -> null + } + } + + // Key maps saved before this field existed only stored a raw duration. + val legacyDurationMs = entity.extras.getData(TriggerEntity.EXTRA_VIBRATION_DURATION) + .valueOrNull()?.toIntOrNull() ?: return null + + return VibrateEffect.CustomDuration(legacyDurationMs.toLong()) + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt index 6370ac90b2..79a29c0068 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyListItem.kt @@ -22,12 +22,10 @@ import androidx.compose.material.icons.outlined.OfflineBolt import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.ArrowDownward -import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.DeleteOutline import androidx.compose.material.icons.rounded.DragHandle -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalMinimumInteractiveComponentSize @@ -49,6 +47,7 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.keymaps.ClickType import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType import io.github.sds100.keymapper.base.utils.ui.LinkType +import io.github.sds100.keymapper.base.utils.ui.compose.CompactErrorButton import io.github.sds100.keymapper.base.utils.ui.compose.DragDropState @Composable @@ -127,8 +126,8 @@ fun TriggerKeyListItem( enabled = isDraggingEnabled, orientation = Orientation.Vertical, startDragImmediately = true, - onDragStarted = { offset -> - dragDropState?.onDragStart(index, offset) + onDragStarted = { + dragDropState?.onDragStart(model.id) }, onDragStopped = { dragDropState?.onDragInterrupted() }, ), @@ -157,11 +156,11 @@ fun TriggerKeyListItem( contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, ) + + Spacer(Modifier.width(8.dp)) } } - Spacer(Modifier.width(8.dp)) - if (model.error == null) { val clickTypeString = when (model.clickType) { ClickType.SHORT_PRESS -> null @@ -194,24 +193,19 @@ fun TriggerKeyListItem( ) } + if (model.error?.isFixable ?: false) { + Spacer(Modifier.width(8.dp)) + + CompactErrorButton(onClick = { onFixClick(model.error!!) }) { + Text(text = stringResource(R.string.button_fix)) + } + + Spacer(Modifier.width(4.dp)) + } + CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 16.dp, ) { - if (model.error != null && model.error?.isFixable ?: false) { - FilledTonalButton( - modifier = Modifier.padding(start = 8.dp, end = 8.dp), - onClick = { onFixClick(model.error!!) }, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ), - ) { - Text( - text = stringResource(R.string.button_fix), - ) - } - } - if (model !is TriggerKeyListItemModel.FloatingButtonDeleted) { IconButton(onClick = onEditClick) { Icon( @@ -227,11 +221,11 @@ fun TriggerKeyListItem( IconButton(onClick = onRemoveClick) { Icon( - imageVector = Icons.Rounded.Clear, + imageVector = Icons.Rounded.DeleteOutline, contentDescription = stringResource( R.string.trigger_key_list_item_remove, ), - tint = MaterialTheme.colorScheme.onSurface, + tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp), ) } @@ -375,20 +369,20 @@ private fun TextColumn( ) { Text( text = primaryText, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) if (secondaryText != null) { Text( text = secondaryText, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface, ) } if (tertiaryText != null) { Text( text = tertiaryText, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface, ) } @@ -396,13 +390,17 @@ private fun TextColumn( } @Composable -private fun ErrorTextColumn(modifier: Modifier = Modifier, primaryText: String, errorText: String) { +private fun ErrorTextColumn( + modifier: Modifier = Modifier, + primaryText: String, + errorText: String, +) { Column( modifier = modifier, ) { Text( text = primaryText, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -410,7 +408,7 @@ private fun ErrorTextColumn(modifier: Modifier = Modifier, primaryText: String, Text( text = errorText, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.error, ) } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt index 24c1b11264..00989420cd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/trigger/TriggerKeyOptionsBottomSheet.kt @@ -42,9 +42,9 @@ import io.github.sds100.keymapper.base.compose.KeyMapperTheme import io.github.sds100.keymapper.base.keymaps.ClickType import io.github.sds100.keymapper.base.system.accessibility.FingerprintGestureType import io.github.sds100.keymapper.base.utils.ui.CheckBoxListItem -import io.github.sds100.keymapper.base.utils.ui.compose.CheckBoxText import io.github.sds100.keymapper.base.utils.ui.compose.KeyMapperSegmentedButtonRow import io.github.sds100.keymapper.base.utils.ui.compose.RadioButtonText +import io.github.sds100.keymapper.base.utils.ui.compose.SwitchText import io.github.sds100.keymapper.base.utils.ui.compose.openUriSafe import io.github.sds100.keymapper.system.inputevents.Scancode import kotlinx.coroutines.launch @@ -110,7 +110,7 @@ fun TriggerKeyOptionsBottomSheet( isCompact = isCompact, ) - CheckBoxText( + SwitchText( modifier = Modifier.padding(8.dp), text = stringResource(R.string.flag_dont_override_default_action), isChecked = state.doNotRemapChecked, @@ -129,7 +129,7 @@ fun TriggerKeyOptionsBottomSheet( isCompact = isCompact, ) - CheckBoxText( + SwitchText( modifier = Modifier.padding(8.dp), text = stringResource(R.string.flag_dont_override_default_action), isChecked = state.doNotRemapChecked, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt index d0dd8793ea..187ffb2137 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ErrorUtils.kt @@ -6,6 +6,7 @@ import android.telephony.SmsManager import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.purchasing.PurchasingError import io.github.sds100.keymapper.base.purchasing.RevenueCatEntitlementId +import io.github.sds100.keymapper.base.trigger.TriggerError import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.base.utils.ui.ResourceProviderImpl import io.github.sds100.keymapper.common.utils.AccessibilityServiceError @@ -85,16 +86,19 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { resourceProvider.getString(resId) } - is KMError.AppNotFound -> - resourceProvider.getString( - R.string.error_app_isnt_installed, - packageName, - ) + is KMError.AppNotFound -> { + val name = appName + if (name != null) { + resourceProvider.getString(R.string.error_app_isnt_installed_named, name) + } else { + resourceProvider.getString(R.string.error_app_isnt_installed, packageName) + } + } is KMError.AppDisabled -> resourceProvider.getString( R.string.error_app_is_disabled_package_name, - this.packageName, + this.appName ?: this.packageName, ) is KMError.NoCompatibleImeEnabled -> @@ -612,3 +616,38 @@ val KMError.isFixable: Boolean else -> false } + +fun TriggerError.getFullMessage(resourceProvider: ResourceProvider): String = when (this) { + TriggerError.DND_ACCESS_DENIED -> + resourceProvider.getString(R.string.trigger_error_dnd_access_denied) + + TriggerError.CANT_DETECT_IN_PHONE_CALL -> + resourceProvider.getString(R.string.trigger_error_cant_detect_in_phone_call) + + TriggerError.ASSISTANT_TRIGGER_NOT_PURCHASED -> + resourceProvider.getString(R.string.trigger_error_assistant_not_purchased) + + TriggerError.DPAD_IME_NOT_SELECTED -> + resourceProvider.getString(R.string.trigger_error_dpad_ime_not_selected) + + TriggerError.FLOATING_BUTTON_DELETED -> + resourceProvider.getString(R.string.trigger_error_floating_button_deleted) + + TriggerError.FLOATING_BUTTONS_NOT_PURCHASED -> + resourceProvider.getString(R.string.trigger_error_floating_buttons_not_purchased) + + TriggerError.PURCHASE_VERIFICATION_FAILED -> + resourceProvider.getString(R.string.trigger_error_product_verification_failed) + + TriggerError.SYSTEM_BRIDGE_UNSUPPORTED -> + resourceProvider.getString(R.string.trigger_error_system_bridge_unsupported) + + TriggerError.SYSTEM_BRIDGE_DISCONNECTED -> + resourceProvider.getString(R.string.trigger_error_system_bridge_disconnected) + + TriggerError.EVDEV_DEVICE_NOT_FOUND -> + resourceProvider.getString(R.string.trigger_error_evdev_device_not_found) + + TriggerError.MIGRATE_SCREEN_OFF_TRIGGER -> + resourceProvider.getString(R.string.trigger_error_migrate_screen_off_key_map) +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/VibrateEffectStrings.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/VibrateEffectStrings.kt new file mode 100644 index 0000000000..53b0ff4cf6 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/VibrateEffectStrings.kt @@ -0,0 +1,13 @@ +package io.github.sds100.keymapper.base.utils + +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.vibration.VibrateEffect + +object VibrateEffectStrings { + fun getLabel(type: VibrateEffect.PredefinedType) = when (type) { + VibrateEffect.PredefinedType.CLICK -> R.string.vibration_effect_click + VibrateEffect.PredefinedType.DOUBLE_CLICK -> R.string.vibration_effect_double_click + VibrateEffect.PredefinedType.HEAVY_CLICK -> R.string.vibration_effect_heavy_click + VibrateEffect.PredefinedType.TICK -> R.string.vibration_effect_tick + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt index 4849afedb6..ba50beebc0 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavDestination.kt @@ -6,6 +6,7 @@ import io.github.sds100.keymapper.base.actions.pinchscreen.PinchPickCoordinateRe import io.github.sds100.keymapper.base.actions.swipescreen.SwipePickCoordinateResult import io.github.sds100.keymapper.base.actions.tapscreen.PickCoordinateResult import io.github.sds100.keymapper.base.constraints.ConstraintData +import io.github.sds100.keymapper.base.system.apps.ChooseAppResult import io.github.sds100.keymapper.base.system.apps.ChooseAppShortcutResult import io.github.sds100.keymapper.base.system.intents.ConfigIntentResult import io.github.sds100.keymapper.base.trigger.TriggerSetupShortcut @@ -47,6 +48,9 @@ abstract class NavDestination(val isCompose: Boolean = false) { const val ID_ADVANCED_TRIGGERS = "advanced_triggers" const val ID_GET_EVENT = "get_event" const val ID_XIAOMI_OPTIMIZATION = "xiaomi_optimization" + const val ID_TRIGGER_BY_INTENT = "trigger_by_intent" + const val ID_ENABLE_BY_INTENT = "enable_by_intent" + const val ID_CONFIG_NOTIFICATION_CONSTRAINT = "config_notification_constraint" } @Serializable @@ -60,7 +64,7 @@ abstract class NavDestination(val isCompose: Boolean = false) { * Allow the list to show hidden apps that can't be launched. */ val allowHiddenApps: Boolean, - ) : NavDestination() { + ) : NavDestination() { override val id: String = ID_CHOOSE_APP } @@ -124,6 +128,12 @@ abstract class NavDestination(val isCompose: Boolean = false) { override val id: String = ID_CHOOSE_CONSTRAINT } + @Serializable + data object ConfigNotificationConstraint : + NavDestination(isCompose = true) { + override val id: String = ID_CONFIG_NOTIFICATION_CONSTRAINT + } + @Serializable data object ChooseBluetoothDevice : NavDestination() { override val id: String = ID_CHOOSE_BLUETOOTH_DEVICE @@ -223,4 +233,14 @@ abstract class NavDestination(val isCompose: Boolean = false) { data object XiaomiOptimization : NavDestination(isCompose = true) { override val id: String = ID_XIAOMI_OPTIMIZATION } + + @Serializable + data class TriggerByIntent(val keyMapUid: String) : NavDestination(isCompose = true) { + override val id: String = ID_TRIGGER_BY_INTENT + } + + @Serializable + data class EnableByIntent(val keyMapUid: String) : NavDestination(isCompose = true) { + override val id: String = ID_ENABLE_BY_INTENT + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigationProvider.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigationProvider.kt index e47277917a..06384ccca3 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigationProvider.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/navigation/NavigationProvider.kt @@ -377,9 +377,9 @@ fun NavigationProvider.sendNavResultFromBundle( ) { when (destinationId) { NavDestination.ID_CHOOSE_APP -> { - val packageName = bundle.getString(ChooseAppFragment.EXTRA_PACKAGE_NAME) + val json = bundle.getString(ChooseAppFragment.EXTRA_RESULT) - onNavResult(NavResult(requestKey, Json.encodeToString(packageName!!))) + onNavResult(NavResult(requestKey, json)) } NavDestination.ID_CHOOSE_APP_SHORTCUT -> { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceProvider.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceProvider.kt index 6c24de3d8d..cfc4958650 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceProvider.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/ResourceProvider.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.drawable.Drawable import androidx.annotation.ColorRes import androidx.annotation.DrawableRes +import androidx.annotation.PluralsRes import androidx.annotation.StringRes import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -23,6 +24,10 @@ class ResourceProviderImpl @Inject constructor(@ApplicationContext context: Cont override fun getString(resId: Int): String = ctx.str(resId) + override fun getPluralString(resId: Int, quantity: Int, vararg args: Any): String { + return ctx.resources.getQuantityString(resId, quantity, *args) + } + override fun getDrawable(resId: Int): Drawable = ctx.drawable(resId) override fun getColor(color: Int): Int = ctx.color(color) @@ -32,6 +37,8 @@ interface ResourceProvider { fun getString(@StringRes resId: Int, args: Array): String fun getString(@StringRes resId: Int, arg: Any): String fun getString(@StringRes resId: Int): String + fun getPluralString(@PluralsRes resId: Int, quantity: Int, vararg args: Any): String + fun getText(@StringRes resId: Int): CharSequence fun getDrawable(@DrawableRes resId: Int): Drawable fun getColor(@ColorRes color: Int): Int diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SliderMinimums.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SliderMinimums.kt index 5a4c852b8e..bc3fb8b06a 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SliderMinimums.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/SliderMinimums.kt @@ -9,5 +9,5 @@ object SliderMinimums { const val TRIGGER_LONG_PRESS_DELAY = 0 const val TRIGGER_DOUBLE_PRESS_DELAY = 0 const val TRIGGER_SEQUENCE_TRIGGER_TIMEOUT = 0 - const val VIBRATION_DURATION = 1 + const val VIBRATION_DURATION = 0 } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CodeBlock.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CodeBlock.kt new file mode 100644 index 0000000000..fa0658fc46 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CodeBlock.kt @@ -0,0 +1,165 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import android.content.ClipData +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import kotlinx.coroutines.launch + +object CodeBlockDefaults { + /** + * Soft wrap the code after this many characters so that long commands stay readable. + */ + const val MAX_LINE_LENGTH: Int = 80 + + val MaxHeight: Dp = 240.dp +} + +/** + * Shows monospace text that can be selected, scrolled in both directions and copied. + */ +@Composable +fun CodeBlock( + modifier: Modifier = Modifier, + code: String, + clipboardLabel: String, + label: String? = null, + maxLineLength: Int = CodeBlockDefaults.MAX_LINE_LENGTH, + maxHeight: Dp = CodeBlockDefaults.MaxHeight, +) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + + val textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace) + + // The code only wraps once it is this wide, so narrow screens scroll horizontally + // instead of wrapping every few words. + val lineWidth = remember(textStyle, maxLineLength, density, textMeasurer) { + val widthPx = textMeasurer.measure( + text = "0".repeat(maxLineLength), + style = textStyle, + softWrap = false, + ).size.width + + with(density) { widthPx.toDp() } + } + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (label != null) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + SelectionContainer( + modifier = Modifier + .weight(1f) + .heightIn(max = maxHeight) + .verticalScroll(rememberScrollState()) + .horizontalScroll(rememberScrollState()), + ) { + Text( + modifier = Modifier + .padding(start = 12.dp, top = 8.dp, bottom = 8.dp) + .widthIn(max = lineWidth), + text = code, + style = textStyle, + ) + } + + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry( + ClipEntry(ClipData.newPlainText(clipboardLabel, code)), + ) + } + }, + ) { + Icon( + imageVector = Icons.Rounded.ContentCopy, + contentDescription = if (label == null) { + stringResource(R.string.code_block_copy) + } else { + stringResource(R.string.code_block_copy_content_description, label) + }, + ) + } + } + } + } +} + +@Preview +@Composable +private fun CodeBlockPreview() { + KeyMapperTheme { + Surface { + CodeBlock( + modifier = Modifier.padding(16.dp), + label = "Command", + code = "adb shell am broadcast -n io.github.sds100.keymapper/io.github." + + "sds100.keymapper.api.TriggerKeyMapsBroadcastReceiver " + + "-a io.github.sds100.keymapper.ACTION_TRIGGER_KEYMAP_BY_UID " + + "--es io.github.sds100.keymapper.EXTRA_KEYMAP_UID " + + "beea7ef5-e33e-4bd3-9987-9002e5035f23", + clipboardLabel = "Command", + ) + } + } +} + +@Preview +@Composable +private fun CodeBlockShortPreview() { + KeyMapperTheme { + Surface { + CodeBlock( + modifier = Modifier.padding(16.dp), + code = "io.github.sds100.keymapper.debug", + clipboardLabel = "Package", + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactButton.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactButton.kt new file mode 100644 index 0000000000..234b4eccb1 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactButton.kt @@ -0,0 +1,116 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +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.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.compose.KeyMapperTheme + +private val compactButtonHeight = 28.dp +private val compactButtonContentPadding = PaddingValues(horizontal = 12.dp, vertical = 2.dp) + +@Composable +fun CompactOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: ButtonColors = ButtonDefaults.outlinedButtonColors(), + border: BorderStroke = ButtonDefaults.outlinedButtonBorder(), + content: @Composable RowScope.() -> Unit, +) { + OutlinedButton( + modifier = modifier.height(compactButtonHeight), + onClick = onClick, + enabled = enabled, + shape = MaterialTheme.shapes.small, + colors = colors, + contentPadding = compactButtonContentPadding, + border = border, + content = content, + ) +} + +@Composable +fun CompactFilledTonalButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: ButtonColors = ButtonDefaults.filledTonalButtonColors(), + content: @Composable RowScope.() -> Unit, +) { + FilledTonalButton( + modifier = modifier.height(compactButtonHeight), + onClick = onClick, + enabled = enabled, + shape = MaterialTheme.shapes.small, + colors = colors, + contentPadding = compactButtonContentPadding, + content = content, + ) +} + +@Composable +fun CompactErrorButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + Button( + modifier = modifier.height(compactButtonHeight), + onClick = onClick, + enabled = enabled, + shape = MaterialTheme.shapes.small, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + contentPadding = compactButtonContentPadding, + content = content, + ) +} + +@Composable +private fun CompactButtonsPreviewContent() { + Surface { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CompactOutlinedButton(onClick = {}) { + Text("Outlined") + } + + CompactFilledTonalButton(onClick = {}) { + Text("Tonal") + } + + CompactErrorButton(onClick = {}) { + Text("Fix") + } + } + } +} + +@PreviewLightDark +@Composable +private fun PreviewLight() { + KeyMapperTheme { + CompactButtonsPreviewContent() + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt index 040b285a01..705ce63916 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/CompactChip.kt @@ -34,6 +34,7 @@ fun CompactChip( borderStroke: BorderStroke? = null, onClick: (() -> Unit)? = null, enabled: Boolean = false, + actionContent: (@Composable () -> Unit)? = null, ) { CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 16.dp, @@ -42,45 +43,63 @@ fun CompactChip( Surface( modifier = modifier.height(chipHeight), color = containerColor, + contentColor = contentColor, border = borderStroke, shape = AssistChipDefaults.shape, ) { - CompactChipContent(icon, text, contentColor) + CompactChipContent(icon, text, contentColor, actionContent = actionContent) } } else { Surface( modifier = modifier.height(chipHeight), color = containerColor, + contentColor = contentColor, border = borderStroke, shape = AssistChipDefaults.shape, onClick = onClick, ) { - CompactChipContent(icon, text, contentColor) + CompactChipContent(icon, text, contentColor, actionContent = actionContent) } } } } @Composable -fun ErrorCompactChip(onClick: () -> Unit, text: String, enabled: Boolean) { +fun ErrorCompactChip( + modifier: Modifier = Modifier, + onClick: () -> Unit, + containerColor: Color = MaterialTheme.colorScheme.surfaceContainer, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + text: String, + enabled: Boolean, + actionContent: (@Composable () -> Unit)? = null, +) { CompactChip( + modifier = modifier, text = text, + containerColor = containerColor, + contentColor = contentColor, icon = { Icon( modifier = Modifier.fillMaxHeight(), imageVector = Icons.Outlined.Error, + tint = MaterialTheme.colorScheme.error, contentDescription = null, ) }, - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, onClick = onClick, enabled = enabled, + actionContent = actionContent, ) } @Composable -private fun CompactChipContent(icon: @Composable (() -> Unit)?, text: String, contentColor: Color) { +private fun CompactChipContent( + icon: @Composable (() -> Unit)?, + text: String, + contentColor: Color, + actionContent: (@Composable () -> Unit)? = null, +) { Row( modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, @@ -97,5 +116,10 @@ private fun CompactChipContent(icon: @Composable (() -> Unit)?, text: String, co style = MaterialTheme.typography.labelLarge, color = contentColor, ) + + if (actionContent != null) { + Spacer(modifier = Modifier.width(4.dp)) + actionContent() + } } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeChipModel.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeChipModel.kt index 15f44addaf..992fda2688 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeChipModel.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeChipModel.kt @@ -8,6 +8,10 @@ sealed class ComposeChipModel { override val id: String, val icon: ComposeIconInfo?, override val text: String, + /** + * Disabled chips are greyed out. + */ + val isEnabled: Boolean = true, ) : ComposeChipModel() data class Error( diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt index 4212057655..fb6cb0f2ad 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ComposeDialogs.kt @@ -9,16 +9,139 @@ 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.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import kotlinx.coroutines.launch + +@Composable +fun TextFieldDialog( + title: String, + submitButtonText: String, + initialText: String, + hint: String? = null, + canBeEmpty: Boolean = false, + /** + * Returns an error message. + */ + onSubmitClick: suspend (newText: String) -> String? = { null }, + onDismissRequest: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } + var textFieldValue: TextFieldValue by remember { + mutableStateOf( + TextFieldValue( + text = initialText, + selection = TextRange(initialText.length), + ), + ) + } + + var error: String? by remember { mutableStateOf(null) } + + val isError by remember { derivedStateOf { error != null } } + + val emptyErrorText = stringResource(R.string.error_cant_be_empty) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } + + val submit = { + scope.launch { + error = onSubmitClick(textFieldValue.text) + + if (error == null) { + onDismissRequest() + } + } + Unit + } + + CustomDialog( + title = title, + confirmButton = { + TextButton( + onClick = submit, + enabled = !isError, + ) { + Text(submitButtonText) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.neg_cancel)) + } + }, + onDismissRequest = onDismissRequest, + ) { + TextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .focusRequester(focusRequester), + value = textFieldValue, + onValueChange = { + error = null + textFieldValue = it + + if (!canBeEmpty && textFieldValue.text.isBlank()) { + error = emptyErrorText + } + }, + placeholder = if (hint == null) { + null + } else { + { Text(hint) } + }, + singleLine = true, + maxLines = 1, + isError = isError, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + if (!isError) { + submit() + } + }, + ), + supportingText = { + if (error != null) Text(error!!) + }, + ) + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -79,6 +202,7 @@ fun CustomDialogContent( ) } + Spacer(modifier = Modifier.height(8.dp)) // HorizontalDivider() Box(Modifier.weight(1f, fill = false), content = content) // HorizontalDivider() @@ -100,3 +224,28 @@ fun CustomDialogContent( } } } + +@Preview(widthDp = 800, heightDp = 400) +@Composable +private fun TextFieldDialogPreview() { + KeyMapperTheme { + TextFieldDialog( + title = "Title", + submitButtonText = "Submit", + initialText = "Some dialog text", + ) + } +} + +@Preview(widthDp = 800, heightDp = 400) +@Composable +private fun TextFieldDialogHintPreview() { + KeyMapperTheme { + TextFieldDialog( + title = "Title", + submitButtonText = "Submit", + initialText = "", + hint = "Some placeholder text", + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt index bece2124d7..020f15b5a5 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DragDropState.kt @@ -6,51 +6,101 @@ import androidx.compose.animation.core.spring import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +/** + * The distance past the edge of the list at which auto scrolling reaches its maximum speed. + */ +private val autoScrollEdgeDistance = 64.dp + +/** + * Maximum auto scroll speed per second. + */ +private val autoScrollMaxSpeed = 1000.dp + @Composable fun rememberDragDropState( lazyListState: LazyListState, /** - * Ignore the last N items in the list. Do not allow dragging and dropping these items or - * placing other items in these positions. + * The keys of the items that can be dragged, in their current order. Other items in the list, + * such as headers and footers, can not be dragged and items can not be dropped on them. */ - ignoreLastItems: Int = 0, - onMove: (Int, Int) -> Unit, + keys: List, + /** + * Called once when the drag ends with the indices in [keys]. While dragging, the order is + * only changed locally, so use [DragDropState.ordered] to display the items. + */ + onMove: (fromIndex: Int, toIndex: Int) -> Unit, onStart: () -> Unit = {}, onEnd: () -> Unit = {}, ): DragDropState { val scope = rememberCoroutineScope() + val currentOnMove by rememberUpdatedState(onMove) + val currentOnStart by rememberUpdatedState(onStart) + val currentOnEnd by rememberUpdatedState(onEnd) + val state = remember(lazyListState) { DragDropState( state = lazyListState, - ignoreLastItems = ignoreLastItems, - onStart = onStart, - onMove = onMove, - onEnd = onEnd, scope = scope, + onStart = { currentOnStart() }, + onMove = { fromIndex, toIndex -> currentOnMove(fromIndex, toIndex) }, + onEnd = { currentOnEnd() }, ) } - LaunchedEffect(state) { - while (true) { - val diff = state.scrollChannel.receive() - lazyListState.scrollBy(diff) + SideEffect { + state.updateKeys(keys) + } + + val density = LocalDensity.current + val edgeDistancePx = with(density) { autoScrollEdgeDistance.toPx() } + val maxSpeedPx = with(density) { autoScrollMaxSpeed.toPx() } + + LaunchedEffect(state, edgeDistancePx, maxSpeedPx) { + snapshotFlow { state.draggingItemKey != null }.collectLatest { isDragging -> + if (!isDragging) { + return@collectLatest + } + + var lastFrameNanos = withFrameNanos { it } + + while (true) { + val frameNanos = withFrameNanos { it } + val seconds = (frameNanos - lastFrameNanos) / 1_000_000_000f + lastFrameNanos = frameNanos + + val overflow = state.draggingItemOverflow() + if (overflow != 0f) { + val fraction = (overflow / edgeDistancePx).coerceIn(-1f, 1f) + lazyListState.scrollBy(fraction * maxSpeedPx * seconds) + } + + // Check again after scrolling because other items move under the dragged item. + state.moveToTarget() + } } } @@ -58,20 +108,29 @@ fun rememberDragDropState( } /** - * This is copied from an official demo for drag and drop at https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt + * Originally based on the official demo for drag and drop at https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt + * + * Items are tracked by their key rather than their index so the list can contain other items, + * such as headers and footers. The new order is kept locally until the drag ends so the list + * does not depend on the new order propagating back from a ViewModel while dragging. */ class DragDropState internal constructor( private val state: LazyListState, - private val ignoreLastItems: Int, private val scope: CoroutineScope, private val onStart: () -> Unit, private val onMove: (Int, Int) -> Unit, private val onEnd: () -> Unit, ) { - var draggingItemIndex by mutableStateOf(null) + var draggingItemKey by mutableStateOf(null) private set - internal val scrollChannel = Channel() + private var keys: List = emptyList() + + /** + * The order of the keys while dragging, and after the drag ends until the new order is + * received in [updateKeys]. + */ + private var pendingOrder by mutableStateOf?>(null) private var draggingItemDraggedDelta by mutableFloatStateOf(0f) private var draggingItemInitialOffset by mutableIntStateOf(0) @@ -81,44 +140,81 @@ class DragDropState internal constructor( } ?: 0f private val draggingItemLayoutInfo: LazyListItemInfo? - get() = state.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == draggingItemIndex } + get() = draggingItemKey?.let { key -> + state.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key } + } + + /** + * The layout info when the order was last changed. Do not change the order again until the + * list has been laid out with the new order, otherwise the item can move back and forth. + */ + private var layoutInfoAtLastMove: LazyListLayoutInfo? = null - internal var previousIndexOfDraggedItem by mutableStateOf(null) + internal var previousKeyOfDraggedItem by mutableStateOf(null) private set internal var previousItemOffset = Animatable(0f) private set - fun onDragStart(index: Int, offset: Offset) { - // Calculate the offset of the item in the list - val lazyItem = state.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == index } - ?: return + /** + * Sort the items in the order they should be displayed while dragging. + */ + fun ordered(items: List, key: (T) -> Any): List { + val order = pendingOrder ?: return items + val positions = order.withIndex().associate { it.value to it.index } + return items.sortedBy { positions[key(it)] ?: Int.MAX_VALUE } + } + + internal fun updateKeys(newKeys: List) { + if (newKeys == keys) { + return + } + + keys = newKeys - val initialOffset = lazyItem.offset + // The moved list has been received so the local order is no longer needed. + if (draggingItemKey == null) { + pendingOrder = null + } + } - val finalOffset = offset + Offset(0f, initialOffset.toFloat()) + /** + * Start dragging the item with this key. Use this when dragging with a drag handle. + */ + fun onDragStart(key: Any) { + if (key !in keys) { + return + } - onDragStart(finalOffset) + val item = state.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key } ?: return + startDragging(item) } + /** + * Start dragging the item at this offset in the list. + */ fun onDragStart(offset: Offset) { - // check if the touch position is on drag handle - state.layoutInfo.visibleItemsInfo - .firstOrNull { item -> - item.index < state.layoutInfo.totalItemsCount - ignoreLastItems && - offset.y.toInt() in item.offset..(item.offset + item.size) - }?.also { - draggingItemIndex = it.index - draggingItemInitialOffset = it.offset - } + val item = state.layoutInfo.visibleItemsInfo.firstOrNull { item -> + item.key in keys && offset.y.toInt() in item.offset..item.offsetEnd + } ?: return + + startDragging(item) + } + + private fun startDragging(item: LazyListItemInfo) { + pendingOrder = ordered(keys) { it } + draggingItemKey = item.key + draggingItemInitialOffset = item.offset + draggingItemDraggedDelta = 0f + layoutInfoAtLastMove = null onStart.invoke() } fun onDragInterrupted() { - if (draggingItemIndex != null) { - previousIndexOfDraggedItem = draggingItemIndex + val key = draggingItemKey + + if (key != null) { + previousKeyOfDraggedItem = key val startOffset = draggingItemOffset scope.launch { previousItemOffset.snapTo(startOffset) @@ -129,68 +225,123 @@ class DragDropState internal constructor( visibilityThreshold = 1f, ), ) - previousIndexOfDraggedItem = null + previousKeyOfDraggedItem = null + } + + val fromIndex = keys.indexOf(key) + val toIndex = ordered(keys) { it }.indexOf(key) + + if (fromIndex != -1 && toIndex != -1 && fromIndex != toIndex) { + // Keep the local order until the new keys are received. + onMove.invoke(fromIndex, toIndex) + } else { + pendingOrder = null } } + draggingItemDraggedDelta = 0f - draggingItemIndex = null + draggingItemKey = null draggingItemInitialOffset = 0 + layoutInfoAtLastMove = null onEnd.invoke() } fun onDrag(offset: Offset) { draggingItemDraggedDelta += offset.y + moveToTarget() + } + /** + * Move the dragged item to the position of the item underneath its middle. + */ + internal fun moveToTarget() { val draggingItem = draggingItemLayoutInfo ?: return + val layoutInfo = state.layoutInfo + val order = pendingOrder ?: return + + if (layoutInfo === layoutInfoAtLastMove) { + return + } + val startOffset = draggingItem.offset + draggingItemOffset - val endOffset = startOffset + draggingItem.size - val middleOffset = startOffset + (endOffset - startOffset) / 2f + val middleOffset = startOffset + draggingItem.size / 2f + + val targetItem = layoutInfo.visibleItemsInfo.find { item -> + item.key != draggingItem.key && + item.key in keys && + middleOffset.toInt() in item.offset..item.offsetEnd + } ?: return - val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> - middleOffset.toInt() in item.offset..item.offsetEnd && - draggingItem.index != item.index + // Only move once the middle of the dragged item has passed the middle of the target. + // Otherwise items of different heights swap back and forth. + val targetMiddle = targetItem.offset + targetItem.size / 2f + val hasPassedTarget = if (targetItem.offset > draggingItem.offset) { + middleOffset > targetMiddle + } else { + middleOffset < targetMiddle } - val itemCount = state.layoutInfo.totalItemsCount - if (targetItem != null) { - val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) { - draggingItem.index - } else if (draggingItem.index == state.firstVisibleItemIndex) { - targetItem.index - } else { - null - } + if (!hasPassedTarget) { + return + } - if (draggingItem.index < itemCount - ignoreLastItems && - targetItem.index < itemCount - ignoreLastItems - ) { - if (scrollToIndex != null) { - scope.launch { - // this is needed to neutralize automatic keeping the first item first. - state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) - onMove.invoke(draggingItem.index, targetItem.index) - } - } else { - onMove.invoke(draggingItem.index, targetItem.index) - } - draggingItemIndex = targetItem.index - } + val fromIndex = order.indexOf(draggingItem.key) + val toIndex = order.indexOf(targetItem.key) + + if (fromIndex == -1 || toIndex == -1) { + return + } + + // Where the dragged item will be laid out after moving if the other items stay in place. + val newOffset = if (targetItem.offset > draggingItem.offset) { + targetItem.offsetEnd - draggingItem.size } else { - val overscroll = when { - draggingItemDraggedDelta > 0 -> - (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast( - 0f, - ) + targetItem.offset + } + val minOffset = layoutInfo.viewportStartOffset + val maxOffset = + (layoutInfo.viewportEndOffset - draggingItem.size).coerceAtLeast(minOffset) - draggingItemDraggedDelta < 0 -> - (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + if (newOffset !in minOffset..maxOffset) { + // Moving a small item past a large item can move it out of the list, which stops + // it being laid out so the drag can not continue. Scroll so it stays in the list. + // The dragged item will be at the target's index after moving. + state.requestScrollToItem(targetItem.index, -newOffset.coerceIn(minOffset, maxOffset)) + } else if (draggingItem.index == state.firstVisibleItemIndex || + targetItem.index == state.firstVisibleItemIndex + ) { + // The list keeps the first visible item in place when the order changes, which + // scrolls the list when the first visible item is moved. Keep the scroll position. + state.requestScrollToItem( + state.firstVisibleItemIndex, + state.firstVisibleItemScrollOffset, + ) + } - else -> 0f - } - if (overscroll != 0f) { - scrollChannel.trySend(overscroll) - } + pendingOrder = order.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } + layoutInfoAtLastMove = layoutInfo + } + + /** + * How far the dragged item is past the edge of the list in the direction it is being dragged. + * Positive when past the end and negative when past the start. + */ + internal fun draggingItemOverflow(): Float { + val draggingItem = draggingItemLayoutInfo ?: return 0f + val layoutInfo = state.layoutInfo + + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + + return when { + draggingItemDraggedDelta > 0 -> + (endOffset - layoutInfo.viewportEndOffset).coerceAtLeast(0f) + + draggingItemDraggedDelta < 0 -> + (startOffset - layoutInfo.viewportStartOffset).coerceAtMost(0f) + + else -> 0f } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DraggableItem.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DraggableItem.kt index d40e0cb3cd..da665d9cfd 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DraggableItem.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/DraggableItem.kt @@ -11,28 +11,39 @@ import androidx.compose.ui.zIndex @Composable fun LazyItemScope.DraggableItem( dragDropState: DragDropState, - index: Int, + /** + * The same key that is used for this item in the lazy list. + */ + key: Any, modifier: Modifier = Modifier, content: @Composable BoxScope.(isDragging: Boolean) -> Unit, ) { - val dragging = index == dragDropState.draggingItemIndex + val dragging = key == dragDropState.draggingItemKey + val draggingModifier = if (dragging) { Modifier .zIndex(1f) .graphicsLayer { translationY = dragDropState.draggingItemOffset } - } else if (index == dragDropState.previousIndexOfDraggedItem) { + } else if (key == dragDropState.previousKeyOfDraggedItem) { Modifier .zIndex(1f) .graphicsLayer { translationY = dragDropState.previousItemOffset.value } - } else { + } else if (dragDropState.draggingItemKey != null || + dragDropState.previousKeyOfDraggedItem != null + ) { + // Only animate placement while a drag/drop is actually reflowing the list. Applying + // this unconditionally makes every row "jiggle" into place whenever the list is laid + // out for unrelated reasons, e.g. the pager swiping this page into view. Modifier.animateItem( fadeInSpec = null, fadeOutSpec = null, ) + } else { + Modifier } Box(modifier.then(draggingModifier)) { content(dragging) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ExpandableDraggableCard.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ExpandableDraggableCard.kt new file mode 100644 index 0000000000..8b8fcbfc34 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/ExpandableDraggableCard.kt @@ -0,0 +1,295 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme + +const val EXPAND_ANIMATION_DURATION = 300 + +/** + * The fade and size change share the same duration and easing so the content does not disappear + * before the card has finished changing shape. + */ +val expandTransition: EnterTransition = + fadeIn(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) + + expandVertically( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + expandFrom = Alignment.Top, + ) + +val collapseTransition: ExitTransition = + fadeOut(tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing)) + + shrinkVertically( + animationSpec = tween(EXPAND_ANIMATION_DURATION, easing = FastOutSlowInEasing), + shrinkTowards = Alignment.Top, + ) + +/** + * The header content is laid out in a row and is also in a lookahead scope so it can animate + * its bounds while the card expands. + */ +interface ExpandableDraggableCardHeaderScope : + RowScope, + LookaheadScope + +private class ExpandableDraggableCardHeaderScopeImpl( + rowScope: RowScope, + lookaheadScope: LookaheadScope, +) : ExpandableDraggableCardHeaderScope, + RowScope by rowScope, + LookaheadScope by lookaheadScope + +/** + * A card that can be expanded by clicking its header and reordered by dragging its handle. + * + * @param key The key of this item in the drag and drop state. + * @param headerContent The content of the header row. It is always visible and is passed whether + * the card is expanded. + * @param expandedContent The content that is only visible when the card is expanded. + */ +@Composable +fun ExpandableDraggableCard( + modifier: Modifier = Modifier, + key: Any, + isExpanded: Boolean, + isDragging: Boolean, + isReorderingEnabled: Boolean, + dragHandleContentDescription: String, + expandContentDescription: String, + collapseContentDescription: String, + isDraggingEnabled: Boolean = true, + dragDropState: DragDropState? = null, + contentColor: Color = MaterialTheme.colorScheme.onSurface, + onExpandedChange: (Boolean) -> Unit = {}, + onMoveUp: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + headerContent: @Composable ExpandableDraggableCardHeaderScope.(isExpanded: Boolean) -> Unit, + expandedContent: @Composable () -> Unit, +) { + val draggableState = rememberDraggableState { + dragDropState?.onDrag(Offset(0f, it)) + } + + val moveUpLabel = stringResource(R.string.accessibility_action_move_up) + val moveDownLabel = stringResource(R.string.accessibility_action_move_down) + + val cardColors = CardDefaults.elevatedCardColors( + containerColor = if (isDragging) { + MaterialTheme.colorScheme.surfaceContainerHighest + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + contentColor = contentColor, + ) + + ElevatedCard( + modifier = modifier + .semantics { + if (isReorderingEnabled) { + customActions = buildList { + onMoveUp?.let { action -> + add( + CustomAccessibilityAction(moveUpLabel) { + action() + true + }, + ) + } + onMoveDown?.let { action -> + add( + CustomAccessibilityAction(moveDownLabel) { + action() + true + }, + ) + } + } + } + }, + colors = cardColors, + ) { + LookaheadScope { + val lookaheadScope = this + + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onExpandedChange(!isExpanded) }, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(8.dp)) + + if (isReorderingEnabled) { + Icon( + modifier = Modifier + .size(24.dp) + .draggable( + state = draggableState, + enabled = isDraggingEnabled, + orientation = Orientation.Vertical, + startDragImmediately = true, + onDragStarted = { + dragDropState?.onDragStart(key) + }, + onDragStopped = { dragDropState?.onDragInterrupted() }, + ), + imageVector = Icons.Rounded.DragHandle, + contentDescription = dragHandleContentDescription, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(Modifier.width(8.dp)) + + val headerScope = remember(this, lookaheadScope) { + ExpandableDraggableCardHeaderScopeImpl(this, lookaheadScope) + } + + headerScope.headerContent(isExpanded) + + CompositionLocalProvider( + LocalMinimumInteractiveComponentSize provides 16.dp, + ) { + IconButton(onClick = { onExpandedChange(!isExpanded) }) { + Icon( + imageVector = if (isExpanded) { + Icons.Rounded.KeyboardArrowUp + } else { + Icons.Rounded.KeyboardArrowDown + }, + contentDescription = if (isExpanded) { + collapseContentDescription + } else { + expandContentDescription + }, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Spacer(Modifier.width(4.dp)) + } + + AnimatedVisibility( + visible = isExpanded, + enter = expandTransition, + exit = collapseTransition, + ) { + expandedContent() + } + } + } + } +} + +@Preview +@Composable +private fun CollapsedPreview() { + KeyMapperTheme { + ExpandableDraggableCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + key = "key", + isExpanded = false, + isDragging = false, + isReorderingEnabled = true, + dragHandleContentDescription = "Drag handle", + expandContentDescription = "Expand", + collapseContentDescription = "Collapse", + headerContent = { + Text( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp), + text = "Header", + ) + }, + expandedContent = { + Text( + modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 8.dp), + text = "Expanded content", + ) + }, + ) + } +} + +@Preview +@Composable +private fun ExpandedPreview() { + KeyMapperTheme { + ExpandableDraggableCard( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + key = "key", + isExpanded = true, + isDragging = false, + isReorderingEnabled = true, + dragHandleContentDescription = "Drag handle", + expandContentDescription = "Expand", + collapseContentDescription = "Collapse", + headerContent = { + Text( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp), + text = "Header", + ) + }, + expandedContent = { + Text( + modifier = Modifier.padding(start = 16.dp, end = 8.dp, bottom = 8.dp), + text = "Expanded content", + ) + }, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/HorizontalFadingEdges.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/HorizontalFadingEdges.kt new file mode 100644 index 0000000000..2c9405fcff --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/HorizontalFadingEdges.kt @@ -0,0 +1,52 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.foundation.ScrollState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp + +/** + * The width of the shadow drawn over the start/end of a horizontally scrollable row to + * indicate that it can be scrolled. + */ +private val ScrollFadeEdgeWidth = 48.dp + +/** + * Draws a shadow over the start and/or end of the content to indicate it is horizontally + * scrollable in that direction. The shadow on each side is only drawn while it is possible to + * scroll further that way. + */ +fun Modifier.horizontalFadingEdges(scrollState: ScrollState): Modifier = this + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } + .drawWithContent { + drawContent() + + val fadeWidthPx = ScrollFadeEdgeWidth.toPx() + + if (scrollState.canScrollBackward) { + drawRect( + brush = Brush.horizontalGradient( + colors = listOf(Color.Transparent, Color.Black), + startX = 0f, + endX = fadeWidthPx, + ), + blendMode = BlendMode.DstIn, + ) + } + + if (scrollState.canScrollForward) { + drawRect( + brush = Brush.horizontalGradient( + colors = listOf(Color.Black, Color.Transparent), + startX = size.width - fadeWidthPx, + endX = size.width, + ), + blendMode = BlendMode.DstIn, + ) + } + } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt index 8dc10f0f0b..839736e4c4 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/KeyMapperSegmentedButtonRow.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonColors import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text @@ -27,6 +28,8 @@ import androidx.compose.ui.unit.sp * @param onStateSelected Callback when a button is selected * @param isCompact Whether to use compact styling (smaller shapes, auto-sizing text) * @param isEnabled Whether the buttons are enabled + * @param isStateEnabled Whether an individual button is enabled. Defaults to always true; use + * this to disable specific options while leaving the rest of the row enabled. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -37,12 +40,16 @@ fun KeyMapperSegmentedButtonRow( onStateSelected: (T) -> Unit, isCompact: Boolean = false, isEnabled: Boolean = true, + isStateEnabled: (T) -> Boolean = { true }, + colors: SegmentedButtonColors = SegmentedButtonDefaults.colors(), ) { - val colors = if (isEnabled) { - SegmentedButtonDefaults.colors() + val hasDisabledButton = !isEnabled || buttonStates.any { !isStateEnabled(it.first) } + + val colors = if (!hasDisabledButton) { + colors } else { // The disabled border color of the inactive button is by default not greyed out enough - SegmentedButtonDefaults.colors( + colors.copy( disabledInactiveBorderColor = SegmentedButtonDefaults.colors().inactiveBorderColor.copy(alpha = 0.5f), ) @@ -54,15 +61,15 @@ fun KeyMapperSegmentedButtonRow( for (content in buttonStates) { val (state, label) = content val isSelected = state == selectedState - val isDisabled = !isEnabled - val isUnselectedDisabled = isDisabled && !isSelected + val isButtonEnabled = isEnabled && isStateEnabled(state) + val isUnselectedDisabled = !isButtonEnabled && !isSelected if (isCompact) { SegmentedButton( modifier = Modifier.height(36.dp), selected = isSelected, onClick = { onStateSelected(state) }, - enabled = isEnabled, + enabled = isButtonEnabled, icon = { }, shape = SegmentedButtonDefaults.itemShape( index = buttonStates.indexOf(content), @@ -89,7 +96,7 @@ fun KeyMapperSegmentedButtonRow( SegmentedButton( selected = isSelected, onClick = { onStateSelected(state) }, - enabled = isEnabled, + enabled = isButtonEnabled, shape = SegmentedButtonDefaults.itemShape( index = buttonStates.indexOf(content), count = buttonStates.size, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt index 8bb1949548..584dabb4ea 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SliderOptionText.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsDraggedAsState import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -44,7 +45,7 @@ import kotlin.math.roundToInt @Composable fun SliderOptionText( modifier: Modifier = Modifier, - title: String, + title: String?, value: Float, defaultValue: Float, valueText: (Float) -> String, @@ -55,35 +56,43 @@ fun SliderOptionText( ) { var showDialog by rememberSaveable { mutableStateOf(false) } + var newSliderValue: Float? by remember { mutableStateOf(null) } + val currentValue = newSliderValue ?: value + if (showDialog) { ValueDialog( initialValue = if (value == defaultValue) { null } else { - value.roundToInt() + currentValue.roundToInt() }, placeholderValue = valueText(defaultValue), title = title, onDismissRequest = { showDialog = false }, onSaveClick = { newValue -> + newSliderValue = null + if (newValue == null) { onValueChange(defaultValue) } else { onValueChange(newValue.toFloat()) } + showDialog = false }, ) } Column(modifier = modifier) { - Text( - modifier = Modifier, - text = title, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + if (title != null) { + Text( + modifier = Modifier, + text = title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } Row(verticalAlignment = Alignment.CenterVertically) { val interactionSource = remember { MutableInteractionSource() } @@ -92,8 +101,13 @@ fun SliderOptionText( Slider( modifier = Modifier.weight(1f), - value = value, - onValueChange = onValueChange, + value = currentValue, + onValueChange = { newSliderValue = it }, + onValueChangeFinished = { + newSliderValue?.let { + onValueChange(it) + } + }, enabled = isEnabled, valueRange = valueRange, interactionSource = interactionSource, @@ -101,18 +115,21 @@ fun SliderOptionText( KeyMapperSliderThumb(interactionSource) }, steps = - (((valueRange.endInclusive - valueRange.start) / stepSize.toFloat()).toInt()) - + (((valueRange.endInclusive - valueRange.start) / stepSize.toFloat()).roundToInt()) - 1, ) Spacer(modifier = Modifier.width(8.dp)) - ElevatedButton(onClick = { showDialog = true }) { + ElevatedButton( + onClick = { showDialog = true }, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + ) { // Do not show the text when dragging because it popping in/out moves the slider - val text = if (value == defaultValue && !isDragged) { - stringResource(R.string.slider_default_button, valueText(value)) + val text = if (currentValue == defaultValue && !isDragged) { + stringResource(R.string.slider_default_button, valueText(currentValue)) } else { - valueText(value) + valueText(currentValue) } Text(text) @@ -120,8 +137,13 @@ fun SliderOptionText( // Always show the reset button because it popping in/out when dragging over // the default value causes the slider to change size and jiggle. - AnimatedVisibility(visible = value != defaultValue || isDragged) { - IconButton(onClick = { onValueChange(defaultValue) }) { + AnimatedVisibility(visible = currentValue != defaultValue || isDragged) { + IconButton( + onClick = { + newSliderValue = null + onValueChange(defaultValue) + }, + ) { Icon( Icons.Rounded.RestartAlt, contentDescription = stringResource( @@ -138,7 +160,7 @@ fun SliderOptionText( private fun ValueDialog( initialValue: Int?, placeholderValue: String?, - title: String, + title: String?, onDismissRequest: () -> Unit, onSaveClick: (Int?) -> Unit, ) { @@ -218,6 +240,26 @@ private fun Preview() { } } +@Preview +@Composable +private fun PreviewNoTitle() { + KeyMapperTheme { + Surface { + SliderOptionText( + modifier = Modifier.width(400.dp), + title = null, + value = 50f, + defaultValue = 500f, + valueText = { "${it.roundToInt()} ms" }, + isEnabled = true, + onValueChange = {}, + valueRange = 0f..1000f, + stepSize = 50, + ) + } + } +} + @Preview @Composable private fun PreviewDefault() { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt index 3f124629ef..ea205c478e 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchPreferenceCompose.kt @@ -39,9 +39,9 @@ fun SwitchPreferenceCompose( .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { Icon( - modifier = Modifier.align(Alignment.CenterVertically), imageVector = icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface, diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchSettingsPreferenceCompose.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchSettingsPreferenceCompose.kt new file mode 100644 index 0000000000..6db29e607a --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/SwitchSettingsPreferenceCompose.kt @@ -0,0 +1,87 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +/** + * The same layout as [SwitchPreferenceCompose] but with a trailing settings cog that opens + * further configuration for the option, e.g. [io.github.sds100.keymapper.base.keymaps.TriggerVibrationBottomSheet]. + */ +@Composable +fun SwitchSettingsPreferenceCompose( + modifier: Modifier = Modifier, + title: String, + text: String?, + icon: ImageVector, + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + onSettingsClick: () -> Unit, + isEnabled: Boolean = true, + isSettingsEnabled: Boolean = true, +) { + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.medium, + onClick = { + onCheckedChange(!isChecked) + }, + enabled = isEnabled, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + if (text != null) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + IconButton( + onClick = onSettingsClick, + enabled = isSettingsEnabled, + ) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = null, + ) + } + + Switch( + checked = isChecked, + onCheckedChange = onCheckedChange, + enabled = isEnabled, + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/VibrationEffectPickerContent.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/VibrationEffectPickerContent.kt new file mode 100644 index 0000000000..2c0a7bc971 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/VibrationEffectPickerContent.kt @@ -0,0 +1,158 @@ +package io.github.sds100.keymapper.base.utils.ui.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.VibrateEffectStrings +import io.github.sds100.keymapper.base.utils.ui.SliderMaximums +import io.github.sds100.keymapper.base.utils.ui.SliderMinimums +import io.github.sds100.keymapper.base.utils.ui.SliderStepSizes +import io.github.sds100.keymapper.base.vibration.VibrateEffect +import kotlin.math.roundToInt + +enum class VibrationEffectMode { + DURATION, + PREDEFINED, +} + +/** + * The content for picking a [VibrateEffect]: either a custom duration or a predefined haptic + * effect. This has no [androidx.compose.material3.ModalBottomSheet] or Done/Cancel buttons of + * its own so it can be embedded in different bottom sheets. + */ +@Composable +fun VibrationEffectPickerContent( + modifier: Modifier = Modifier, + mode: VibrationEffectMode, + onModeChange: (VibrationEffectMode) -> Unit, + durationMs: Int, + onDurationChange: (Int) -> Unit, + defaultDurationMs: Int, + predefinedType: VibrateEffect.PredefinedType, + onPredefinedTypeChange: (VibrateEffect.PredefinedType) -> Unit, + isPredefinedSupported: Boolean = true, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) { + KeyMapperSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + buttonStates = listOf( + VibrationEffectMode.DURATION to + stringResource(R.string.vibration_effect_mode_duration), + VibrationEffectMode.PREDEFINED to + if (isPredefinedSupported) { + stringResource(R.string.vibration_effect_mode_predefined) + } else { + stringResource( + R.string.vibration_effect_mode_predefined_unsuppored, + ) + }, + ), + selectedState = mode, + onStateSelected = onModeChange, + isStateEnabled = { it != VibrationEffectMode.PREDEFINED || isPredefinedSupported }, + ) + + when (mode) { + VibrationEffectMode.DURATION -> { + val durationMin = SliderMinimums.VIBRATION_DURATION + val durationMax = SliderMaximums.VIBRATION_DURATION + + SliderOptionText( + modifier = Modifier.fillMaxWidth(), + title = stringResource(R.string.extra_label_vibration_duration), + value = durationMs.toFloat(), + defaultValue = defaultDurationMs.toFloat(), + valueText = { "${it.toInt()} ms" }, + onValueChange = { onDurationChange(it.roundToInt()) }, + valueRange = durationMin.toFloat()..durationMax.toFloat(), + stepSize = SliderStepSizes.VIBRATION_DURATION, + ) + } + + VibrationEffectMode.PREDEFINED -> { + FlowRow( + modifier = Modifier.align(Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (type in VibrateEffect.PredefinedType.entries) { + FilterChip( + selected = predefinedType == type, + onClick = { onPredefinedTypeChange(type) }, + label = { Text(stringResource(VibrateEffectStrings.getLabel(type))) }, + ) + } + } + } + } + } +} + +@Preview(widthDp = 400) +@Composable +private fun VibrationEffectPickerContentDurationPreview() { + KeyMapperTheme { + Surface { + VibrationEffectPickerContent( + modifier = Modifier.fillMaxWidth(), + mode = VibrationEffectMode.DURATION, + onModeChange = {}, + durationMs = 200, + onDurationChange = {}, + defaultDurationMs = 200, + predefinedType = VibrateEffect.PredefinedType.CLICK, + onPredefinedTypeChange = {}, + ) + } + } +} + +@Preview(widthDp = 300) +@Composable +private fun VibrationEffectPickerContentPredefinedPreview() { + KeyMapperTheme { + Surface { + VibrationEffectPickerContent( + modifier = Modifier.fillMaxWidth(), + mode = VibrationEffectMode.PREDEFINED, + onModeChange = {}, + durationMs = 200, + onDurationChange = {}, + defaultDurationMs = 200, + predefinedType = VibrateEffect.PredefinedType.CLICK, + onPredefinedTypeChange = {}, + ) + } + } +} + +@Preview(widthDp = 400) +@Composable +private fun VibrationEffectPickerContentPredefinedUnsupportedPreview() { + KeyMapperTheme { + Surface { + VibrationEffectPickerContent( + modifier = Modifier.fillMaxWidth(), + mode = VibrationEffectMode.DURATION, + onModeChange = {}, + durationMs = 200, + onDurationChange = {}, + defaultDurationMs = 200, + predefinedType = VibrateEffect.PredefinedType.CLICK, + onPredefinedTypeChange = {}, + isPredefinedSupported = false, + ) + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/MobileSensorHi.kt b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/MobileSensorHi.kt new file mode 100644 index 0000000000..9daba1e40a --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/utils/ui/compose/icons/MobileSensorHi.kt @@ -0,0 +1,87 @@ +package io.github.sds100.keymapper.base.utils.ui.compose.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val KeyMapperIcons.MobileSensorHi: ImageVector + get() { + if (_MobileSensorHi != null) { + return _MobileSensorHi!! + } + _MobileSensorHi = ImageVector.Builder( + name = "MobileSensorHi", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(320f, 840f) + quadToRelative(-33f, 0f, -56.5f, -23.5f) + reflectiveQuadTo(240f, 760f) + verticalLineToRelative(-560f) + quadToRelative(0f, -33f, 23.5f, -56.5f) + reflectiveQuadTo(320f, 120f) + horizontalLineToRelative(320f) + quadToRelative(33f, 0f, 56.5f, 23.5f) + reflectiveQuadTo(720f, 200f) + verticalLineToRelative(560f) + quadToRelative(0f, 33f, -23.5f, 56.5f) + reflectiveQuadTo(640f, 840f) + lineTo(320f, 840f) + close() + moveTo(640f, 760f) + verticalLineToRelative(-560f) + lineTo(320f, 200f) + verticalLineToRelative(560f) + horizontalLineToRelative(320f) + close() + moveTo(508.5f, 308.5f) + quadTo(520f, 297f, 520f, 280f) + reflectiveQuadToRelative(-11.5f, -28.5f) + quadTo(497f, 240f, 480f, 240f) + reflectiveQuadToRelative(-28.5f, 11.5f) + quadTo(440f, 263f, 440f, 280f) + reflectiveQuadToRelative(11.5f, 28.5f) + quadTo(463f, 320f, 480f, 320f) + reflectiveQuadToRelative(28.5f, -11.5f) + close() + moveTo(0f, 680f) + verticalLineToRelative(-280f) + horizontalLineToRelative(80f) + verticalLineToRelative(280f) + lineTo(0f, 680f) + close() + moveTo(120f, 560f) + verticalLineToRelative(-280f) + horizontalLineToRelative(80f) + verticalLineToRelative(280f) + horizontalLineToRelative(-80f) + close() + moveTo(880f, 560f) + verticalLineToRelative(-280f) + horizontalLineToRelative(80f) + verticalLineToRelative(280f) + horizontalLineToRelative(-80f) + close() + moveTo(760f, 680f) + verticalLineToRelative(-280f) + horizontalLineToRelative(80f) + verticalLineToRelative(280f) + horizontalLineToRelative(-80f) + close() + moveTo(320f, 760f) + verticalLineToRelative(-560f) + verticalLineToRelative(560f) + close() + } + }.build() + + return _MobileSensorHi!! + } + +@Suppress("ObjectPropertyName") +private var _MobileSensorHi: ImageVector? = null diff --git a/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateConfigDelegate.kt b/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateConfigDelegate.kt new file mode 100644 index 0000000000..ececc2f0c8 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateConfigDelegate.kt @@ -0,0 +1,121 @@ +package io.github.sds100.keymapper.base.vibration + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import dagger.hilt.android.scopes.ViewModelScoped +import io.github.sds100.keymapper.base.utils.ui.compose.VibrationEffectMode +import io.github.sds100.keymapper.system.vibrator.VibratorAdapter +import javax.inject.Inject + +private const val MAX_PREVIEW_VIBRATION_DURATION_MS = 1000L + +data class VibrateConfigState( + val mode: VibrationEffectMode = VibrationEffectMode.DURATION, + val durationMs: Int, + val defaultDurationMs: Int, + val predefinedType: VibrateEffect.PredefinedType = VibrateEffect.PredefinedType.CLICK, + val isPredefinedSupported: Boolean = true, +) + +/** + * Shared UI logic for picking a [VibrateEffect] (a custom duration or a predefined haptic + * effect), used by both the Vibrate action and the key map "vibrate on trigger" option. It plays + * a short preview vibration whenever the mode, duration or predefined effect changes so the user + * can feel what they're configuring. + */ +interface VibrateConfigDelegate { + val vibrateConfigState: VibrateConfigState? + + fun openVibrateConfig(effect: VibrateEffect?, defaultDurationMs: Int) + fun closeVibrateConfig() + fun onVibrateModeChange(mode: VibrationEffectMode) + fun onVibrateDurationChange(durationMs: Int) + fun onVibratePredefinedTypeChange(type: VibrateEffect.PredefinedType) + + /** + * @return the [VibrateEffect] built from the current config state, or null if nothing is + * currently being configured. + */ + fun buildVibrateEffect(): VibrateEffect? +} + +@ViewModelScoped +class VibrateConfigDelegateImpl @Inject constructor(private val vibratorAdapter: VibratorAdapter) : + VibrateConfigDelegate { + + override var vibrateConfigState: VibrateConfigState? by mutableStateOf(null) + private set + + override fun openVibrateConfig(effect: VibrateEffect?, defaultDurationMs: Int) { + val isPredefinedSupported = vibratorAdapter.supportsPredefinedEffects + val usePredefined = effect is VibrateEffect.Predefined && isPredefinedSupported + + vibrateConfigState = VibrateConfigState( + mode = if (usePredefined) { + VibrationEffectMode.PREDEFINED + } else { + VibrationEffectMode.DURATION + }, + durationMs = (effect as? VibrateEffect.CustomDuration)?.durationMs?.toInt() + ?: defaultDurationMs, + defaultDurationMs = defaultDurationMs, + predefinedType = (effect as? VibrateEffect.Predefined)?.predefinedType + ?: VibrateEffect.PredefinedType.CLICK, + isPredefinedSupported = isPredefinedSupported, + ) + } + + override fun closeVibrateConfig() { + vibrateConfigState = null + } + + override fun onVibrateModeChange(mode: VibrationEffectMode) { + val currentState = vibrateConfigState ?: return + + if (mode == VibrationEffectMode.PREDEFINED && !currentState.isPredefinedSupported) { + return + } + + vibrateConfigState = currentState.copy(mode = mode) + playPreview() + } + + override fun onVibrateDurationChange(durationMs: Int) { + vibrateConfigState = vibrateConfigState?.copy(durationMs = durationMs) ?: return + playPreview() + } + + override fun onVibratePredefinedTypeChange(type: VibrateEffect.PredefinedType) { + vibrateConfigState = vibrateConfigState?.copy(predefinedType = type) ?: return + playPreview() + } + + override fun buildVibrateEffect(): VibrateEffect? { + val state = vibrateConfigState ?: return null + + return when (state.mode) { + VibrationEffectMode.DURATION -> + VibrateEffect.CustomDuration(state.durationMs.toLong()) + + VibrationEffectMode.PREDEFINED -> + VibrateEffect.Predefined(state.predefinedType) + } + } + + private fun playPreview() { + val effect = buildVibrateEffect() ?: return + + // Cap how long the preview buzzes for so a long custom duration doesn't play in full + // every time the user drags the slider. + val previewEffect = if (effect is VibrateEffect.CustomDuration) { + VibrateEffect.CustomDuration( + effect.durationMs.coerceAtMost(MAX_PREVIEW_VIBRATION_DURATION_MS), + ) + } else { + effect + } + + vibratorAdapter.vibrate(previewEffect) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateEffect.kt b/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateEffect.kt new file mode 100644 index 0000000000..3c33d6b816 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibrateEffect.kt @@ -0,0 +1,20 @@ +package io.github.sds100.keymapper.base.vibration + +import kotlinx.serialization.Serializable + +@Serializable +sealed class VibrateEffect { + @Serializable + data class CustomDuration(val durationMs: Long) : VibrateEffect() + + @Serializable + data class Predefined(val predefinedType: PredefinedType) : VibrateEffect() + + @Serializable + enum class PredefinedType { + CLICK, + DOUBLE_CLICK, + HEAVY_CLICK, + TICK, + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibratorAdapterExt.kt b/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibratorAdapterExt.kt new file mode 100644 index 0000000000..f0ba2ca0c8 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/vibration/VibratorAdapterExt.kt @@ -0,0 +1,28 @@ +package io.github.sds100.keymapper.base.vibration + +import io.github.sds100.keymapper.system.vibrator.PredefinedVibrationEffect +import io.github.sds100.keymapper.system.vibrator.VibratorAdapter + +/** + * Fires [effect] on the vibrator, falling back to [defaultDurationMs] when [effect] is null. + */ +fun VibratorAdapter.vibrate(effect: VibrateEffect?, defaultDurationMs: Long) { + when (effect) { + null -> vibrate(defaultDurationMs) + else -> vibrate(effect) + } +} + +fun VibratorAdapter.vibrate(effect: VibrateEffect) { + when (effect) { + is VibrateEffect.CustomDuration -> vibrate(effect.durationMs) + is VibrateEffect.Predefined -> vibrate(effect.predefinedType.toSystemEffect()) + } +} + +private fun VibrateEffect.PredefinedType.toSystemEffect(): PredefinedVibrationEffect = when (this) { + VibrateEffect.PredefinedType.CLICK -> PredefinedVibrationEffect.CLICK + VibrateEffect.PredefinedType.DOUBLE_CLICK -> PredefinedVibrationEffect.DOUBLE_CLICK + VibrateEffect.PredefinedType.HEAVY_CLICK -> PredefinedVibrationEffect.HEAVY_CLICK + VibrateEffect.PredefinedType.TICK -> PredefinedVibrationEffect.TICK +} diff --git a/base/src/main/res/values-pt/strings.xml b/base/src/main/res/values-pt/strings.xml index dd4af2f2a9..84f438f8fe 100644 --- a/base/src/main/res/values-pt/strings.xml +++ b/base/src/main/res/values-pt/strings.xml @@ -553,7 +553,6 @@ Deixe em branco se alguma rede Wi-Fi precisar ser correspondida. Segure firme Mantenha pressionado até ser pressionado novamente Não remapeie - Permitir que outros aplicativos acionem este mapa de teclas Copiar ID do mapa de chaves diff --git a/base/src/main/res/values-tr/strings.xml b/base/src/main/res/values-tr/strings.xml index f10bf2ee04..1e57fffdc8 100644 --- a/base/src/main/res/values-tr/strings.xml +++ b/base/src/main/res/values-tr/strings.xml @@ -547,7 +547,6 @@ Kaydedilmiş ses dosyalarını ayarlardan silebilirsiniz. Basılı tut Tekrar basılana kadar basılı tut Yeniden eşleme yapma - Diğer uygulamaların bu tuş eşlemesini intent\'ler veya kısayollarla kontrol etmesine izin ver Tuş eşleme kimliğini kopyala diff --git a/base/src/main/res/values-zh-rCN/strings.xml b/base/src/main/res/values-zh-rCN/strings.xml index e4f0911123..e7830b0399 100644 --- a/base/src/main/res/values-zh-rCN/strings.xml +++ b/base/src/main/res/values-zh-rCN/strings.xml @@ -553,7 +553,6 @@ 按住 保持直到再次按下 不要重映射 - 允许其他应用通过 Intent 或快捷方式控制此按键映射 复制按键映射 id diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index bc6ba9d952..ca5400a571 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -70,7 +70,8 @@ Reset Once you have enabled the device admin, you must DEACTIVATE it if you want to uninstall Key Mapper. - Wait %sms + Wait %sms + Wait %ss Start activity: %s Start service: %s @@ -121,6 +122,7 @@ Options: Actions: Trigger: + None Constraints: Swipe up Swipe down @@ -239,6 +241,7 @@ %s is not in foreground %s is playing media %s is not playing media + Media is not playing %s is connected %s is disconnected Screen is on @@ -263,14 +266,25 @@ Landscape (270°) Portrait (any) Landscape (any) + Not portrait (0°) + Not landscape (90°) + Not portrait (180°) + Not landscape (270°) + Not portrait (any) + Not landscape (any) Screen orientation Physical orientation Physical: Portrait Physical: Landscape Physical: Portrait (upside down) Physical: Landscape (inverted) + Physical: Not portrait + Physical: Not landscape + Physical: Not portrait (upside down) + Physical: Not landscape (inverted) Display resolution Resolution is %1$d × %2$d + Resolution is not %1$d × %2$d Display resolution Custom Width @@ -308,11 +322,17 @@ Lock screen is not showing In phone call + Ringing or not in call Not in phone call + Ringing or in call Phone ringing + Not ringing Ringer mode: Ring Ringer mode: Vibrate Ringer mode: Silent + Ringer mode is not: Ring + Ringer mode is not: Vibrate + Ringer mode is not: Silent Charging Discharging @@ -320,6 +340,8 @@ Hinge open Hinge is closed Hinge is open + Hinge is not closed + Hinge is not open Notification panel is showing Notification panel is not showing @@ -329,8 +351,42 @@ Portrait (180°) Landscape (270°) + + + Notification is posted + Notification from %s + No notification from %s + Notification %1$s %2$s \"%3$s\" + No notification %1$s %2$s \"%3$s\" + + app + title + text + + App + Title + Text + + contains + is exactly + + Contains + Exactly + + + + Edit constraint + Notification constraint + Match on + How to match + App + Choose app + Notification title + Notification text + Some notifications may not match if the device is hiding sensitive notification content, such as on the lock screen. Time Time between %s and %s + Time not between %s and %s Time constraint Start time Edit start time @@ -351,7 +407,6 @@ https://keymapper.app/contributing/#translating?utm_source=in_app https://github.com/keymapperorg/KeyMapper/blob/master/PRIVACY_POLICY.md https://discord.gg/Suj6nyw - https://keymapper.app/redirects/trigger-by-intent https://keymapper.app/redirects/trigger https://keymapper.app/redirects/trigger-options https://keymapper.app/redirects/action @@ -436,9 +491,6 @@ Add constraint Choose Key code Add extra - Create launcher shortcut - Create the shortcut manually - Intent guide Help Select screenshot (optional) Choose activity @@ -490,7 +542,7 @@ Failed to create bug report - Fix error + Fix errors You have no files app installed that allows you to create a file for Key Mapper. Please install a file manager. You have no files app installed that allows you to choose a file for Key Mapper. Please install a file manager. @@ -757,7 +809,6 @@ Hold down Hold down until pressed again Do not remap - Allow other apps to control this key map with intents or shortcuts Copy key map id @@ -822,6 +873,7 @@ You must be using the Key Mapper Input Method for this action to work! The app with package name %s isn\'t installed! + %s isn\'t installed! App %s is disabled! You need to grant Key Mapper permission to modify system settings. @@ -842,7 +894,7 @@ Key Mapper needs to be a device administrator! Key Mapper doesn\'t have permission to use that shortcut The app needs permission to change the Do Not Disturb state! - This action needs permission to read the phone state! + Permission needed to read the phone state! Can\'t find the WRITE_SETTINGS permission page! Error opening this app shortcut Can\'t find the Do Not Disturb access permission settings! @@ -1142,6 +1194,7 @@ Switch keyboard Switch to %s Cycle keyboard language + Cycle through keyboards Cut Copy @@ -1209,6 +1262,16 @@ Message cannot be empty Short Long + Vibrate + Vibrate for %1$s ms + Vibrate: %1$s + Duration + Effect + Effect (unsupported) + Click + Double click + Heavy click + Tick Device controls screen HTTP request HTTP Method @@ -1394,6 +1457,7 @@ Phone Power Device + Notifications Time @@ -1765,6 +1829,7 @@ Group constraints New constraint Delete group constraint + Tap a constraint to invert it This group @@ -1802,6 +1867,17 @@ Remove Edit Test + Expand + Collapse + Rename + Duplicate + Add delay + Remove + No repeat + No burst + Burst %dx + No hold down + Hold down for %dms Add actions to set what the key map should do once triggered. Recently used actions Action options @@ -1816,6 +1892,32 @@ Cancel Run these actions when you trigger the key map: Repeating under 20 ms can lag! Adjust for your device and game. + Action name + %s options + Customize + Swap action + Repeat options + This action can be set to repeat multiple times for one trigger. + Don\'t repeat + Repeat a fixed number of times + Repeat continuously until the trigger is released + Not available for side key, assistant or fingerprint gesture triggers! + Repeat continuously until the trigger is pressed again + Before repeating, wait… + Then repeat this action every… + Maximum repeat limit + Burst options + The action can be performed multiple times in a row in a rapid-fire burst. + Every time the action repeats, it can be performed multiple times in a row in a rapid-fire burst. + Burst size + Hold down options + This action can be held down instead of being pressed and released immediately. + This action can be set to be held down for a period of time, regardless of how long you press the trigger. + Don\'t hold down + Hold down for a period of time + Hold down until the trigger is released + Hold down until the trigger is pressed again + Delay options Choose side Brightness @@ -1842,9 +1944,32 @@ Are you sure you want to delete this constraint? Yes, delete Cancel - Logic mode + This key map will only run if these constraints are met. + NOT + NOT %s + Invert constraint with NOT + Single constraint + + %d AND constraint + %d AND constraints + + + %d OR constraint + %d OR constraints + + This constraint must be met: + All of these constraints must be met at the same time: + At least one of these constraints must be met: + Expand constraint group + Collapse constraint group + Add constraint to group + Delete constraint group + Delete constraint group + Are you sure you want to delete this group and all of its constraints? + Group name + Rename constraint group + Combine groups with Choose a constraint - This key map will only run if: Untitled group @@ -2138,4 +2263,44 @@ Disable auto-switch keyboard? Choosing a keyboard here will disable the automatic keyboard switching feature. You can re-enable it in Settings. + + Copy + Copy %s + + + Information + Screen off detection + Feedback + Timing + Integration with other apps + Key map ID + Detect trigger when the screen is off + This key map will vibrate when triggered + Some text will display on the screen when this key map is triggered + Trigger by intent + Enable/disable by intent + Use key maps with Tasker, Automate, etc. + Create app shortcut + Use key maps with other apps + Your launcher doesn\'t support creating shortcuts so you must create it manually + + + Any app that can send an intent, such as Tasker or Automate, can trigger this key map. Send a broadcast to Key Mapper with these settings. The intent MUST name the receiver below because Android does not deliver implicit broadcasts to other apps. + Any app that can send an intent, such as Tasker or Automate, can turn this key map on and off without opening Key Mapper. Send a broadcast to Key Mapper with these settings. + Package + Class + Action + Turn on + Turn off + Toggle on/off + String extra name + String extra value (this key map) + Target a broadcast receiver with the following + Pick the one action you want to send + Specify this key map with the following extra + Test it with ADB + Command + Run this from a computer to check your setup before building it in another app. + Key Mapper intent + diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt index 593aa20497..048114e820 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionDataEntityMapperTest.kt @@ -16,6 +16,66 @@ import org.junit.Test */ class ActionDataEntityMapperTest { + @Test + fun `open app action with no app name extra is loaded with a null saved app name`() { + val entity = ActionEntity( + type = ActionEntity.Type.APP, + data = "com.example", + ) + + assertThat( + ActionDataEntityMapper.fromEntity(entity), + `is`(ActionData.App(packageName = "com.example", savedAppName = null)), + ) + } + + @Test + fun `open app action with a saved app name round trips through the entity`() { + val action = ActionData.App(packageName = "com.example", savedAppName = "Example") + + val entity = ActionDataEntityMapper.toEntity(action) + + assertThat( + entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull(), + `is`("Example"), + ) + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `app shortcut action with a saved app name round trips through the entity`() { + val action = ActionData.AppShortcut( + packageName = "com.example", + shortcutTitle = "Do the thing", + uri = "intent:...", + savedAppName = "Example", + ) + + val entity = ActionDataEntityMapper.toEntity(action) + + assertThat( + entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull(), + `is`("Example"), + ) + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `control media for app action with a saved app name round trips through the entity`() { + val action = ActionData.ControlMediaForApp.Pause( + packageName = "com.example", + savedAppName = "Example", + ) + + val entity = ActionDataEntityMapper.toEntity(action) + + assertThat( + entity.extras.getData(ActionEntity.EXTRA_APP_NAME).valueOrNull(), + `is`("Example"), + ) + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + @Test fun `save and load the screen resolution of a tap screen action`() { // GIVEN diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionEntityMapperTest.kt new file mode 100644 index 0000000000..7ecd30102e --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionEntityMapperTest.kt @@ -0,0 +1,57 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.common.utils.hasFlag +import io.github.sds100.keymapper.data.entities.ActionEntity +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue +import org.junit.Test + +class ActionEntityMapperTest { + + @Test + fun `save and restore the custom name of an action`() { + val action = Action(data = ActionData.ConsumeKeyEvent, customName = "My action") + + val entity = ActionEntityMapper.toEntity(KeyMap(actionList = listOf(action))).single() + + assertThat(ActionEntityMapper.fromEntity(entity)?.customName, `is`("My action")) + } + + @Test + fun `do not save a blank custom name`() { + val action = Action(data = ActionData.ConsumeKeyEvent, customName = " ") + + val entity = ActionEntityMapper.toEntity(KeyMap(actionList = listOf(action))).single() + + assertThat(ActionEntityMapper.fromEntity(entity)?.customName, nullValue()) + } + + @Test + fun `action is enabled when the disabled flag is not set`() { + val entity = + ActionEntity(type = ActionEntity.Type.SYSTEM_ACTION, data = "consume_key_event") + + assertThat(ActionEntityMapper.fromEntity(entity)?.isEnabled, `is`(true)) + } + + @Test + fun `save and restore a disabled action`() { + val action = Action(data = ActionData.ConsumeKeyEvent, isEnabled = false) + + val entity = ActionEntityMapper.toEntity(KeyMap(actionList = listOf(action))).single() + + assertThat(entity.flags.hasFlag(ActionEntity.ACTION_FLAG_DISABLED), `is`(true)) + assertThat(ActionEntityMapper.fromEntity(entity)?.isEnabled, `is`(false)) + } + + @Test + fun `do not set the disabled flag for an enabled action`() { + val action = Action(data = ActionData.ConsumeKeyEvent, isEnabled = true) + + val entity = ActionEntityMapper.toEntity(KeyMap(actionList = listOf(action))).single() + + assertThat(entity.flags.hasFlag(ActionEntity.ACTION_FLAG_DISABLED), `is`(false)) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionUiHelperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionUiHelperTest.kt new file mode 100644 index 0000000000..f23f099a80 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ActionUiHelperTest.kt @@ -0,0 +1,62 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.Success +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.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@RunWith(MockitoJUnitRunner::class) +class ActionUiHelperTest { + + private lateinit var mockUseCase: DisplayActionUseCase + private lateinit var helper: ActionUiHelper + + @Before + fun init() { + mockUseCase = mock() + + val mockResourceProvider: ResourceProvider = mock() + whenever(mockResourceProvider.getString(any(), any())).thenAnswer { + it.getArgument(1).toString() + } + + helper = ActionUiHelper(mockUseCase, mockResourceProvider) + } + + @Test + fun `open app title uses the live app name when the app is installed`() { + whenever(mockUseCase.getAppName("com.example")).thenReturn(Success("Live Name")) + + val action = ActionData.App(packageName = "com.example", savedAppName = "Saved Name") + + assertThat(helper.getTitle(action, showDeviceDescriptors = false), `is`("Live Name")) + } + + @Test + fun `open app title falls back to the saved app name when the app is uninstalled`() { + whenever(mockUseCase.getAppName("com.example")) + .thenReturn(KMError.AppNotFound("com.example")) + + val action = ActionData.App(packageName = "com.example", savedAppName = "Saved Name") + + assertThat(helper.getTitle(action, showDeviceDescriptors = false), `is`("Saved Name")) + } + + @Test + fun `open app title falls back to the raw package name when no name was ever saved`() { + whenever(mockUseCase.getAppName("com.example")) + .thenReturn(KMError.AppNotFound("com.example")) + + val action = ActionData.App(packageName = "com.example", savedAppName = null) + + assertThat(helper.getTitle(action, showDeviceDescriptors = false), `is`("com.example")) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt index 96309f4a02..d978ad5e6b 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/ConfigActionsUseCaseTest.kt @@ -6,6 +6,8 @@ import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.keymaps.ClickType import io.github.sds100.keymapper.base.keymaps.ConfigKeyMapStateImpl import io.github.sds100.keymapper.base.keymaps.KeyMap +import io.github.sds100.keymapper.base.trigger.AssistantTriggerKey +import io.github.sds100.keymapper.base.trigger.AssistantTriggerType import io.github.sds100.keymapper.base.trigger.KeyEventTriggerDevice import io.github.sds100.keymapper.base.trigger.KeyEventTriggerKey import io.github.sds100.keymapper.base.utils.singleKeyTrigger @@ -17,9 +19,12 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.not +import org.hamcrest.Matchers.nullValue import org.junit.Before import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -73,6 +78,102 @@ class ConfigActionsUseCaseTest { assertThat(actionList[0].repeat, `is`(false)) } + @Test + fun `add key event action with assistant trigger, do not enable repeat`() = + runTest(testDispatcher) { + configKeyMapState.setKeyMap( + KeyMap( + trigger = singleKeyTrigger( + AssistantTriggerKey( + type = AssistantTriggerType.ANY, + clickType = ClickType.SHORT_PRESS, + ), + ), + ), + ) + + useCase.addAction(ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_W)) + + val actionList = useCase.keyMap.value.dataOrNull()!!.actionList + assertThat(actionList[0].repeat, `is`(false)) + } + + @Test + fun `add key event action with key event trigger, repeat until released`() = + runTest(testDispatcher) { + configKeyMapState.setKeyMap( + KeyMap( + trigger = singleKeyTrigger( + KeyEventTriggerKey( + keyCode = KeyEvent.KEYCODE_VOLUME_DOWN, + device = KeyEventTriggerDevice.Internal, + clickType = ClickType.SHORT_PRESS, + ), + ), + ), + ) + + useCase.addAction(ActionData.InputKeyEvent(keyCode = KeyEvent.KEYCODE_W)) + + val action = useCase.keyMap.value.dataOrNull()!!.actionList[0] + assertThat(action.repeat, `is`(true)) + assertThat(action.repeatMode, `is`(RepeatMode.TRIGGER_RELEASED)) + } + + @Test + fun `set custom name, trim the name and remove it when blank`() = runTest(testDispatcher) { + configKeyMapState.setKeyMap(KeyMap()) + useCase.addAction(ActionData.ConsumeKeyEvent) + + val uid = useCase.keyMap.value.dataOrNull()!!.actionList[0].uid + + useCase.setActionCustomName(uid, " My action ") + assertThat( + useCase.keyMap.value.dataOrNull()!!.actionList[0].customName, + `is`("My action"), + ) + + useCase.setActionCustomName(uid, " ") + assertThat(useCase.keyMap.value.dataOrNull()!!.actionList[0].customName, nullValue()) + } + + @Test + fun `disable an action, only that action is disabled`() = runTest(testDispatcher) { + configKeyMapState.setKeyMap(KeyMap()) + useCase.addAction(ActionData.ConsumeKeyEvent) + useCase.addAction(ActionData.GoHome) + + val uid = useCase.keyMap.value.dataOrNull()!!.actionList[0].uid + + useCase.setActionEnabled(uid, false) + + val actionList = useCase.keyMap.value.dataOrNull()!!.actionList + assertThat(actionList[0].isEnabled, `is`(false)) + assertThat(actionList[1].isEnabled, `is`(true)) + + useCase.setActionEnabled(uid, true) + assertThat(useCase.keyMap.value.dataOrNull()!!.actionList[0].isEnabled, `is`(true)) + } + + @Test + fun `duplicate an action, insert a copy with a new uid directly after the original`() = + runTest(testDispatcher) { + configKeyMapState.setKeyMap(KeyMap()) + useCase.addAction(ActionData.ConsumeKeyEvent) + useCase.addAction(ActionData.GoHome) + + val originalUid = useCase.keyMap.value.dataOrNull()!!.actionList[0].uid + + useCase.duplicateAction(originalUid) + + val actionList = useCase.keyMap.value.dataOrNull()!!.actionList + assertThat(actionList.size, `is`(3)) + assertThat(actionList[0].uid, `is`(originalUid)) + assertThat(actionList[1].data, `is`(ActionData.ConsumeKeyEvent)) + assertThat(actionList[1].uid, `is`(not(originalUid))) + assertThat(actionList[2].data, `is`(ActionData.GoHome)) + } + /** * Issue #852. Add a phone ringing constraint when you add an action * to answer a phone call. @@ -88,7 +189,9 @@ class ConfigActionsUseCaseTest { useCase.addAction(action) // THEN - verify(mockConfigConstraintsUseCase).addConstraint(any()) + verify( + mockConfigConstraintsUseCase, + ).addConstraint(anyOrNull(), any()) } /** @@ -106,7 +209,9 @@ class ConfigActionsUseCaseTest { useCase.addAction(action) // THEN - verify(mockConfigConstraintsUseCase).addConstraint(any()) + verify( + mockConfigConstraintsUseCase, + ).addConstraint(anyOrNull(), any()) } /** diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt index 7e98e38ec4..fedf064c79 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCaseTest.kt @@ -3,11 +3,14 @@ package io.github.sds100.keymapper.base.actions import io.github.sds100.keymapper.base.input.InputEventHub import io.github.sds100.keymapper.base.system.accessibility.IAccessibilityService import io.github.sds100.keymapper.base.system.devices.FakeDevicesAdapter +import io.github.sds100.keymapper.base.system.inputmethod.FakeInputMethodAdapter +import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.PinchScreenType import io.github.sds100.keymapper.common.utils.SizeKM import io.github.sds100.keymapper.common.utils.Success import io.github.sds100.keymapper.system.display.DisplayAdapter +import io.github.sds100.keymapper.system.inputmethod.ImeInfo import io.github.sds100.keymapper.system.popup.ToastAdapter import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -18,6 +21,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.junit.MockitoJUnitRunner import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -38,6 +43,8 @@ class PerformActionsUseCaseTest { private lateinit var mockToastAdapter: ToastAdapter private lateinit var mockInputEventHub: InputEventHub private lateinit var mockDisplayAdapter: DisplayAdapter + private lateinit var fakeInputMethodAdapter: FakeInputMethodAdapter + private lateinit var mockSwitchImeInterface: SwitchImeInterface @Before fun init() { @@ -46,11 +53,13 @@ class PerformActionsUseCaseTest { mockToastAdapter = mock() mockInputEventHub = mock() mockDisplayAdapter = mock() + fakeInputMethodAdapter = FakeInputMethodAdapter() + mockSwitchImeInterface = mock() useCase = PerformActionsUseCaseImpl( service = mockAccessibilityService, - inputMethodAdapter = mock(), - switchImeInterface = mock(), + inputMethodAdapter = fakeInputMethodAdapter, + switchImeInterface = mockSwitchImeInterface, fileAdapter = mock(), suAdapter = mock {}, shell = mock(), @@ -75,7 +84,6 @@ class PerformActionsUseCaseTest { resourceProvider = mock(), settingsRepository = mock(), soundsManager = mock(), - notificationReceiverAdapter = mock(), ringtoneAdapter = mock(), inputEventHub = mockInputEventHub, systemBridgeConnectionManager = mock(), @@ -83,6 +91,7 @@ class PerformActionsUseCaseTest { coroutineScope = testCoroutineScope, notificationAdapter = mock(), settingsAdapter = mock(), + vibratorAdapter = mock(), ) } @@ -273,4 +282,75 @@ class PerformActionsUseCaseTest { // THEN verify(mockAccessibilityService).tapScreen(eq(540), eq(1200), any()) } + + /** + * issue #1501 + */ + @Test + fun `cycle keyboard action switches to the next input method`() = runTest(testDispatcher) { + // GIVEN + val imeA = ImeInfo("ime.a", "ime.a", "Keyboard A", isEnabled = true, isChosen = true) + val imeB = ImeInfo("ime.b", "ime.b", "Keyboard B", isEnabled = true, isChosen = false) + + fakeInputMethodAdapter.inputMethods.value = listOf(imeA, imeB) + fakeInputMethodAdapter.chosenIme.value = imeA + + // Simulate the input method actually switching once switchIme is called. + whenever(mockSwitchImeInterface.switchIme(any())).doAnswer { invocation -> + val imeId = invocation.getArgument(0) + fakeInputMethodAdapter.chosenIme.value = + fakeInputMethodAdapter.inputMethods.value.first { it.id == imeId } + Success(Unit) + } + + // WHEN + useCase.perform(ActionData.CycleKeyboard) + + // THEN + verify(mockSwitchImeInterface).switchIme(eq("ime.b")) + verify(mockToastAdapter, never()).show(any(), any()) + } + + /** + * issue #1501 + */ + @Test + fun `cycle keyboard action shows an error if the keyboard does not switch in time`() = + runTest(testDispatcher) { + // GIVEN + val imeA = ImeInfo("ime.a", "ime.a", "Keyboard A", isEnabled = true, isChosen = true) + val imeB = ImeInfo("ime.b", "ime.b", "Keyboard B", isEnabled = true, isChosen = false) + + fakeInputMethodAdapter.inputMethods.value = listOf(imeA, imeB) + fakeInputMethodAdapter.chosenIme.value = imeA + // The chosen IME never actually changes to ime.b. + + // WHEN + useCase.perform(ActionData.CycleKeyboard) + + // THEN + verify(mockSwitchImeInterface).switchIme(eq("ime.b")) + verify(mockToastAdapter).show(anyOrNull(), any()) + } + + /** + * issue #1501 + */ + @Test + fun `cycle keyboard action does not switch when there is only one enabled keyboard`() = + runTest(testDispatcher) { + // GIVEN + val onlyIme = ImeInfo("ime.a", "ime.a", "Keyboard A", isEnabled = true, isChosen = true) + + fakeInputMethodAdapter.inputMethods.value = listOf(onlyIme) + fakeInputMethodAdapter.chosenIme.value = onlyIme + + // WHEN + useCase.perform(ActionData.CycleKeyboard) + + // THEN it still "switches" to the same keyboard rather than showing an error, and + // since that keyboard is already chosen the confirmation succeeds immediately. + verify(mockSwitchImeInterface).switchIme(eq("ime.a")) + verify(mockToastAdapter, never()).show(any(), any()) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/actions/VibrateActionEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/actions/VibrateActionEntityMapperTest.kt new file mode 100644 index 0000000000..1a7e3e8ed5 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/actions/VibrateActionEntityMapperTest.kt @@ -0,0 +1,29 @@ +package io.github.sds100.keymapper.base.actions + +import io.github.sds100.keymapper.base.vibration.VibrateEffect +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.junit.Test + +class VibrateActionEntityMapperTest { + + @Test + fun `save and load a vibrate action with a custom duration`() { + val action = ActionData.Vibrate(VibrateEffect.CustomDuration(500L)) + + val entity = ActionDataEntityMapper.toEntity(action) + + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } + + @Test + fun `save and load a vibrate action with a predefined effect`() { + val action = ActionData.Vibrate( + VibrateEffect.Predefined(VibrateEffect.PredefinedType.DOUBLE_CLICK), + ) + + val entity = ActionDataEntityMapper.toEntity(action) + + assertThat(ActionDataEntityMapper.fromEntity(entity), `is`(action)) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityDeserializerTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityDeserializerTest.kt new file mode 100644 index 0000000000..9c7c6c943b --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityDeserializerTest.kt @@ -0,0 +1,64 @@ +package io.github.sds100.keymapper.base.constraints + +import com.github.salomonbrys.kotson.fromJson +import com.github.salomonbrys.kotson.registerTypeAdapter +import com.google.gson.GsonBuilder +import io.github.sds100.keymapper.data.entities.ActionEntity +import io.github.sds100.keymapper.data.entities.ConstraintEntity +import io.github.sds100.keymapper.data.entities.ConstraintGroupEntity +import io.github.sds100.keymapper.data.entities.EntityExtra +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 org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue +import org.junit.Test + +/** + * Key maps saved before constraint groups and the isNot/groupUid fields were added to + * ConstraintEntity have no "isNot", "groupUid" or "constraintGroups" keys in their JSON at all. + */ +class ConstraintEntityDeserializerTest { + + private val gson = GsonBuilder() + .registerTypeAdapter(ConstraintEntity.DESERIALIZER) + .registerTypeAdapter(ConstraintGroupEntity.DESERIALIZER) + .registerTypeAdapter(TriggerEntity.DESERIALIZER) + .registerTypeAdapter(TriggerKeyEntity.SERIALIZER) + .registerTypeAdapter(TriggerKeyEntity.DESERIALIZER) + .registerTypeAdapter(ActionEntity.DESERIALIZER) + .registerTypeAdapter(EntityExtra.DESERIALIZER) + .registerTypeAdapter(KeyMapEntity.DESERIALIZER) + .create() + + @Test + fun `constraint entity json without isNot or groupUid deserializes to defaults`() { + val json = """{"type":"constraint_screen_on","extras":[],"uid":"uid1"}""" + + val entity = gson.fromJson(json) + + assertThat(entity.isNot, `is`(false)) + assertThat(entity.groupUid, nullValue()) + } + + @Test + fun `key map entity json without constraintGroups deserializes to an empty list`() { + val json = """ + { + "trigger":{"extras":[],"flags":0,"keys":[],"mode":2}, + "actionList":[], + "constraintList":[], + "constraintMode":1, + "flags":0, + "isEnabled":true, + "uid":"uid1" + } + """.trimIndent() + + val entity = gson.fromJson(json) + + assertThat(entity.constraintGroups, empty()) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt new file mode 100644 index 0000000000..b3fdbad8ac --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintEntityMapperTest.kt @@ -0,0 +1,357 @@ +package io.github.sds100.keymapper.base.constraints + +import io.github.sds100.keymapper.common.utils.valueOrNull +import io.github.sds100.keymapper.data.entities.ConstraintEntity +import io.github.sds100.keymapper.data.entities.EntityExtra +import io.github.sds100.keymapper.data.entities.getData +import io.github.sds100.keymapper.system.camera.CameraLens +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.junit.Test + +/** + * The constraints that were the inverse of another constraint were replaced by the + * positive constraint with isNot. They are migrated when they are read. + */ +@Suppress("DEPRECATION") +class ConstraintEntityMapperTest { + + companion object { + private const val UID = "constraint_uid" + } + + @Test + fun `app not in foreground is migrated to NOT app in foreground`() { + val entity = ConstraintEntity( + UID, + ConstraintEntity.APP_NOT_FOREGROUND, + EntityExtra(ConstraintEntity.EXTRA_PACKAGE_NAME, "com.example"), + ) + + assertMigratedToNot(entity, ConstraintData.AppInForeground("com.example")) + } + + @Test + fun `app not playing media is migrated to NOT app playing media`() { + val entity = ConstraintEntity( + UID, + ConstraintEntity.APP_NOT_PLAYING_MEDIA, + EntityExtra(ConstraintEntity.EXTRA_PACKAGE_NAME, "com.example"), + ) + + assertMigratedToNot(entity, ConstraintData.AppPlayingMedia("com.example")) + } + + @Test + fun `no media playing is migrated to NOT media playing`() { + val entity = ConstraintEntity(UID, ConstraintEntity.NO_MEDIA_PLAYING) + + assertMigratedToNot(entity, ConstraintData.MediaPlaying) + } + + @Test + fun `bluetooth device disconnected is migrated to NOT bluetooth device connected`() { + val entity = ConstraintEntity( + UID, + ConstraintEntity.BT_DEVICE_DISCONNECTED, + EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, "00:11:22:33:44:55"), + EntityExtra(ConstraintEntity.EXTRA_BT_NAME, "Headphones"), + ) + + assertMigratedToNot( + entity, + ConstraintData.BtDeviceConnected( + bluetoothAddress = "00:11:22:33:44:55", + deviceName = "Headphones", + ), + ) + } + + @Test + fun `screen off is migrated to NOT screen on`() { + val entity = ConstraintEntity(UID, ConstraintEntity.SCREEN_OFF) + + assertMigratedToNot(entity, ConstraintData.ScreenOn) + } + + @Test + fun `back flashlight off is migrated to NOT back flashlight on`() { + val entity = ConstraintEntity( + UID, + ConstraintEntity.FLASHLIGHT_OFF, + EntityExtra(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, "option_lens_back"), + ) + + assertMigratedToNot(entity, ConstraintData.FlashlightOn(CameraLens.BACK)) + } + + @Test + fun `front flashlight off is migrated to NOT front flashlight on`() { + val entity = ConstraintEntity( + UID, + ConstraintEntity.FLASHLIGHT_OFF, + EntityExtra(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, "option_lens_front"), + ) + + assertMigratedToNot(entity, ConstraintData.FlashlightOn(CameraLens.FRONT)) + } + + @Test + fun `wifi off is migrated to NOT wifi on`() { + val entity = ConstraintEntity(UID, ConstraintEntity.WIFI_OFF) + + assertMigratedToNot(entity, ConstraintData.WifiOn) + } + + @Test + fun `wifi disconnected from a network is migrated to NOT wifi connected to the network`() { + val entity = ConstraintEntity( + UID, + ConstraintEntity.WIFI_DISCONNECTED, + EntityExtra(ConstraintEntity.EXTRA_SSID, "home"), + ) + + assertMigratedToNot(entity, ConstraintData.WifiConnected(ssid = "home")) + } + + @Test + fun `wifi disconnected from any network is migrated to NOT wifi connected to any network`() { + val entity = ConstraintEntity(UID, ConstraintEntity.WIFI_DISCONNECTED) + + assertMigratedToNot(entity, ConstraintData.WifiConnected(ssid = null)) + } + + @Test + fun `input method not chosen is migrated to NOT input method chosen`() { + val entity = ConstraintEntity( + UID, + ConstraintEntity.IME_NOT_CHOSEN, + EntityExtra(ConstraintEntity.EXTRA_IME_ID, "ime_id"), + EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, "Gboard"), + ) + + assertMigratedToNot(entity, ConstraintData.ImeChosen(imeId = "ime_id", imeLabel = "Gboard")) + } + + @Test + fun `keyboard not showing is migrated to NOT keyboard showing`() { + val entity = ConstraintEntity(UID, ConstraintEntity.KEYBOARD_NOT_SHOWING) + + assertMigratedToNot(entity, ConstraintData.KeyboardShowing) + } + + @Test + fun `device is unlocked is migrated to NOT device is locked`() { + val entity = ConstraintEntity(UID, ConstraintEntity.DEVICE_IS_UNLOCKED) + + assertMigratedToNot(entity, ConstraintData.DeviceIsLocked) + } + + @Test + fun `lock screen not showing is migrated to NOT lock screen showing`() { + val entity = ConstraintEntity(UID, ConstraintEntity.LOCK_SCREEN_NOT_SHOWING) + + assertMigratedToNot(entity, ConstraintData.LockScreenShowing) + } + + @Test + fun `discharging is migrated to NOT charging`() { + val entity = ConstraintEntity(UID, ConstraintEntity.DISCHARGING) + + assertMigratedToNot(entity, ConstraintData.Charging) + } + + @Test + fun `notification panel not showing is migrated to NOT notification panel showing`() { + val entity = ConstraintEntity(UID, ConstraintEntity.NOTIFICATION_PANEL_NOT_SHOWING) + + assertMigratedToNot(entity, ConstraintData.NotificationPanelShowing) + } + + @Test + fun `hinge open is not migrated because it is not the inverse of hinge closed`() { + val entity = ConstraintEntity(UID, ConstraintEntity.HINGE_OPEN) + + assertThat( + ConstraintEntityMapper.fromEntity(entity), + `is`(Constraint(uid = UID, data = ConstraintData.HingeOpen, isNot = false)), + ) + } + + @Test + fun `not in phone call is not migrated because it is not the inverse of in phone call`() { + val entity = ConstraintEntity(UID, ConstraintEntity.NOT_IN_PHONE_CALL) + + assertThat( + ConstraintEntityMapper.fromEntity(entity), + `is`(Constraint(uid = UID, data = ConstraintData.NotInPhoneCall, isNot = false)), + ) + } + + @Test + fun `constraint with isNot false is not inverted`() { + val entity = ConstraintEntity(UID, ConstraintEntity.SCREEN_ON) + + assertThat( + ConstraintEntityMapper.fromEntity(entity), + `is`(Constraint(uid = UID, data = ConstraintData.ScreenOn, isNot = false)), + ) + } + + @Test + fun `constraint with isNot true is inverted`() { + val entity = ConstraintEntity( + type = ConstraintEntity.SCREEN_ON, + extras = emptyList(), + uid = UID, + isNot = true, + ) + + assertThat( + ConstraintEntityMapper.fromEntity(entity), + `is`(Constraint(uid = UID, data = ConstraintData.ScreenOn, isNot = true)), + ) + } + + @Test + fun `deprecated constraint type with isNot true becomes the positive constraint`() { + val entity = ConstraintEntity( + type = ConstraintEntity.SCREEN_OFF, + extras = emptyList(), + uid = UID, + isNot = true, + ) + + assertThat( + ConstraintEntityMapper.fromEntity(entity), + `is`(Constraint(uid = UID, data = ConstraintData.ScreenOn, isNot = false)), + ) + } + + @Test + fun `save NOT constraint with the positive type and isNot true`() { + val constraint = Constraint( + uid = UID, + data = ConstraintData.AppInForeground("com.example"), + isNot = true, + ) + + val entity = ConstraintEntityMapper.toEntity(constraint) + + assertThat(entity.type, `is`(ConstraintEntity.APP_FOREGROUND)) + assertThat(entity.isNot, `is`(true)) + assertThat( + entity.extras.getData(ConstraintEntity.EXTRA_PACKAGE_NAME).valueOrNull(), + `is`("com.example"), + ) + } + + @Test + fun `isNot is false if the constraint is not inverted`() { + val constraint = Constraint(uid = UID, data = ConstraintData.ScreenOn) + + val entity = ConstraintEntityMapper.toEntity(constraint) + + assertThat(entity.isNot, `is`(false)) + } + + @Test + fun `migrated constraints are saved with the positive type and loaded the same`() { + val deprecatedEntities = listOf( + ConstraintEntity( + UID, + ConstraintEntity.APP_NOT_FOREGROUND, + EntityExtra(ConstraintEntity.EXTRA_PACKAGE_NAME, "com.example"), + ), + ConstraintEntity( + UID, + ConstraintEntity.APP_NOT_PLAYING_MEDIA, + EntityExtra(ConstraintEntity.EXTRA_PACKAGE_NAME, "com.example"), + ), + ConstraintEntity(UID, ConstraintEntity.NO_MEDIA_PLAYING), + ConstraintEntity( + UID, + ConstraintEntity.BT_DEVICE_DISCONNECTED, + EntityExtra(ConstraintEntity.EXTRA_BT_ADDRESS, "00:11:22:33:44:55"), + EntityExtra(ConstraintEntity.EXTRA_BT_NAME, "Headphones"), + ), + ConstraintEntity(UID, ConstraintEntity.SCREEN_OFF), + ConstraintEntity( + UID, + ConstraintEntity.FLASHLIGHT_OFF, + EntityExtra(ConstraintEntity.EXTRA_FLASHLIGHT_CAMERA_LENS, "option_lens_back"), + ), + ConstraintEntity(UID, ConstraintEntity.WIFI_OFF), + ConstraintEntity( + UID, + ConstraintEntity.WIFI_DISCONNECTED, + EntityExtra(ConstraintEntity.EXTRA_SSID, "home"), + ), + ConstraintEntity( + UID, + ConstraintEntity.IME_NOT_CHOSEN, + EntityExtra(ConstraintEntity.EXTRA_IME_ID, "ime_id"), + EntityExtra(ConstraintEntity.EXTRA_IME_LABEL, "Gboard"), + ), + ConstraintEntity(UID, ConstraintEntity.KEYBOARD_NOT_SHOWING), + ConstraintEntity(UID, ConstraintEntity.DEVICE_IS_UNLOCKED), + ConstraintEntity(UID, ConstraintEntity.LOCK_SCREEN_NOT_SHOWING), + ConstraintEntity(UID, ConstraintEntity.DISCHARGING), + ConstraintEntity(UID, ConstraintEntity.NOTIFICATION_PANEL_NOT_SHOWING), + ) + + for (deprecatedEntity in deprecatedEntities) { + val migrated = ConstraintEntityMapper.fromEntity(deprecatedEntity) + val savedEntity = ConstraintEntityMapper.toEntity(migrated) + + assertThat( + "${deprecatedEntity.type} must not be saved with a deprecated type", + savedEntity.type == deprecatedEntity.type, + `is`(false), + ) + assertThat(ConstraintEntityMapper.fromEntity(savedEntity), `is`(migrated)) + } + } + + @Test + fun `app in foreground with no app name extra is loaded with a null app name`() { + val entity = ConstraintEntity( + UID, + ConstraintEntity.APP_FOREGROUND, + EntityExtra(ConstraintEntity.EXTRA_PACKAGE_NAME, "com.example"), + ) + + assertThat( + ConstraintEntityMapper.fromEntity(entity), + `is`( + Constraint( + uid = UID, + data = ConstraintData.AppInForeground("com.example", appName = null), + ), + ), + ) + } + + @Test + fun `app in foreground with a saved app name round trips through the entity`() { + val constraint = Constraint( + uid = UID, + data = ConstraintData.AppInForeground("com.example", appName = "Example"), + ) + + val entity = ConstraintEntityMapper.toEntity(constraint) + + assertThat( + entity.extras.getData(ConstraintEntity.EXTRA_APP_NAME).valueOrNull(), + `is`("Example"), + ) + assertThat(ConstraintEntityMapper.fromEntity(entity), `is`(constraint)) + } + + private fun assertMigratedToNot(entity: ConstraintEntity, expectedData: ConstraintData) { + assertThat( + ConstraintEntityMapper.fromEntity(entity), + `is`(Constraint(uid = UID, data = expectedData, isNot = true)), + ) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt new file mode 100644 index 0000000000..6df3726b64 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintErrorSnapshotTest.kt @@ -0,0 +1,117 @@ +package io.github.sds100.keymapper.base.constraints + +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.system.apps.PackageManagerAdapter +import io.github.sds100.keymapper.system.camera.CameraAdapter +import io.github.sds100.keymapper.system.inputmethod.InputMethodAdapter +import io.github.sds100.keymapper.system.permissions.PermissionAdapter +import io.github.sds100.keymapper.system.permissions.SystemFeatureAdapter +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue +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.whenever + +@RunWith(MockitoJUnitRunner::class) +class ConstraintErrorSnapshotTest { + + private lateinit var mockPackageManagerAdapter: PackageManagerAdapter + private lateinit var mockPermissionAdapter: PermissionAdapter + private lateinit var mockInputMethodAdapter: InputMethodAdapter + private lateinit var snapshot: ConstraintErrorSnapshot + + @Before + fun init() { + mockPackageManagerAdapter = mock() + mockPermissionAdapter = mock() + mockInputMethodAdapter = mock() + val mockSystemFeatureAdapter: SystemFeatureAdapter = mock() + val mockCameraAdapter: CameraAdapter = mock() + + snapshot = LazyConstraintErrorSnapshot( + mockPackageManagerAdapter, + mockPermissionAdapter, + mockSystemFeatureAdapter, + mockInputMethodAdapter, + mockCameraAdapter, + ) + } + + private fun setAppMissing(packageName: String) { + whenever(mockPackageManagerAdapter.isAppEnabled(packageName)).thenReturn(Success(true)) + whenever(mockPackageManagerAdapter.isAppInstalled(packageName)).thenReturn(false) + } + + private fun setAppDisabled(packageName: String) { + // getAppError returns as soon as it finds the app is disabled, without checking + // whether it's installed, so isAppInstalled is never called for a disabled app. + whenever(mockPackageManagerAdapter.isAppEnabled(packageName)).thenReturn(Success(false)) + } + + @Test + fun `negated app in foreground constraint has no error when app is uninstalled`() { + setAppMissing("com.uninstalled") + + val constraint = Constraint( + data = ConstraintData.AppInForeground("com.uninstalled"), + isNot = true, + ) + + assertThat(snapshot.getError(constraint), `is`(nullValue())) + } + + @Test + fun `negated app in foreground constraint has no error when app is disabled`() { + setAppDisabled("com.disabled") + + val constraint = Constraint( + data = ConstraintData.AppInForeground("com.disabled"), + isNot = true, + ) + + assertThat(snapshot.getError(constraint), `is`(nullValue())) + } + + @Test + fun `non-negated app in foreground constraint still errors when app is uninstalled`() { + setAppMissing("com.uninstalled") + + val constraint = Constraint(data = ConstraintData.AppInForeground("com.uninstalled")) + + assertThat( + snapshot.getError(constraint), + `is`(KMError.AppNotFound("com.uninstalled") as KMError?), + ) + } + + @Test + fun `non-negated app in foreground constraint still errors when app is disabled`() { + setAppDisabled("com.disabled") + + val constraint = Constraint(data = ConstraintData.AppInForeground("com.disabled")) + + assertThat( + snapshot.getError(constraint), + `is`(KMError.AppDisabled("com.disabled") as KMError?), + ) + } + + @Test + fun `app not found error includes the saved app name when it is available`() { + setAppMissing("com.uninstalled") + + val constraint = Constraint( + data = ConstraintData.AppInForeground("com.uninstalled", appName = "Example"), + ) + + assertThat( + snapshot.getError(constraint), + `is`(KMError.AppNotFound("com.uninstalled", "Example") as KMError?), + ) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt index 116070ee6f..d07a26a5bc 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintSnapshotTest.kt @@ -6,6 +6,31 @@ import org.hamcrest.Matchers.`is` import org.junit.Test class ConstraintSnapshotTest { + + private val satisfied = Constraint(data = ConstraintData.AppInForeground("key_mapper")) + private val satisfied2 = Constraint(data = ConstraintData.ScreenOn) + private val unsatisfied = Constraint(data = ConstraintData.AppInForeground("google")) + private val unsatisfied2 = Constraint(data = ConstraintData.WifiOn) + + /** + * The app in foreground and the screen is on. Wi-Fi is off. + */ + private val snapshot = TestConstraintSnapshot( + appInForeground = "key_mapper", + isScreenOn = true, + isWifiEnabled = false, + ) + + private fun group( + vararg constraints: Constraint, + mode: ConstraintMode = ConstraintMode.AND, + ): ConstraintGroup = ConstraintGroup(constraints = constraints.toList(), mode = mode) + + private fun state( + vararg groups: ConstraintGroup, + mode: ConstraintMode = ConstraintMode.AND, + ): ConstraintState = ConstraintState(groups = groups.toList(), mode = mode) + @Test fun `When two constraints in three states, one OR and one AND, and all satisfied return true`() { val snapshot = TestConstraintSnapshot( @@ -15,34 +40,29 @@ class ConstraintSnapshotTest { isLockscreenShowing = true, ) - val state1 = ConstraintState( - constraints = - setOf( + val state1 = state( + group( Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")), - Constraint(data = ConstraintData.Discharging), + Constraint(data = ConstraintData.Charging, isNot = true), + mode = ConstraintMode.AND, ), - mode = ConstraintMode.AND, ) - val state2 = - ConstraintState( - constraints = - setOf( - Constraint(data = ConstraintData.LockScreenNotShowing), - Constraint(data = ConstraintData.DeviceIsUnlocked), - ), + val state2 = state( + group( + Constraint(data = ConstraintData.LockScreenShowing, isNot = true), + Constraint(data = ConstraintData.DeviceIsLocked, isNot = true), mode = ConstraintMode.OR, - ) - - val state3 = - ConstraintState( - constraints = - setOf( - Constraint(data = ConstraintData.LockScreenShowing), - Constraint(data = ConstraintData.DeviceIsUnlocked), - ), + ), + ) + + val state3 = state( + group( + Constraint(data = ConstraintData.LockScreenShowing), + Constraint(data = ConstraintData.DeviceIsLocked, isNot = true), mode = ConstraintMode.AND, - ) + ), + ) assertThat(snapshot.isSatisfied(state1, state2, state3), `is`(true)) } @@ -55,24 +75,21 @@ class ConstraintSnapshotTest { isLocked = true, ) - val state1 = ConstraintState( - constraints = - setOf( + val state1 = state( + group( Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")), - Constraint(data = ConstraintData.Discharging), + Constraint(data = ConstraintData.Charging, isNot = true), + mode = ConstraintMode.AND, ), - mode = ConstraintMode.AND, ) - val state2 = - ConstraintState( - constraints = - setOf( - Constraint(data = ConstraintData.Charging), - Constraint(data = ConstraintData.DeviceIsUnlocked), - ), + val state2 = state( + group( + Constraint(data = ConstraintData.Charging), + Constraint(data = ConstraintData.DeviceIsLocked, isNot = true), mode = ConstraintMode.OR, - ) + ), + ) assertThat(snapshot.isSatisfied(state1, state2), `is`(false)) } @@ -85,157 +102,320 @@ class ConstraintSnapshotTest { isLocked = true, ) - val state1 = ConstraintState( - constraints = - setOf( + val state1 = state( + group( Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")), Constraint(data = ConstraintData.Charging), + mode = ConstraintMode.AND, ), - mode = ConstraintMode.AND, ) - val state2 = - ConstraintState( - constraints = - setOf( - Constraint(data = ConstraintData.Charging), - Constraint(data = ConstraintData.DeviceIsUnlocked), - ), + val state2 = state( + group( + Constraint(data = ConstraintData.Charging), + Constraint(data = ConstraintData.DeviceIsLocked, isNot = true), mode = ConstraintMode.OR, - ) + ), + ) assertThat(snapshot.isSatisfied(state1, state2), `is`(true)) } @Test fun `When one constraint in two states and all satisfied return true`() { - val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper", isCharging = true) - - val state1 = ConstraintState( - constraints = - setOf(Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper"))), + assertThat( + snapshot.isSatisfied(state(group(satisfied)), state(group(satisfied2))), + `is`(true), ) + } - val state2 = - ConstraintState( - constraints = - setOf(Constraint(data = ConstraintData.Charging)), - ) + @Test + fun `When one constraint in two states and all unsatisfied return false`() { + assertThat( + snapshot.isSatisfied(state(group(unsatisfied)), state(group(unsatisfied2))), + `is`(false), + ) + } - assertThat(snapshot.isSatisfied(state1, state2), `is`(true)) + @Test + fun `When one constraint in two states and one unsatisfied return false`() { + assertThat( + snapshot.isSatisfied(state(group(unsatisfied)), state(group(satisfied))), + `is`(false), + ) } @Test - fun `When one constraint in two states and all unsatisfied return false`() { - val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper") + fun `When no constraints in two states return true`() { + assertThat(snapshot.isSatisfied(state(), state()), `is`(true)) + } - val state1 = ConstraintState( - constraints = - setOf(Constraint(data = ConstraintData.AppInForeground(packageName = "google"))), + @Test + fun `When no constraints in two states with mixed constraint modes return true`() { + assertThat( + snapshot.isSatisfied(state(mode = ConstraintMode.OR), state(mode = ConstraintMode.AND)), + `is`(true), ) + } - val state2 = - ConstraintState( - constraints = - setOf(Constraint(data = ConstraintData.AppInForeground(packageName = "google1"))), - ) + @Test + fun `When one constraint and unsatisfied return false`() { + assertThat(snapshot.isSatisfied(state(group(unsatisfied))), `is`(false)) + } - assertThat(snapshot.isSatisfied(state1, state2), `is`(false)) + @Test + fun `When one constraint and satisfied return true`() { + assertThat(snapshot.isSatisfied(state(group(satisfied))), `is`(true)) } @Test - fun `When one constraint in two states and one unsatisfied return false`() { - val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper") + fun `When no constraints return true`() { + assertThat(snapshot.isSatisfied(state()), `is`(true)) + } - val state1 = ConstraintState( - constraints = - setOf(Constraint(data = ConstraintData.AppInForeground(packageName = "google"))), + @Test + fun `When keyboard is showing and constraint is KeyboardShowing return true`() { + val snapshot = TestConstraintSnapshot(isKeyboardShowing = true) + val constraint = Constraint(data = ConstraintData.KeyboardShowing) + assertThat(snapshot.isSatisfied(state(group(constraint))), `is`(true)) + } + + @Test + fun `When keyboard is not showing and constraint is KeyboardShowing return false`() { + val snapshot = TestConstraintSnapshot(isKeyboardShowing = false) + val constraint = Constraint(data = ConstraintData.KeyboardShowing) + assertThat(snapshot.isSatisfied(state(group(constraint))), `is`(false)) + } + + @Test + fun `When keyboard is not showing and constraint is NOT KeyboardShowing return true`() { + val snapshot = TestConstraintSnapshot(isKeyboardShowing = false) + val constraint = Constraint(data = ConstraintData.KeyboardShowing, isNot = true) + assertThat(snapshot.isSatisfied(state(group(constraint))), `is`(true)) + } + + @Test + fun `When keyboard is showing and constraint is NOT KeyboardShowing return false`() { + val snapshot = TestConstraintSnapshot(isKeyboardShowing = true) + val constraint = Constraint(data = ConstraintData.KeyboardShowing, isNot = true) + assertThat(snapshot.isSatisfied(state(group(constraint))), `is`(false)) + } + + @Test + fun `NOT on a satisfied constraint makes it unsatisfied`() { + assertThat(snapshot.isSatisfied(satisfied.copy(isNot = true)), `is`(false)) + } + + @Test + fun `NOT on an unsatisfied constraint makes it satisfied`() { + assertThat(snapshot.isSatisfied(unsatisfied.copy(isNot = true)), `is`(true)) + } + + @Test + fun `AND group with all satisfied constraints is satisfied`() { + val state = state(group(satisfied, satisfied2, mode = ConstraintMode.AND)) + assertThat(snapshot.isSatisfied(state), `is`(true)) + } + + @Test + fun `AND group with one unsatisfied constraint is not satisfied`() { + val state = state(group(satisfied, unsatisfied, mode = ConstraintMode.AND)) + assertThat(snapshot.isSatisfied(state), `is`(false)) + } + + @Test + fun `OR group with one satisfied constraint is satisfied`() { + val state = state(group(unsatisfied, satisfied, mode = ConstraintMode.OR)) + assertThat(snapshot.isSatisfied(state), `is`(true)) + } + + @Test + fun `OR group with no satisfied constraints is not satisfied`() { + val state = state(group(unsatisfied, unsatisfied2, mode = ConstraintMode.OR)) + assertThat(snapshot.isSatisfied(state), `is`(false)) + } + + @Test + fun `AND between groups when all groups are satisfied`() { + val state = state( + group(satisfied), + group(satisfied2), + mode = ConstraintMode.AND, ) - val state2 = - ConstraintState( - constraints = - setOf( - Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")), - ), - ) + assertThat(snapshot.isSatisfied(state), `is`(true)) + } - assertThat(snapshot.isSatisfied(state1, state2), `is`(false)) + @Test + fun `AND between groups when one group is not satisfied`() { + val state = state( + group(satisfied), + group(unsatisfied), + mode = ConstraintMode.AND, + ) + + assertThat(snapshot.isSatisfied(state), `is`(false)) } @Test - fun `When no constraints in two states return true`() { - val snapshot = TestConstraintSnapshot() + fun `OR between groups when one group is satisfied`() { + val state = state( + group(unsatisfied), + group(satisfied), + mode = ConstraintMode.OR, + ) + + assertThat(snapshot.isSatisfied(state), `is`(true)) + } - val state1 = ConstraintState(constraints = emptySet()) - val state2 = ConstraintState(constraints = emptySet()) + @Test + fun `OR between groups when no groups are satisfied`() { + val state = state( + group(unsatisfied), + group(unsatisfied2), + mode = ConstraintMode.OR, + ) - assertThat(snapshot.isSatisfied(state1, state2), `is`(true)) + assertThat(snapshot.isSatisfied(state), `is`(false)) } @Test - fun `When no constraints in two states with mixed constraint modes return true`() { - val snapshot = TestConstraintSnapshot() + fun `(A OR B) AND (C OR D) when each group has one satisfied constraint`() { + val state = state( + group(unsatisfied, satisfied, mode = ConstraintMode.OR), + group(satisfied2, unsatisfied2, mode = ConstraintMode.OR), + mode = ConstraintMode.AND, + ) - val state1 = ConstraintState(constraints = emptySet(), mode = ConstraintMode.OR) - val state2 = ConstraintState(constraints = emptySet(), mode = ConstraintMode.AND) + assertThat(snapshot.isSatisfied(state), `is`(true)) + } - assertThat(snapshot.isSatisfied(state1, state2), `is`(true)) + @Test + fun `(A OR B) AND (C OR D) when one group has no satisfied constraints`() { + val state = state( + group(unsatisfied, satisfied, mode = ConstraintMode.OR), + group(unsatisfied, unsatisfied2, mode = ConstraintMode.OR), + mode = ConstraintMode.AND, + ) + + assertThat(snapshot.isSatisfied(state), `is`(false)) } @Test - fun `When one constraint and unsatisfied return false`() { - val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper") - val constraint = Constraint(data = ConstraintData.AppInForeground(packageName = "google")) - val state = ConstraintState(constraints = setOf(constraint)) + fun `(A AND B) OR (C AND D) when only the second group is fully satisfied`() { + val state = state( + group(satisfied, unsatisfied, mode = ConstraintMode.AND), + group(satisfied, satisfied2, mode = ConstraintMode.AND), + mode = ConstraintMode.OR, + ) + + assertThat(snapshot.isSatisfied(state), `is`(true)) + } + + @Test + fun `(A AND B) OR (C AND D) when neither group is fully satisfied`() { + val state = state( + group(satisfied, unsatisfied, mode = ConstraintMode.AND), + group(unsatisfied2, satisfied2, mode = ConstraintMode.AND), + mode = ConstraintMode.OR, + ) + assertThat(snapshot.isSatisfied(state), `is`(false)) } @Test - fun `When one constraint and satisfied return true`() { - val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper") - val constraint = - Constraint(data = ConstraintData.AppInForeground(packageName = "key_mapper")) - val state = ConstraintState(constraints = setOf(constraint)) + fun `(A AND B) AND (C OR D) with mixed group modes`() { + val state = state( + group(satisfied, satisfied2, mode = ConstraintMode.AND), + group(unsatisfied, satisfied, mode = ConstraintMode.OR), + mode = ConstraintMode.AND, + ) + assertThat(snapshot.isSatisfied(state), `is`(true)) } @Test - fun `When no constraints return true`() { - val snapshot = TestConstraintSnapshot(appInForeground = "key_mapper") - val state = ConstraintState(constraints = emptySet()) + fun `(A OR B) OR (C AND D) with mixed group modes when only the OR group is satisfied`() { + val state = state( + group(unsatisfied, satisfied, mode = ConstraintMode.OR), + group(satisfied2, unsatisfied2, mode = ConstraintMode.AND), + mode = ConstraintMode.OR, + ) + assertThat(snapshot.isSatisfied(state), `is`(true)) } @Test - fun `When keyboard is showing and constraint is KeyboardShowing return true`() { - val snapshot = TestConstraintSnapshot(isKeyboardShowing = true) - val constraint = Constraint(data = ConstraintData.KeyboardShowing) - val state = ConstraintState(constraints = setOf(constraint)) + fun `NOT constraints inside groups are inverted before combining the groups`() { + // (NOT unsatisfied AND satisfied) AND (NOT satisfied OR NOT unsatisfied2) + val state = state( + group(unsatisfied.copy(isNot = true), satisfied, mode = ConstraintMode.AND), + group( + satisfied.copy(isNot = true), + unsatisfied2.copy(isNot = true), + mode = ConstraintMode.OR, + ), + mode = ConstraintMode.AND, + ) + assertThat(snapshot.isSatisfied(state), `is`(true)) } @Test - fun `When keyboard is not showing and constraint is KeyboardShowing return false`() { - val snapshot = TestConstraintSnapshot(isKeyboardShowing = false) - val constraint = Constraint(data = ConstraintData.KeyboardShowing) - val state = ConstraintState(constraints = setOf(constraint)) + fun `NOT on every constraint in an OR group is not satisfied when all are satisfied`() { + val state = state( + group( + satisfied.copy(isNot = true), + satisfied2.copy(isNot = true), + mode = ConstraintMode.OR, + ), + ) + assertThat(snapshot.isSatisfied(state), `is`(false)) } @Test - fun `When keyboard is not showing and constraint is KeyboardNotShowing return true`() { - val snapshot = TestConstraintSnapshot(isKeyboardShowing = false) - val constraint = Constraint(data = ConstraintData.KeyboardNotShowing) - val state = ConstraintState(constraints = setOf(constraint)) + fun `Empty groups are ignored when combining groups with AND`() { + val state = state( + group(), + group(satisfied), + mode = ConstraintMode.AND, + ) + assertThat(snapshot.isSatisfied(state), `is`(true)) } @Test - fun `When keyboard is showing and constraint is KeyboardNotShowing return false`() { - val snapshot = TestConstraintSnapshot(isKeyboardShowing = true) - val constraint = Constraint(data = ConstraintData.KeyboardNotShowing) - val state = ConstraintState(constraints = setOf(constraint)) + fun `Empty groups do not satisfy an OR between groups`() { + val state = state( + group(), + group(unsatisfied), + mode = ConstraintMode.OR, + ) + assertThat(snapshot.isSatisfied(state), `is`(false)) } + + @Test + fun `Only empty groups are satisfied`() { + val state = state(group(), group(mode = ConstraintMode.OR), mode = ConstraintMode.OR) + + assertThat(snapshot.isSatisfied(state), `is`(true)) + } + + @Test + fun `Multiple states with groups are combined with AND`() { + val keyMapState = state( + group(unsatisfied), + group(satisfied), + mode = ConstraintMode.OR, + ) + + val satisfiedGroupState = state(group(satisfied, satisfied2)) + val unsatisfiedGroupState = + state(group(unsatisfied, unsatisfied2, mode = ConstraintMode.OR)) + + assertThat(snapshot.isSatisfied(keyMapState, satisfiedGroupState), `is`(true)) + assertThat(snapshot.isSatisfied(keyMapState, unsatisfiedGroupState), `is`(false)) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapperTest.kt new file mode 100644 index 0000000000..692a7dcebd --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintStateEntityMapperTest.kt @@ -0,0 +1,365 @@ +package io.github.sds100.keymapper.base.constraints + +import io.github.sds100.keymapper.data.entities.ConstraintEntity +import io.github.sds100.keymapper.data.entities.ConstraintGroupEntity +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.everyItem +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue +import org.junit.Test + +class ConstraintStateEntityMapperTest { + + @Test + fun `flat constraints are loaded as one group with the saved mode`() { + val entities = listOf( + ConstraintEntity("uid1", ConstraintEntity.SCREEN_ON), + ConstraintEntity("uid2", ConstraintEntity.CHARGING), + ) + + val state = ConstraintStateEntityMapper.fromEntity(entities, ConstraintEntity.MODE_OR) + + assertThat(state.mode, `is`(ConstraintMode.OR)) + assertThat(state.groups, hasSize(1)) + assertThat(state.groups[0].mode, `is`(ConstraintMode.OR)) + assertThat( + state.groups[0].constraints, + contains( + Constraint(uid = "uid1", data = ConstraintData.ScreenOn), + Constraint(uid = "uid2", data = ConstraintData.Charging), + ), + ) + } + + @Suppress("DEPRECATION") + @Test + fun `deprecated constraints are migrated in one group`() { + val entities = listOf( + ConstraintEntity("uid1", ConstraintEntity.SCREEN_OFF), + ConstraintEntity("uid2", ConstraintEntity.DISCHARGING), + ) + + val state = ConstraintStateEntityMapper.fromEntity(entities, ConstraintEntity.MODE_AND) + + assertThat(state.groups.size, `is`(1)) + assertThat( + state.allConstraints, + `is`( + listOf( + Constraint(uid = "uid1", data = ConstraintData.ScreenOn, isNot = true), + Constraint(uid = "uid2", data = ConstraintData.Charging, isNot = true), + ), + ), + ) + } + + @Test + fun `no constraints are loaded as no groups`() { + val state = ConstraintStateEntityMapper.fromEntity(emptyList(), ConstraintEntity.MODE_OR) + + assertThat(state.groups, empty()) + assertThat(state.mode, `is`(ConstraintMode.OR)) + } + + @Test + fun `save and load flat constraints keeps the constraints and mode`() { + val state = ConstraintState( + groups = listOf( + ConstraintGroup( + uid = "uid1", + constraints = listOf( + Constraint(uid = "uid1", data = ConstraintData.ScreenOn, isNot = true), + Constraint(uid = "uid2", data = ConstraintData.Charging), + ), + mode = ConstraintMode.OR, + ), + ), + mode = ConstraintMode.OR, + ) + + val (entities, mode) = ConstraintStateEntityMapper.toEntity(state) + + assertThat(entities.map { it.groupUid }, everyItem(nullValue())) + + val loadedState = ConstraintStateEntityMapper.fromEntity(entities, mode) + + assertThat(loadedState.mode, `is`(ConstraintMode.OR)) + assertThat(loadedState.groups, hasSize(1)) + assertThat(loadedState.groups[0].mode, `is`(ConstraintMode.OR)) + assertThat( + state.groups[0].constraints, + contains( + Constraint(uid = "uid1", data = ConstraintData.ScreenOn, isNot = true), + Constraint(uid = "uid2", data = ConstraintData.Charging), + ), + ) + } + + @Test + fun `no groups are saved with the constraint state mode`() { + val state = ConstraintState(groups = emptyList(), mode = ConstraintMode.OR) + + val (entities, mode) = ConstraintStateEntityMapper.toEntity(state) + + assertThat(entities, empty()) + assertThat(mode, `is`(ConstraintEntity.MODE_OR)) + } + + @Test + fun `constraints saved before groups existed are loaded as one group with the saved mode`() { + val entities = listOf( + ConstraintEntity("uid1", ConstraintEntity.SCREEN_ON), + ConstraintEntity("uid2", ConstraintEntity.CHARGING), + ) + + val state = ConstraintStateEntityMapper.fromEntityWithGroups( + entities, + emptyList(), + ConstraintEntity.MODE_OR, + ) + + assertThat(state.mode, `is`(ConstraintMode.OR)) + assertThat(state.groups, hasSize(1)) + assertThat(state.groups[0].mode, `is`(ConstraintMode.OR)) + assertThat( + state.groups[0].constraints, + contains( + Constraint(uid = "uid1", data = ConstraintData.ScreenOn), + Constraint(uid = "uid2", data = ConstraintData.Charging), + ), + ) + } + + @Test + fun `no constraints with groups are loaded as no groups`() { + val state = ConstraintStateEntityMapper.fromEntityWithGroups( + emptyList(), + emptyList(), + ConstraintEntity.MODE_OR, + ) + + assertThat(state.groups, empty()) + assertThat(state.mode, `is`(ConstraintMode.OR)) + } + + @Test + fun `no groups are saved with groups as the constraint state mode`() { + val state = ConstraintState(groups = emptyList(), mode = ConstraintMode.OR) + + val (entities, groups, mode) = ConstraintStateEntityMapper.toEntityWithGroups(state) + + assertThat(entities, empty()) + assertThat(groups, empty()) + assertThat(mode, `is`(ConstraintEntity.MODE_OR)) + } + + @Test + fun `one group is saved with a group entity and with the group mode`() { + val state = ConstraintState( + groups = listOf( + ConstraintGroup( + uid = "group1", + constraints = listOf( + Constraint(uid = "uid1", data = ConstraintData.ScreenOn), + Constraint(uid = "uid2", data = ConstraintData.Charging, isNot = true), + ), + mode = ConstraintMode.OR, + ), + ), + mode = ConstraintMode.AND, + ) + + val (entities, groups, groupMode) = ConstraintStateEntityMapper.toEntityWithGroups(state) + + assertThat(groupMode, `is`(ConstraintEntity.MODE_AND)) + assertThat(groups, hasSize(1)) + assertThat( + groups, + contains(ConstraintGroupEntity(uid = "group1", mode = ConstraintEntity.MODE_OR)), + ) + assertThat(entities.map { it.groupUid }, everyItem(`is`("group1"))) + } + + @Test + fun `multiple groups are saved with a group entity per group and the mode between groups`() { + val state = ConstraintState( + groups = listOf( + ConstraintGroup( + uid = "group1", + name = "My group", + constraints = listOf(Constraint(uid = "uid1", data = ConstraintData.ScreenOn)), + mode = ConstraintMode.OR, + ), + ConstraintGroup( + uid = "group2", + constraints = listOf(Constraint(uid = "uid2", data = ConstraintData.Charging)), + mode = ConstraintMode.AND, + ), + ), + mode = ConstraintMode.OR, + ) + + val (entities, groups, mode) = ConstraintStateEntityMapper.toEntityWithGroups(state) + + assertThat(mode, `is`(ConstraintEntity.MODE_OR)) + assertThat(entities.map { it.groupUid }, `is`(listOf("group1", "group2"))) + assertThat( + groups, + `is`( + listOf( + ConstraintGroupEntity( + uid = "group1", + name = "My group", + mode = ConstraintEntity.MODE_OR, + ), + ConstraintGroupEntity( + uid = "group2", + name = null, + mode = ConstraintEntity.MODE_AND, + ), + ), + ), + ) + } + + @Test + fun `save and load multiple named groups keeps the constraints, names, modes and order`() { + val state = ConstraintState( + groups = listOf( + ConstraintGroup( + uid = "group1", + name = "Screen state", + constraints = listOf( + Constraint(uid = "uid1", data = ConstraintData.ScreenOn), + Constraint( + uid = "uid2", + data = ConstraintData.AppInForeground("com.example"), + isNot = true, + ), + ), + mode = ConstraintMode.AND, + ), + ConstraintGroup( + uid = "group2", + constraints = listOf( + Constraint(uid = "uid3", data = ConstraintData.WifiOn, isNot = true), + ), + mode = ConstraintMode.OR, + ), + ConstraintGroup( + uid = "group3", + name = "Charging stuff", + constraints = listOf( + Constraint(uid = "uid4", data = ConstraintData.Charging), + Constraint(uid = "uid5", data = ConstraintData.KeyboardShowing), + ), + mode = ConstraintMode.OR, + ), + ), + mode = ConstraintMode.OR, + ) + + val (entities, groups, mode) = ConstraintStateEntityMapper.toEntityWithGroups(state) + val loadedState = ConstraintStateEntityMapper.fromEntityWithGroups(entities, groups, mode) + + assertThat(loadedState, `is`(state)) + } + + @Test + fun `save and load one group keeps the constraints and mode`() { + val state = ConstraintState( + groups = listOf( + ConstraintGroup( + uid = "uid1", + constraints = listOf( + Constraint(uid = "uid1", data = ConstraintData.ScreenOn, isNot = true), + Constraint(uid = "uid2", data = ConstraintData.Charging), + ), + mode = ConstraintMode.OR, + ), + ), + mode = ConstraintMode.OR, + ) + + val (entities, groups, mode) = ConstraintStateEntityMapper.toEntityWithGroups(state) + val loadedState = ConstraintStateEntityMapper.fromEntityWithGroups(entities, groups, mode) + + assertThat(loadedState, `is`(state)) + } + + @Test + fun `constraints without a group uid are put in their own group when other constraints have groups`() { + val entities = listOf( + ConstraintEntity( + type = ConstraintEntity.SCREEN_ON, + extras = emptyList(), + uid = "uid1", + groupUid = "group1", + ), + ConstraintEntity("uid2", ConstraintEntity.CHARGING), + ConstraintEntity("uid3", ConstraintEntity.WIFI_ON), + ) + + val groupEntities = listOf( + ConstraintGroupEntity(uid = "group1", mode = ConstraintEntity.MODE_OR), + ) + + val state = ConstraintStateEntityMapper.fromEntityWithGroups( + entities, + groupEntities, + ConstraintEntity.MODE_AND, + ) + + val expected = ConstraintState( + groups = listOf( + ConstraintGroup( + uid = "group1", + constraints = listOf(Constraint(uid = "uid1", data = ConstraintData.ScreenOn)), + mode = ConstraintMode.OR, + ), + ConstraintGroup( + uid = "uid2", + constraints = listOf( + Constraint(uid = "uid2", data = ConstraintData.Charging), + Constraint(uid = "uid3", data = ConstraintData.WifiOn), + ), + mode = ConstraintMode.AND, + ), + ), + mode = ConstraintMode.AND, + ) + + assertThat(state, `is`(expected)) + } + + @Test + fun `group without a matching group entity uses the AND mode and no name`() { + val entities = listOf( + ConstraintEntity( + type = ConstraintEntity.SCREEN_ON, + extras = emptyList(), + uid = "uid1", + groupUid = "group1", + ), + ConstraintEntity( + type = ConstraintEntity.CHARGING, + extras = emptyList(), + uid = "uid2", + groupUid = "group2", + ), + ) + + val state = ConstraintStateEntityMapper.fromEntityWithGroups( + entities, + emptyList(), + ConstraintEntity.MODE_OR, + ) + + assertThat(state.groups.map { it.mode }, everyItem(`is`(ConstraintMode.AND))) + assertThat(state.groups.map { it.name }, everyItem(nullValue())) + assertThat(state.mode, `is`(ConstraintMode.OR)) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelperTest.kt new file mode 100644 index 0000000000..f472f12880 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/ConstraintUiHelperTest.kt @@ -0,0 +1,68 @@ +package io.github.sds100.keymapper.base.constraints + +import io.github.sds100.keymapper.base.utils.ui.ResourceProvider +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.Success +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.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@RunWith(MockitoJUnitRunner::class) +class ConstraintUiHelperTest { + + private lateinit var mockUseCase: DisplayConstraintUseCase + private lateinit var helper: ConstraintUiHelper + + @Before + fun init() { + mockUseCase = mock() + + val mockResourceProvider: ResourceProvider = mock() + whenever(mockResourceProvider.getString(any(), any())).thenAnswer { + it.getArgument(1).toString() + } + + helper = ConstraintUiHelper(mockUseCase, mockResourceProvider) + } + + @Test + fun `app in foreground title uses the live app name when the app is installed`() { + whenever(mockUseCase.getAppName("com.example")).thenReturn(Success("Live Name")) + + val constraint = Constraint( + data = ConstraintData.AppInForeground("com.example", appName = "Saved Name"), + ) + + assertThat(helper.getTitle(constraint), `is`("Live Name")) + } + + @Test + fun `app in foreground title falls back to the saved app name when the app is uninstalled`() { + whenever(mockUseCase.getAppName("com.example")) + .thenReturn(KMError.AppNotFound("com.example")) + + val constraint = Constraint( + data = ConstraintData.AppInForeground("com.example", appName = "Saved Name"), + ) + + assertThat(helper.getTitle(constraint), `is`("Saved Name")) + } + + @Test + fun `app in foreground title falls back to the raw package name when no name was ever saved`() { + whenever(mockUseCase.getAppName("com.example")) + .thenReturn(KMError.AppNotFound("com.example")) + + val constraint = Constraint( + data = ConstraintData.AppInForeground("com.example", appName = null), + ) + + assertThat(helper.getTitle(constraint), `is`("com.example")) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcherTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcherTest.kt new file mode 100644 index 0000000000..faaf92e861 --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/constraints/NotificationConstraintMatcherTest.kt @@ -0,0 +1,84 @@ +package io.github.sds100.keymapper.base.constraints + +import io.github.sds100.keymapper.system.notifications.PostedNotification +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.junit.Test + +class NotificationConstraintMatcherTest { + + private fun notification( + packageName: String = "com.termux", + title: String? = "keymap_mode=1", + text: String? = "Session running", + ) = PostedNotification( + key = "key", + packageName = packageName, + title = title, + text = text, + ) + + private fun matches( + data: ConstraintData.NotificationPosted, + notification: PostedNotification = notification(), + ) = NotificationConstraintMatcher.matches(notification, data) + + private fun title(text: String, matchMode: TextMatchMode = TextMatchMode.CONTAINS) = + ConstraintData.NotificationPosted.Title(text, matchMode) + + @Test + fun `contains matches a substring of the title and the text`() { + assertThat(matches(title("mode=1")), `is`(true)) + assertThat( + matches(ConstraintData.NotificationPosted.Text("running", TextMatchMode.CONTAINS)), + `is`(true), + ) + } + + @Test + fun `contains does not match text that is not there`() { + assertThat(matches(title("mode=2")), `is`(false)) + } + + @Test + fun `exact requires the whole value`() { + assertThat(matches(title("keymap_mode=1", TextMatchMode.EXACT)), `is`(true)) + assertThat(matches(title("keymap_mode", TextMatchMode.EXACT)), `is`(false)) + + // The case exact matching is there for: a prefix must not match a longer mode. + assertThat( + matches( + title("keymap_mode=1", TextMatchMode.EXACT), + notification = notification(title = "keymap_mode=10"), + ), + `is`(false), + ) + } + + @Test + fun `matching always ignores case`() { + assertThat(matches(title("KEYMAP")), `is`(true)) + assertThat(matches(title("KEYMAP_MODE=1", TextMatchMode.EXACT)), `is`(true)) + } + + @Test + fun `the app is compared exactly`() { + assertThat(matches(ConstraintData.NotificationPosted.FromApp("com.termux")), `is`(true)) + assertThat(matches(ConstraintData.NotificationPosted.FromApp("com.term")), `is`(false)) + } + + @Test + fun `a null field never matches`() { + assertThat( + matches(title("anything"), notification = notification(title = null)), + `is`(false), + ) + assertThat( + matches( + ConstraintData.NotificationPosted.Text("anything", TextMatchMode.CONTAINS), + notification = notification(text = null), + ), + `is`(false), + ) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt index a5f20562cf..a6b8856c7b 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/DetectKeyMapsUseCaseTest.kt @@ -1,7 +1,10 @@ package io.github.sds100.keymapper.base.keymaps +import io.github.sds100.keymapper.base.actions.Action +import io.github.sds100.keymapper.base.actions.ActionData import io.github.sds100.keymapper.base.constraints.Constraint import io.github.sds100.keymapper.base.constraints.ConstraintData +import io.github.sds100.keymapper.base.constraints.ConstraintGroup import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.constraints.ConstraintState import io.github.sds100.keymapper.base.detection.DetectKeyMapModel @@ -23,8 +26,8 @@ class DetectKeyMapsUseCaseTest { "child", parentUid = "parent", mode = ConstraintMode.OR, - Constraint(data = ConstraintData.LockScreenNotShowing), - Constraint(data = ConstraintData.Discharging), + Constraint(data = ConstraintData.LockScreenShowing, isNot = true), + Constraint(data = ConstraintData.Charging, isNot = true), ), group( "parent", @@ -44,8 +47,8 @@ class DetectKeyMapsUseCaseTest { val keyMap = KeyMap(groupUid = "child") val constraints1 = arrayOf( - Constraint(data = ConstraintData.LockScreenNotShowing), - Constraint(data = ConstraintData.Discharging), + Constraint(data = ConstraintData.LockScreenShowing, isNot = true), + Constraint(data = ConstraintData.Charging, isNot = true), ) val constraints2 = arrayOf( @@ -74,14 +77,8 @@ class DetectKeyMapsUseCaseTest { val expected = DetectKeyMapModel( keyMap, groupConstraintStates = listOf( - ConstraintState( - constraints = constraints1.toSet(), - mode = ConstraintMode.OR, - ), - ConstraintState( - constraints = constraints2.toSet(), - mode = ConstraintMode.AND, - ), + constraintState(ConstraintMode.OR, *constraints1), + constraintState(ConstraintMode.AND, *constraints2), ), ) assertThat(models, Matchers.contains(expected)) @@ -91,8 +88,8 @@ class DetectKeyMapsUseCaseTest { fun `Key map in grandchild group and child only has constraints`() { val keyMap = KeyMap(groupUid = "child") val constraints1 = arrayOf( - Constraint(data = ConstraintData.LockScreenNotShowing), - Constraint(data = ConstraintData.Discharging), + Constraint(data = ConstraintData.LockScreenShowing, isNot = true), + Constraint(data = ConstraintData.Charging, isNot = true), ) val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( keyMaps = listOf(keyMap), @@ -113,10 +110,7 @@ class DetectKeyMapsUseCaseTest { val expected = DetectKeyMapModel( keyMap, groupConstraintStates = listOf( - ConstraintState( - constraints = constraints1.toSet(), - mode = ConstraintMode.OR, - ), + constraintState(ConstraintMode.OR, *constraints1), ), ) assertThat(models, Matchers.contains(expected)) @@ -126,8 +120,8 @@ class DetectKeyMapsUseCaseTest { fun `Key map in grandchild group and parent only has constraints`() { val keyMap = KeyMap(groupUid = "child") val constraints1 = arrayOf( - Constraint(data = ConstraintData.LockScreenNotShowing), - Constraint(data = ConstraintData.Discharging), + Constraint(data = ConstraintData.LockScreenShowing, isNot = true), + Constraint(data = ConstraintData.Charging, isNot = true), ) val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( @@ -146,10 +140,7 @@ class DetectKeyMapsUseCaseTest { val expected = DetectKeyMapModel( keyMap, groupConstraintStates = listOf( - ConstraintState( - constraints = constraints1.toSet(), - mode = ConstraintMode.OR, - ), + constraintState(ConstraintMode.OR, *constraints1), ), ) assertThat(models, Matchers.contains(expected)) @@ -211,6 +202,33 @@ class DetectKeyMapsUseCaseTest { assertThat(models, Matchers.empty()) } + @Test + fun `Key map has disabled actions then remove the disabled actions`() { + val enabledAction = Action(data = ActionData.GoHome) + val disabledAction = Action(data = ActionData.ConsumeKeyEvent, isEnabled = false) + val keyMap = KeyMap(actionList = listOf(enabledAction, disabledAction)) + + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = emptyList(), + ) + + assertThat(models.single().keyMap.actionList, Matchers.contains(enabledAction)) + } + + @Test + fun `Key map has only disabled actions then action list is empty`() { + val disabledAction = Action(data = ActionData.ConsumeKeyEvent, isEnabled = false) + val keyMap = KeyMap(actionList = listOf(disabledAction)) + + val models = DetectKeyMapsUseCaseImpl.processKeyMapsAndGroups( + keyMaps = listOf(keyMap), + groups = emptyList(), + ) + + assertThat(models.single().keyMap.actionList, Matchers.empty()) + } + @Test fun `One key map in child group then include`() { val keyMap = KeyMap(groupUid = "child") @@ -286,12 +304,24 @@ class DetectKeyMapsUseCaseTest { return Group( uid = uid, name = uid, - constraintState = ConstraintState( - constraints = constraint.toSet(), - mode = mode, - ), + constraintState = constraintState(mode, *constraint), parentUid = parentUid, lastOpenedDate = 0, ) } + + private fun constraintState( + mode: ConstraintMode, + vararg constraints: Constraint, + ): ConstraintState { + val groups = if (constraints.isEmpty()) { + emptyList() + } else { + listOf( + ConstraintGroup(uid = "group", constraints = constraints.toList(), mode = mode), + ) + } + + return ConstraintState(groups = groups, mode = mode) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt index c0e5ef5ce6..ed71b52ad4 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/KeyMapAlgorithmTest.kt @@ -11,6 +11,7 @@ import io.github.sds100.keymapper.base.actions.PerformActionsUseCase import io.github.sds100.keymapper.base.actions.RepeatMode import io.github.sds100.keymapper.base.constraints.Constraint import io.github.sds100.keymapper.base.constraints.ConstraintData +import io.github.sds100.keymapper.base.constraints.ConstraintGroup import io.github.sds100.keymapper.base.constraints.ConstraintMode import io.github.sds100.keymapper.base.constraints.ConstraintSnapshot import io.github.sds100.keymapper.base.constraints.ConstraintState @@ -31,6 +32,7 @@ import io.github.sds100.keymapper.base.utils.parallelTrigger import io.github.sds100.keymapper.base.utils.sequenceTrigger import io.github.sds100.keymapper.base.utils.singleKeyTrigger import io.github.sds100.keymapper.base.utils.triggerKey +import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.models.EvdevDeviceInfo import io.github.sds100.keymapper.common.utils.InputDeviceInfo import io.github.sds100.keymapper.common.utils.InputEventAction @@ -768,27 +770,44 @@ class KeyMapAlgorithmTest { trigger = trigger, actionList = listOf(TEST_ACTION), constraintState = ConstraintState( - constraints = setOf( - Constraint(data = ConstraintData.WifiOn), - Constraint(data = ConstraintData.DeviceIsLocked), + groups = listOf( + ConstraintGroup( + constraints = listOf( + Constraint(data = ConstraintData.WifiOn), + Constraint(data = ConstraintData.DeviceIsLocked), + ), + mode = ConstraintMode.OR, + ), ), - mode = ConstraintMode.OR, ), ), groupConstraintStates = listOf( ConstraintState( - constraints = setOf( - Constraint(data = ConstraintData.LockScreenNotShowing), - Constraint(data = ConstraintData.DeviceIsLocked), + groups = listOf( + ConstraintGroup( + constraints = listOf( + Constraint( + data = ConstraintData.LockScreenShowing, + isNot = true, + ), + Constraint(data = ConstraintData.DeviceIsLocked), + ), + mode = ConstraintMode.AND, + ), ), - mode = ConstraintMode.AND, ), ConstraintState( - constraints = setOf( - Constraint(data = ConstraintData.AppInForeground(packageName = "app")), - Constraint(data = ConstraintData.DeviceIsUnlocked), + groups = listOf( + ConstraintGroup( + constraints = listOf( + Constraint( + data = ConstraintData.AppInForeground(packageName = "app"), + ), + Constraint(data = ConstraintData.DeviceIsLocked, isNot = true), + ), + mode = ConstraintMode.OR, + ), ), - mode = ConstraintMode.OR, ), ), ), @@ -819,29 +838,49 @@ class KeyMapAlgorithmTest { trigger = trigger, actionList = listOf(TEST_ACTION), constraintState = ConstraintState( - constraints = setOf( - Constraint(data = ConstraintData.WifiOn), - Constraint(data = ConstraintData.DeviceIsLocked), + groups = listOf( + ConstraintGroup( + constraints = listOf( + Constraint(data = ConstraintData.WifiOn), + Constraint(data = ConstraintData.DeviceIsLocked), + ), + mode = ConstraintMode.OR, + ), ), - mode = ConstraintMode.OR, ), ), groupConstraintStates = listOf( ConstraintState( - constraints = setOf( - Constraint(data = ConstraintData.LockScreenNotShowing), - Constraint(data = ConstraintData.DeviceIsLocked), + groups = listOf( + ConstraintGroup( + constraints = listOf( + Constraint( + data = ConstraintData.LockScreenShowing, + isNot = true, + ), + Constraint(data = ConstraintData.DeviceIsLocked), + ), + mode = ConstraintMode.AND, + ), ), - mode = ConstraintMode.AND, ), ConstraintState( - constraints = setOf( - Constraint( - data = ConstraintData.AppInForeground(packageName = "app"), + groups = listOf( + ConstraintGroup( + constraints = listOf( + Constraint( + data = ConstraintData.AppInForeground( + packageName = "app", + ), + ), + Constraint( + data = ConstraintData.DeviceIsLocked, + isNot = true, + ), + ), + mode = ConstraintMode.OR, ), - Constraint(data = ConstraintData.DeviceIsUnlocked), ), - mode = ConstraintMode.OR, ), ), ), @@ -913,7 +952,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(1), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } /** @@ -953,7 +995,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(1), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } /** @@ -992,7 +1037,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(1), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } /** @@ -1033,7 +1081,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(1), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } /** @@ -1074,7 +1125,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(1), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } /** @@ -1115,7 +1169,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(2)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(2), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } /** @@ -1154,7 +1211,10 @@ class KeyMapAlgorithmTest { advanceUntilIdle() // THEN - verify(detectKeyMapsUseCase, times(1)).vibrate(VIBRATION_DURATION) + verify( + detectKeyMapsUseCase, + times(1), + ).vibrate(VibrateEffect.CustomDuration(VIBRATION_DURATION)) } @Test @@ -1713,14 +1773,24 @@ class KeyMapAlgorithmTest { val shortPressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), ) - val shortPressConstraints = - ConstraintState(constraints = setOf(Constraint(data = ConstraintData.WifiOn))) + val shortPressConstraints = ConstraintState( + groups = listOf( + ConstraintGroup(constraints = listOf(Constraint(data = ConstraintData.WifiOn))), + ), + ) val longPressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.LONG_PRESS), ) - val doublePressConstraints = - ConstraintState(constraints = setOf(Constraint(data = ConstraintData.WifiOff))) + val doublePressConstraints = ConstraintState( + groups = listOf( + ConstraintGroup( + constraints = listOf( + Constraint(data = ConstraintData.WifiOn, isNot = true), + ), + ), + ), + ) loadKeyMaps( KeyMap( @@ -1762,14 +1832,24 @@ class KeyMapAlgorithmTest { val shortPressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN), ) - val shortPressConstraints = - ConstraintState(constraints = setOf(Constraint(data = ConstraintData.WifiOn))) + val shortPressConstraints = ConstraintState( + groups = listOf( + ConstraintGroup(constraints = listOf(Constraint(data = ConstraintData.WifiOn))), + ), + ) val doublePressTrigger = singleKeyTrigger( triggerKey(KeyEvent.KEYCODE_VOLUME_DOWN, clickType = ClickType.DOUBLE_PRESS), ) - val doublePressConstraints = - ConstraintState(constraints = setOf(Constraint(data = ConstraintData.WifiOff))) + val doublePressConstraints = ConstraintState( + groups = listOf( + ConstraintGroup( + constraints = listOf( + Constraint(data = ConstraintData.WifiOn, isNot = true), + ), + ), + ), + ) loadKeyMaps( KeyMap( @@ -1913,8 +1993,14 @@ class KeyMapAlgorithmTest { ), actionList = listOf(Action(data = actionData)), constraintState = ConstraintState( - constraints = setOf( - Constraint(data = ConstraintData.FlashlightOn(lens = CameraLens.BACK)), + groups = listOf( + ConstraintGroup( + constraints = listOf( + Constraint( + data = ConstraintData.FlashlightOn(lens = CameraLens.BACK), + ), + ), + ), ), ), ) diff --git a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt index 4bc6370c7e..8c94aad2a7 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/keymaps/TriggerKeyMapFromOtherAppsControllerTest.kt @@ -6,9 +6,9 @@ import io.github.sds100.keymapper.base.actions.ActionErrorSnapshot import io.github.sds100.keymapper.base.actions.PerformActionsUseCase import io.github.sds100.keymapper.base.actions.RepeatMode import io.github.sds100.keymapper.base.constraints.DetectConstraintsUseCase +import io.github.sds100.keymapper.base.detection.DetectKeyMapModel import io.github.sds100.keymapper.base.detection.DetectKeyMapsUseCase import io.github.sds100.keymapper.base.detection.TriggerKeyMapFromOtherAppsController -import io.github.sds100.keymapper.base.trigger.Trigger import io.github.sds100.keymapper.base.utils.TestConstraintSnapshot import io.github.sds100.keymapper.common.utils.KMError import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -46,14 +46,14 @@ class TriggerKeyMapFromOtherAppsControllerTest { private lateinit var detectKeyMapsUseCase: DetectKeyMapsUseCase private lateinit var performActionsUseCase: PerformActionsUseCase private lateinit var detectConstraintsUseCase: DetectConstraintsUseCase - private lateinit var keyMapListFlow: MutableStateFlow> + private lateinit var keyMapListFlow: MutableStateFlow> @Before fun init() { keyMapListFlow = MutableStateFlow(emptyList()) detectKeyMapsUseCase = mock { - on { keyMapsToTriggerFromOtherApps } doReturn keyMapListFlow + on { allKeyMapList } doReturn keyMapListFlow MutableStateFlow(VIBRATION_DURATION).apply { on { defaultVibrateDuration } doReturn this @@ -108,9 +108,8 @@ class TriggerKeyMapFromOtherAppsControllerTest { ) val keyMap = KeyMap( actionList = listOf(action), - trigger = Trigger(triggerFromOtherApps = true), ) - keyMapListFlow.value = listOf(keyMap) + keyMapListFlow.value = listOf(DetectKeyMapModel(keyMap)) advanceUntilIdle() diff --git a/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt b/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt index 4fc6301f4f..cebb370334 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/system/inputmethod/FakeInputMethodAdapter.kt @@ -40,4 +40,18 @@ class FakeInputMethodAdapter : InputMethodAdapter { override fun cycleInputMethodSubtype(): KMResult { return Success(Unit) } + + override fun getNextInputMethod(): KMResult { + val chosenImeId = chosenIme.value?.id + val enabledImes = inputMethods.value.filter { it.isEnabled } + + if (enabledImes.isEmpty()) { + return getInfoById(chosenImeId.orEmpty()) + } + + val currentIndex = enabledImes.indexOfFirst { it.id == chosenImeId } + val nextIndex = if (currentIndex == -1) 0 else (currentIndex + 1) % enabledImes.size + + return Success(enabledImes[nextIndex]) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/system/notifications/PostedNotificationStoreTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/system/notifications/PostedNotificationStoreTest.kt new file mode 100644 index 0000000000..f25a8c8adc --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/system/notifications/PostedNotificationStoreTest.kt @@ -0,0 +1,77 @@ +package io.github.sds100.keymapper.base.system.notifications + +import io.github.sds100.keymapper.system.notifications.PostedNotification +import io.github.sds100.keymapper.system.notifications.PostedNotificationStore +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.`is` +import org.junit.Test + +class PostedNotificationStoreTest { + + private val store = PostedNotificationStore() + + private fun notification(key: String = "key", title: String? = "keymap_mode=1") = + PostedNotification( + key = key, + packageName = "com.termux", + title = title, + text = "Session running", + ) + + @Test + fun `posting adds to the active list`() { + store.onPosted(notification()) + + assertThat(store.active.value.size, `is`(1)) + } + + @Test + fun `the active list is in the order the notifications were posted`() { + // Dismissing the most recent notification relies on this order. + store.onPosted(notification(key = "first", title = "first")) + store.onPosted(notification(key = "second", title = "second")) + store.onPosted(notification(key = "third", title = "third")) + + assertThat(store.active.value.map { it.title }, contains("first", "second", "third")) + assertThat(store.active.value.last().key, `is`("third")) + } + + @Test + fun `updating a notification in place moves it to the end`() { + store.onPosted(notification(key = "first", title = "first")) + store.onPosted(notification(key = "second", title = "second")) + store.onPosted(notification(key = "first", title = "first updated")) + + assertThat(store.active.value.map { it.title }, contains("second", "first updated")) + } + + @Test + fun `removing a notification takes it out of the active list`() { + store.onPosted(notification(key = "first")) + store.onPosted(notification(key = "second")) + + store.onRemoved("second") + + assertThat(store.active.value.single().key, `is`("first")) + } + + @Test + fun `seeding on reconnect replaces whatever was there`() { + store.onPosted(notification(key = "stale")) + + store.seed(listOf(notification(key = "fresh"))) + + assertThat(store.active.value.single().key, `is`("fresh")) + } + + @Test + fun `clearing removes everything so no content is left in memory`() { + store.onPosted(notification()) + + store.clear() + + assertThat(store.active.value, `is`(empty())) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegateTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegateTest.kt index 312edb60aa..92857f4702 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegateTest.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/trigger/ConfigTriggerDelegateTest.kt @@ -7,6 +7,7 @@ import io.github.sds100.keymapper.base.utils.parallelTrigger import io.github.sds100.keymapper.base.utils.sequenceTrigger import io.github.sds100.keymapper.base.utils.singleKeyTrigger import io.github.sds100.keymapper.base.utils.triggerKey +import io.github.sds100.keymapper.base.vibration.VibrateEffect import io.github.sds100.keymapper.common.models.EvdevDeviceInfo import io.github.sds100.keymapper.system.inputevents.Scancode import org.hamcrest.MatcherAssert.assertThat @@ -14,6 +15,7 @@ import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.hasSize import org.hamcrest.Matchers.instanceOf import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue import org.junit.After import org.junit.Before import org.junit.Test @@ -1387,4 +1389,46 @@ class ConfigTriggerDelegateTest { assertThat(newTrigger.keys, hasSize(1)) assertThat(newTrigger.keys, contains(trigger.keys[0])) } + + @Test + fun `set vibrate effect to a custom duration`() { + val trigger = Trigger(vibrate = true) + + val newTrigger = delegate.setVibrateEffect( + trigger, + VibrateEffect.CustomDuration(250L), + defaultVibrateDuration = 100, + ) + + assertThat(newTrigger.vibrateEffect, `is`(VibrateEffect.CustomDuration(250L))) + } + + @Test + fun `set vibrate effect to a predefined effect`() { + val trigger = Trigger(vibrate = true) + + val newTrigger = delegate.setVibrateEffect( + trigger, + VibrateEffect.Predefined(VibrateEffect.PredefinedType.HEAVY_CLICK), + defaultVibrateDuration = 100, + ) + + assertThat( + newTrigger.vibrateEffect, + `is`(VibrateEffect.Predefined(VibrateEffect.PredefinedType.HEAVY_CLICK)), + ) + } + + @Test + fun `set vibrate effect to null when custom duration matches the app-wide default`() { + val trigger = Trigger(vibrate = true, vibrateEffect = VibrateEffect.CustomDuration(250L)) + + val newTrigger = delegate.setVibrateEffect( + trigger, + VibrateEffect.CustomDuration(100L), + defaultVibrateDuration = 100, + ) + + assertThat(newTrigger.vibrateEffect, `is`(nullValue())) + } } diff --git a/base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerEntityMapperTest.kt b/base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerEntityMapperTest.kt new file mode 100644 index 0000000000..b73d8b8d3a --- /dev/null +++ b/base/src/test/java/io/github/sds100/keymapper/base/trigger/TriggerEntityMapperTest.kt @@ -0,0 +1,66 @@ +package io.github.sds100.keymapper.base.trigger + +import io.github.sds100.keymapper.base.vibration.VibrateEffect +import io.github.sds100.keymapper.data.entities.EntityExtra +import io.github.sds100.keymapper.data.entities.TriggerEntity +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.nullValue +import org.junit.Test + +class TriggerEntityMapperTest { + + @Test + fun `save and load a custom vibration duration`() { + val trigger = Trigger( + vibrate = true, + vibrateEffect = VibrateEffect.CustomDuration(250L), + ) + + val entity = TriggerEntityMapper.toEntity(trigger) + val loadedTrigger = TriggerEntityMapper.fromEntity(entity, floatingButtons = emptyList()) + + assertThat(loadedTrigger.vibrateEffect, `is`(VibrateEffect.CustomDuration(250L))) + } + + @Test + fun `save and load a predefined vibration effect`() { + val trigger = Trigger( + vibrate = true, + vibrateEffect = VibrateEffect.Predefined(VibrateEffect.PredefinedType.TICK), + ) + + val entity = TriggerEntityMapper.toEntity(trigger) + val loadedTrigger = TriggerEntityMapper.fromEntity(entity, floatingButtons = emptyList()) + + assertThat( + loadedTrigger.vibrateEffect, + `is`(VibrateEffect.Predefined(VibrateEffect.PredefinedType.TICK)), + ) + } + + @Test + fun `load a custom duration from a trigger saved before predefined effects existed`() { + // GIVEN an entity saved by an older version of the app that only stored a raw duration. + val entity = TriggerEntity( + flags = TriggerEntity.TRIGGER_FLAG_VIBRATE, + extras = listOf( + @Suppress("DEPRECATION") + EntityExtra(TriggerEntity.EXTRA_VIBRATION_DURATION, "250"), + ), + ) + + val trigger = TriggerEntityMapper.fromEntity(entity, floatingButtons = emptyList()) + + assertThat(trigger.vibrateEffect, `is`(VibrateEffect.CustomDuration(250L))) + } + + @Test + fun `load null vibrate effect when nothing was ever set`() { + val entity = TriggerEntity(flags = TriggerEntity.TRIGGER_FLAG_VIBRATE) + + val trigger = TriggerEntityMapper.fromEntity(entity, floatingButtons = emptyList()) + + assertThat(trigger.vibrateEffect, `is`(nullValue())) + } +} diff --git a/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt index 1af4cf04f2..7a01439985 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/utils/TestConstraintSnapshot.kt @@ -3,6 +3,7 @@ package io.github.sds100.keymapper.base.utils import io.github.sds100.keymapper.base.constraints.Constraint import io.github.sds100.keymapper.base.constraints.ConstraintData import io.github.sds100.keymapper.base.constraints.ConstraintSnapshot +import io.github.sds100.keymapper.base.constraints.NotificationConstraintMatcher import io.github.sds100.keymapper.common.utils.Orientation import io.github.sds100.keymapper.common.utils.PhysicalOrientation import io.github.sds100.keymapper.common.utils.SizeKM @@ -11,6 +12,7 @@ import io.github.sds100.keymapper.system.camera.CameraLens import io.github.sds100.keymapper.system.foldable.HingeState import io.github.sds100.keymapper.system.foldable.isClosed import io.github.sds100.keymapper.system.foldable.isOpen +import io.github.sds100.keymapper.system.notifications.PostedNotification import io.github.sds100.keymapper.system.phone.CallState import io.github.sds100.keymapper.system.volume.RingerMode import java.time.LocalTime @@ -38,29 +40,34 @@ class TestConstraintSnapshot( val hingeState: HingeState = HingeState.Unavailable, val isNotificationPanelShowing: Boolean = false, val displaySize: SizeKM = SizeKM(1080, 1920), + val postedNotifications: List = emptyList(), ) : ConstraintSnapshot { override fun isSatisfied(constraint: Constraint): Boolean { val isSatisfied = when (val data = constraint.data) { is ConstraintData.AppInForeground -> appInForeground == data.packageName - is ConstraintData.AppNotInForeground -> appInForeground != data.packageName + is ConstraintData.AppPlayingMedia -> appsPlayingMedia.contains(data.packageName) - is ConstraintData.AppNotPlayingMedia -> - appsPlayingMedia.none { it == data.packageName } - is ConstraintData.MediaPlaying -> appsPlayingMedia.isNotEmpty() - is ConstraintData.NoMediaPlaying -> appsPlayingMedia.isEmpty() - is ConstraintData.BtDeviceConnected -> { - connectedBluetoothDevices.any { it.address == data.bluetoothAddress } + + is ConstraintData.NotificationPosted -> if (constraint.isNot) { + postedNotifications.all { + NotificationConstraintMatcher.matches(it, constraint.data) + } + } else { + postedNotifications.any { + NotificationConstraintMatcher.matches(it, constraint.data) + } } - is ConstraintData.BtDeviceDisconnected -> { - connectedBluetoothDevices.none { it.address == data.bluetoothAddress } + is ConstraintData.BtDeviceConnected -> { + connectedBluetoothDevices.any { it.address == data.bluetoothAddress } } is ConstraintData.OrientationCustom -> orientation == data.orientation + is ConstraintData.OrientationLandscape -> orientation == Orientation.ORIENTATION_90 || orientation == Orientation.ORIENTATION_270 @@ -72,15 +79,11 @@ class TestConstraintSnapshot( is ConstraintData.PhysicalOrientation -> physicalOrientation == data.physicalOrientation - is ConstraintData.ScreenOff -> !isScreenOn is ConstraintData.ScreenOn -> isScreenOn + is ConstraintData.DisplayResolution -> (displaySize.width == data.width && displaySize.height == data.height) || (displaySize.width == data.height && displaySize.height == data.width) - is ConstraintData.FlashlightOff -> when (data.lens) { - CameraLens.BACK -> !isBackFlashlightOn - CameraLens.FRONT -> !isFrontFlashlightOn - } is ConstraintData.FlashlightOn -> when (data.lens) { CameraLens.BACK -> isBackFlashlightOn @@ -96,30 +99,26 @@ class TestConstraintSnapshot( } } - is ConstraintData.WifiDisconnected -> - if (data.ssid == null) { - // connected to no network - connectedWifiSSID == null - } else { - connectedWifiSSID != data.ssid - } - - is ConstraintData.WifiOff -> !isWifiEnabled is ConstraintData.WifiOn -> isWifiEnabled + is ConstraintData.ImeChosen -> chosenImeId == data.imeId - is ConstraintData.ImeNotChosen -> chosenImeId != data.imeId + is ConstraintData.KeyboardShowing -> isKeyboardShowing - is ConstraintData.KeyboardNotShowing -> !isKeyboardShowing + is ConstraintData.DeviceIsLocked -> isLocked - is ConstraintData.DeviceIsUnlocked -> !isLocked + is ConstraintData.InPhoneCall -> callState == CallState.IN_PHONE_CALL + is ConstraintData.NotInPhoneCall -> callState == CallState.NONE + is ConstraintData.PhoneRinging -> callState == CallState.RINGING + is ConstraintData.RingerMode -> ringerMode == data.ringerMode + is ConstraintData.Charging -> isCharging - is ConstraintData.Discharging -> !isCharging + is ConstraintData.LockScreenShowing -> isLockscreenShowing - is ConstraintData.LockScreenNotShowing -> !isLockscreenShowing + is ConstraintData.Time -> { val startTime = data.startTime val endTime = data.endTime @@ -133,11 +132,12 @@ class TestConstraintSnapshot( ConstraintData.HingeClosed -> hingeState is HingeState.Available && hingeState.isClosed() + ConstraintData.HingeOpen -> hingeState is HingeState.Available && hingeState.isOpen() + ConstraintData.NotificationPanelShowing -> isNotificationPanelShowing - ConstraintData.NotificationPanelNotShowing -> !isNotificationPanelShowing - } + } != constraint.isNot if (isSatisfied) { Timber.d("Constraint satisfied: $constraint") diff --git a/base/src/test/java/io/github/sds100/keymapper/base/utils/ui/FakeResourceProvider.kt b/base/src/test/java/io/github/sds100/keymapper/base/utils/ui/FakeResourceProvider.kt index fbf7c7b132..38fafe5c32 100644 --- a/base/src/test/java/io/github/sds100/keymapper/base/utils/ui/FakeResourceProvider.kt +++ b/base/src/test/java/io/github/sds100/keymapper/base/utils/ui/FakeResourceProvider.kt @@ -17,6 +17,10 @@ class FakeResourceProvider : ResourceProvider { return stringResourceMap[resId] ?: "" } + override fun getPluralString(resId: Int, quantity: Int, vararg args: Any): String { + throw Exception() + } + override fun getText(resId: Int): CharSequence { return stringResourceMap[resId] ?: "" } diff --git a/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt b/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt index c5d3be8401..f0759b76b6 100644 --- a/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt +++ b/common/src/main/java/io/github/sds100/keymapper/common/utils/KMResult.kt @@ -31,8 +31,8 @@ abstract class KMError : KMResult() { data object BackupVersionTooNew : KMError() data object LauncherShortcutsNotSupported : KMError() - data class AppNotFound(val packageName: String) : KMError() - data class AppDisabled(val packageName: String) : KMError() + data class AppNotFound(val packageName: String, val appName: String? = null) : KMError() + data class AppDisabled(val packageName: String, val appName: String? = null) : KMError() data object AppShortcutCantBeOpened : KMError() data object InsufficientPermissionsToOpenAppShortcut : KMError() data object NoCompatibleImeEnabled : KMError() diff --git a/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/23.json b/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/23.json new file mode 100644 index 0000000000..b20d0c74de --- /dev/null +++ b/data/schemas/io.github.sds100.keymapper.data.db.AppDatabase/23.json @@ -0,0 +1,482 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "37044a47f92b3ba990fd4a6e02386a09", + "entities": [ + { + "tableName": "keymaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `trigger` TEXT NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `constraint_groups` TEXT NOT NULL DEFAULT '[]', `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, `uid` TEXT NOT NULL, `group_uid` TEXT, FOREIGN KEY(`group_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constraintGroups", + "columnName": "constraint_groups", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupUid", + "columnName": "group_uid", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keymaps_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_keymaps_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "group_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "fingerprintmaps", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `action_list` TEXT NOT NULL, `constraint_list` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `extras` TEXT NOT NULL, `flags` INTEGER NOT NULL, `is_enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "actionList", + "columnName": "action_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraint_list", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extras", + "columnName": "extras", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "is_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `severity` INTEGER NOT NULL, `message` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "severity", + "columnName": "severity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "floating_layouts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`uid`))", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_layouts_name", + "unique": true, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_floating_layouts_name` ON `${TABLE_NAME}` (`name`)" + } + ] + }, + { + "tableName": "floating_buttons", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `layout_uid` TEXT NOT NULL, `text` TEXT NOT NULL, `button_size` INTEGER NOT NULL, `x` INTEGER NOT NULL, `y` INTEGER NOT NULL, `orientation` TEXT NOT NULL, `display_width` INTEGER NOT NULL, `display_height` INTEGER NOT NULL, `border_opacity` REAL, `background_opacity` REAL, `show_over_status_bar` INTEGER, `show_over_input_method` INTEGER, `position_locked` INTEGER, PRIMARY KEY(`uid`), FOREIGN KEY(`layout_uid`) REFERENCES `floating_layouts`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "layoutUid", + "columnName": "layout_uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "buttonSize", + "columnName": "button_size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "x", + "columnName": "x", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "y", + "columnName": "y", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orientation", + "columnName": "orientation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayWidth", + "columnName": "display_width", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayHeight", + "columnName": "display_height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "borderOpacity", + "columnName": "border_opacity", + "affinity": "REAL" + }, + { + "fieldPath": "backgroundOpacity", + "columnName": "background_opacity", + "affinity": "REAL" + }, + { + "fieldPath": "showOverStatusBar", + "columnName": "show_over_status_bar", + "affinity": "INTEGER" + }, + { + "fieldPath": "showOverInputMethod", + "columnName": "show_over_input_method", + "affinity": "INTEGER" + }, + { + "fieldPath": "isPositionLocked", + "columnName": "position_locked", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_floating_buttons_layout_uid", + "unique": false, + "columnNames": [ + "layout_uid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_floating_buttons_layout_uid` ON `${TABLE_NAME}` (`layout_uid`)" + } + ], + "foreignKeys": [ + { + "table": "floating_layouts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "layout_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` TEXT NOT NULL, `name` TEXT NOT NULL, `constraints` TEXT NOT NULL, `constraint_mode` INTEGER NOT NULL, `parent_uid` TEXT, `last_opened_date` INTEGER, PRIMARY KEY(`uid`), FOREIGN KEY(`parent_uid`) REFERENCES `groups`(`uid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintList", + "columnName": "constraints", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "constraintMode", + "columnName": "constraint_mode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentUid", + "columnName": "parent_uid", + "affinity": "TEXT" + }, + { + "fieldPath": "lastOpenedDate", + "columnName": "last_opened_date", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uid" + ] + }, + "foreignKeys": [ + { + "table": "groups", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "parent_uid" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "accessibility_nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `package_name` TEXT NOT NULL, `text` TEXT, `content_description` TEXT, `class_name` TEXT, `view_resource_id` TEXT, `unique_id` TEXT, `actions` INTEGER NOT NULL, `interacted` INTEGER NOT NULL DEFAULT false, `tooltip` TEXT DEFAULT NULL, `hint` TEXT DEFAULT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT" + }, + { + "fieldPath": "contentDescription", + "columnName": "content_description", + "affinity": "TEXT" + }, + { + "fieldPath": "className", + "columnName": "class_name", + "affinity": "TEXT" + }, + { + "fieldPath": "viewResourceId", + "columnName": "view_resource_id", + "affinity": "TEXT" + }, + { + "fieldPath": "uniqueId", + "columnName": "unique_id", + "affinity": "TEXT" + }, + { + "fieldPath": "actions", + "columnName": "actions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "interacted", + "columnName": "interacted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "tooltip", + "columnName": "tooltip", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "hint", + "columnName": "hint", + "affinity": "TEXT", + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '37044a47f92b3ba990fd4a6e02386a09')" + ] + } +} \ No newline at end of file diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt index 3cc6174bc2..522cc55bd9 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/AppDatabase.kt @@ -35,6 +35,7 @@ import io.github.sds100.keymapper.data.migration.AutoMigration18To19 import io.github.sds100.keymapper.data.migration.AutoMigration19To20 import io.github.sds100.keymapper.data.migration.AutoMigration20To21 import io.github.sds100.keymapper.data.migration.AutoMigration21To22 +import io.github.sds100.keymapper.data.migration.AutoMigration22To23 import io.github.sds100.keymapper.data.migration.Migration10To11 import io.github.sds100.keymapper.data.migration.Migration11To12 import io.github.sds100.keymapper.data.migration.Migration13To14 @@ -70,6 +71,8 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 AutoMigration(from = 20, to = 21, spec = AutoMigration20To21::class), // Adds floating button option to lock in position AutoMigration(from = 21, to = 22, spec = AutoMigration21To22::class), + // Adds the constraint_groups column to key maps + AutoMigration(from = 22, to = 23, spec = AutoMigration22To23::class), ], ) @TypeConverters( @@ -82,7 +85,7 @@ import io.github.sds100.keymapper.data.migration.Migration9To10 abstract class AppDatabase : RoomDatabase() { companion object { const val DATABASE_NAME = "key_map_database" - const val DATABASE_VERSION = 22 + const val DATABASE_VERSION = 23 val MIGRATION_1_2 = object : Migration(1, 2) { diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt index ec692c2871..51eabc0605 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/dao/KeyMapDao.kt @@ -20,6 +20,7 @@ interface KeyMapDao { const val KEY_ACTION_LIST = "action_list" const val KEY_CONSTRAINT_LIST = "constraint_list" const val KEY_CONSTRAINT_MODE = "constraint_mode" + const val KEY_CONSTRAINT_GROUPS = "constraint_groups" const val KEY_UID = "uid" const val KEY_GROUP_UID = "group_uid" } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ConstraintListTypeConverter.kt b/data/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ConstraintListTypeConverter.kt index da676515d9..bac0754032 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ConstraintListTypeConverter.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/db/typeconverter/ConstraintListTypeConverter.kt @@ -5,13 +5,24 @@ import com.github.salomonbrys.kotson.fromJson import com.github.salomonbrys.kotson.registerTypeAdapter import com.google.gson.GsonBuilder import io.github.sds100.keymapper.data.entities.ConstraintEntity +import io.github.sds100.keymapper.data.entities.ConstraintGroupEntity class ConstraintListTypeConverter { - private val gson = GsonBuilder().registerTypeAdapter(ConstraintEntity.DESERIALIZER).create() + private val gson = GsonBuilder() + .registerTypeAdapter(ConstraintEntity.DESERIALIZER) + .registerTypeAdapter(ConstraintGroupEntity.DESERIALIZER) + .create() @TypeConverter fun toConstraintList(json: String) = gson.fromJson>(json) @TypeConverter fun toJsonString(constraintList: List) = gson.toJson(constraintList)!! + + @TypeConverter + fun toConstraintGroupList(json: String) = gson.fromJson>(json) + + @TypeConverter + fun constraintGroupListToJsonString(constraintGroups: List) = + gson.toJson(constraintGroups)!! } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt index 7d20d6dca6..adecee8ccc 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ActionEntity.kt @@ -69,6 +69,7 @@ data class ActionEntity( const val EXTRA_SHORTCUT_TITLE = "extra_title" const val EXTRA_PACKAGE_NAME = "extra_package_name" + const val EXTRA_APP_NAME = "extra_app_name" const val EXTRA_STREAM_TYPE = "extra_stream_type" const val EXTRA_LENS = "extra_flash" const val EXTRA_RINGER_MODE = "extra_ringer_mode" @@ -99,6 +100,11 @@ data class ActionEntity( const val EXTRA_NOTIFICATION_TIMEOUT = "extra_notification_timeout" const val EXTRA_TOAST_DURATION = "extra_toast_duration" const val EXTRA_STEP_MEDIA_DURATION = "extra_step_media_duration" + const val EXTRA_VIBRATE_MODE = "extra_vibrate_mode" + const val EXTRA_VIBRATE_DURATION_MS = "extra_vibrate_duration_ms" + const val EXTRA_VIBRATE_EFFECT_TYPE = "extra_vibrate_effect_type" + const val VIBRATE_MODE_DURATION = "duration" + const val VIBRATE_MODE_PREDEFINED = "predefined" // Accessibility node extras const val EXTRA_ACCESSIBILITY_PACKAGE_NAME = "extra_accessibility_package_name" @@ -142,6 +148,12 @@ data class ActionEntity( const val ACTION_FLAG_SHELL_COMMAND_USE_ROOT = 16 const val ACTION_FLAG_SHELL_COMMAND_USE_ADB = 32 + /** + * Stored as disabled rather than enabled so actions saved before this flag existed + * are enabled by default. + */ + const val ACTION_FLAG_DISABLED = 64 + const val EXTRA_CUSTOM_STOP_REPEAT_BEHAVIOUR = "extra_custom_stop_repeat_behaviour" const val EXTRA_CUSTOM_HOLD_DOWN_BEHAVIOUR = "extra_custom_hold_down_behaviour" const val EXTRA_REPEAT_DELAY = "extra_hold_down_until_repeat_delay" @@ -150,6 +162,7 @@ data class ActionEntity( const val EXTRA_DELAY_BEFORE_NEXT_ACTION = "extra_delay_before_next_action" const val EXTRA_HOLD_DOWN_DURATION = "extra_hold_down_duration" const val EXTRA_REPEAT_LIMIT = "extra_repeat_limit" + const val EXTRA_CUSTOM_NAME = "extra_custom_name" const val EXTRA_SETTING_VALUE = "extra_setting_value" const val EXTRA_SETTING_TYPE = "extra_setting_type" @@ -210,6 +223,7 @@ data class ActionEntity( MODIFY_SETTING, CREATE_NOTIFICATION, TOAST, + VIBRATE, } constructor( diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt index 9e8a884ebe..90c5d0628b 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintEntity.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.data.entities import android.os.Parcelable import com.github.salomonbrys.kotson.byArray +import com.github.salomonbrys.kotson.byBool import com.github.salomonbrys.kotson.byNullableString import com.github.salomonbrys.kotson.byString import com.github.salomonbrys.kotson.jsonDeserializer @@ -19,6 +20,12 @@ data class ConstraintEntity( @SerializedName(NAME_UID) val uid: String, + + @SerializedName(NAME_IS_NOT) + val isNot: Boolean = false, + + @SerializedName(NAME_GROUP_UID) + val groupUid: String? = null, ) : Parcelable { constructor(uid: String, type: String, vararg extra: EntityExtra) : this( @@ -32,6 +39,8 @@ data class ConstraintEntity( const val NAME_TYPE = "type" const val NAME_EXTRAS = "extras" const val NAME_UID = "uid" + const val NAME_IS_NOT = "isNot" + const val NAME_GROUP_UID = "groupUid" const val MODE_OR = 0 const val MODE_AND = 1 @@ -39,16 +48,26 @@ data class ConstraintEntity( // types const val APP_FOREGROUND = "constraint_app_foreground" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val APP_NOT_FOREGROUND = "constraint_app_not_foreground" const val APP_PLAYING_MEDIA = "constraint_app_playing_media" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val APP_NOT_PLAYING_MEDIA = "constraint_app_not_playing_media" const val MEDIA_PLAYING = "constraint_media_playing" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val NO_MEDIA_PLAYING = "constraint_no_media_playing" const val BT_DEVICE_CONNECTED = "constraint_bt_device_connected" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val BT_DEVICE_DISCONNECTED = "constraint_bt_device_disconnected" const val SCREEN_ON = "constraint_screen_on" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val SCREEN_OFF = "constraint_screen_off" const val ORIENTATION_0 = "constraint_orientation_0" @@ -68,22 +87,36 @@ data class ConstraintEntity( const val DISPLAY_RESOLUTION = "constraint_display_resolution" const val FLASHLIGHT_ON = "flashlight_on" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val FLASHLIGHT_OFF = "flashlight_off" const val WIFI_ON = "wifi_on" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val WIFI_OFF = "wifi_off" const val WIFI_CONNECTED = "wifi_connected" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val WIFI_DISCONNECTED = "wifi_disconnected" const val IME_CHOSEN = "ime_chosen" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val IME_NOT_CHOSEN = "ime_not_chosen" const val KEYBOARD_SHOWING = "keyboard_showing" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val KEYBOARD_NOT_SHOWING = "keyboard_not_showing" const val DEVICE_IS_LOCKED = "is_locked" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val DEVICE_IS_UNLOCKED = "is_unlocked" const val LOCK_SCREEN_SHOWING = "lock_screen_showing" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val LOCK_SCREEN_NOT_SHOWING = "lock_screen_not_showing" const val IN_PHONE_CALL = "in_phone_call" @@ -95,17 +128,27 @@ data class ConstraintEntity( const val RINGER_MODE_SILENT = "ringer_mode_silent" const val CHARGING = "charging" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val DISCHARGING = "discharging" const val HINGE_CLOSED = "hinge_closed" const val HINGE_OPEN = "hinge_open" const val NOTIFICATION_PANEL_SHOWING = "notification_panel_showing" + + @Deprecated(NOT_DEPRECATION_MESSAGE) const val NOTIFICATION_PANEL_NOT_SHOWING = "notification_panel_not_showing" + private const val NOT_DEPRECATION_MESSAGE = + "Only read to migrate old constraints. Use the positive constraint type with isNot." + + const val NOTIFICATION_POSTED = "constraint_notification_posted" + const val TIME = "time" const val EXTRA_PACKAGE_NAME = "extra_package_name" + const val EXTRA_APP_NAME = "extra_app_name" const val EXTRA_BT_ADDRESS = "extra_bluetooth_device_address" const val EXTRA_BT_NAME = "extra_bluetooth_device_name" const val EXTRA_FLASHLIGHT_CAMERA_LENS = "extra_flashlight_camera_lens" @@ -113,6 +156,10 @@ data class ConstraintEntity( const val EXTRA_IME_ID = "extra_ime_id" const val EXTRA_IME_LABEL = "extra_ime_label" + const val EXTRA_NOTIFICATION_FIELD = "extra_notification_field" + const val EXTRA_NOTIFICATION_MATCH_MODE = "extra_notification_match_mode" + const val EXTRA_NOTIFICATION_VALUE = "extra_notification_value" + const val EXTRA_RESOLUTION_WIDTH = "extra_resolution_width" const val EXTRA_RESOLUTION_HEIGHT = "extra_resolution_height" @@ -131,10 +178,15 @@ data class ConstraintEntity( // Constraints did not always have UID so this could be null. val uid by it.json.byNullableString(NAME_UID) + val isNot by it.json.byBool(NAME_IS_NOT) { false } + val groupUid by it.json.byNullableString(NAME_GROUP_UID) + ConstraintEntity( uid = uid ?: UUID.randomUUID().toString(), type = type, extras = extraList, + isNot = isNot, + groupUid = groupUid, ) } } diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintGroupEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintGroupEntity.kt new file mode 100644 index 0000000000..e99d7e7cdb --- /dev/null +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/ConstraintGroupEntity.kt @@ -0,0 +1,36 @@ +package io.github.sds100.keymapper.data.entities + +import android.os.Parcelable +import com.github.salomonbrys.kotson.byInt +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 + +@Parcelize +data class ConstraintGroupEntity( + @SerializedName(NAME_UID) + val uid: String, + + @SerializedName(NAME_NAME) + val name: String? = null, + + @SerializedName(NAME_MODE) + val mode: Int = ConstraintEntity.DEFAULT_MODE, +) : Parcelable { + companion object { + // DON'T CHANGE THESE. Used for JSON serialization and parsing. + const val NAME_UID = "uid" + const val NAME_NAME = "name" + const val NAME_MODE = "mode" + + val DESERIALIZER = jsonDeserializer { + val uid by it.json.byString(NAME_UID) + val name by it.json.byNullableString(NAME_NAME) + val mode by it.json.byInt(NAME_MODE) { ConstraintEntity.DEFAULT_MODE } + + ConstraintGroupEntity(uid = uid, name = name, mode = mode) + } + } +} diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt index a91eac14f2..c516336c78 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/KeyMapEntity.kt @@ -9,6 +9,7 @@ import androidx.room.PrimaryKey import com.github.salomonbrys.kotson.byArray import com.github.salomonbrys.kotson.byBool import com.github.salomonbrys.kotson.byInt +import com.github.salomonbrys.kotson.byNullableArray import com.github.salomonbrys.kotson.byNullableString import com.github.salomonbrys.kotson.byObject import com.github.salomonbrys.kotson.byString @@ -56,6 +57,10 @@ data class KeyMapEntity( @ColumnInfo(name = KeyMapDao.KEY_CONSTRAINT_MODE) val constraintMode: Int = ConstraintEntity.DEFAULT_MODE, + @SerializedName(NAME_CONSTRAINT_GROUPS) + @ColumnInfo(name = KeyMapDao.KEY_CONSTRAINT_GROUPS, defaultValue = "[]") + val constraintGroups: List = listOf(), + /** * Flags are stored as bits. */ @@ -83,6 +88,7 @@ data class KeyMapEntity( const val NAME_ACTION_LIST = "actionList" const val NAME_CONSTRAINT_LIST = "constraintList" const val NAME_CONSTRAINT_MODE = "constraintMode" + const val NAME_CONSTRAINT_GROUPS = "constraintGroups" const val NAME_FLAGS = "flags" const val NAME_IS_ENABLED = "isEnabled" const val NAME_UID = "uid" @@ -99,6 +105,11 @@ data class KeyMapEntity( val constraintList = it.context.deserialize>(constraintListJsonArray) + val constraintGroupsJsonArray by it.json.byNullableArray(NAME_CONSTRAINT_GROUPS) + val constraintGroups = constraintGroupsJsonArray?.let { jsonArray -> + it.context.deserialize>(jsonArray) + } ?: listOf() + val constraintMode by it.json.byInt(NAME_CONSTRAINT_MODE) val flags by it.json.byInt(NAME_FLAGS) val isEnabled by it.json.byBool(NAME_IS_ENABLED) @@ -111,6 +122,7 @@ data class KeyMapEntity( actionList = actionList, constraintList = constraintList, constraintMode = constraintMode, + constraintGroups = constraintGroups, flags = flags, isEnabled = isEnabled, uid = uid, diff --git a/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt b/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt index ec2e77603d..9bdd6e7498 100644 --- a/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt +++ b/data/src/main/java/io/github/sds100/keymapper/data/entities/TriggerEntity.kt @@ -43,6 +43,9 @@ data class TriggerEntity( @Deprecated("This is now on by default for evdev trigger keys") const val TRIGGER_FLAG_SCREEN_OFF_TRIGGERS = 4 + @Deprecated( + "All key maps can now be triggered by intent or shortcut. Do not reuse this bit.", + ) const val TRIGGER_FLAG_FROM_OTHER_APPS = 8 const val TRIGGER_FLAG_SHOW_TOAST = 16 @@ -51,7 +54,12 @@ data class TriggerEntity( 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" + const val EXTRA_VIBRATION_DURATION = "extra_vibration_duration" + const val EXTRA_VIBRATE_MODE = "extra_vibrate_mode" + const val EXTRA_VIBRATE_EFFECT_TYPE = "extra_vibrate_effect_type" + const val VIBRATE_MODE_DURATION = "duration" + const val VIBRATE_MODE_PREDEFINED = "predefined" val DESERIALIZER = jsonDeserializer { val triggerKeysJsonArray by it.json.byArray(NAME_KEYS) diff --git a/data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration22To23.kt b/data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration22To23.kt new file mode 100644 index 0000000000..37572864a6 --- /dev/null +++ b/data/src/main/java/io/github/sds100/keymapper/data/migration/AutoMigration22To23.kt @@ -0,0 +1,5 @@ +package io.github.sds100.keymapper.data.migration + +import androidx.room.migration.AutoMigrationSpec + +class AutoMigration22To23 : AutoMigrationSpec diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb38faaad3..cd18a7178b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,8 +61,7 @@ material = "1.13.0" mflisar-dragselectrecyclerview = "0.3" mockito-android = "4.6.1" -mockito-core = "5.15.2" -mockito-inline = "5.2.0" +mockito-core = "5.23.0" mockito-kotlin = "4.0.0" okhttp = "4.12.0" @@ -170,7 +169,6 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } junit-params = { group = "pl.pragmatists", name = "JUnitParams", version.ref = "junit-params" } mockito-android = { group = "org.mockito", name = "mockito-android", version.ref = "mockito-android" } mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito-core" } -mockito-inline = { group = "org.mockito", name = "mockito-inline", version.ref = "mockito-inline" } mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockito-kotlin" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } hamcrest-all = { group = "org.hamcrest", name = "hamcrest-all", version = "1.3" } # No version variable diff --git a/system/build.gradle.kts b/system/build.gradle.kts index dbac603be3..efad88dd58 100644 --- a/system/build.gradle.kts +++ b/system/build.gradle.kts @@ -46,6 +46,8 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.core.ktx) + // Only for DynamicColors, to decide whether Key Mapper's notifications need tinting. + implementation(libs.google.android.material) implementation(libs.jakewharton.timber) implementation(libs.dagger.hilt.android) ksp(libs.dagger.hilt.android.compiler) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt b/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt index 8909d67dcc..10b61433fe 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/SystemHiltModule.kt @@ -38,6 +38,8 @@ import io.github.sds100.keymapper.system.network.AndroidNetworkAdapter import io.github.sds100.keymapper.system.network.NetworkAdapter import io.github.sds100.keymapper.system.nfc.AndroidNfcAdapter import io.github.sds100.keymapper.system.nfc.NfcAdapter +import io.github.sds100.keymapper.system.notifications.AndroidNotificationAdapter +import io.github.sds100.keymapper.system.notifications.NotificationAdapter import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapter import io.github.sds100.keymapper.system.notifications.NotificationReceiverAdapterImpl import io.github.sds100.keymapper.system.permissions.AndroidPermissionAdapter @@ -190,6 +192,10 @@ abstract class SystemHiltModule { impl: NotificationReceiverAdapterImpl, ): NotificationReceiverAdapter + @Singleton + @Binds + abstract fun provideNotificationAdapter(impl: AndroidNotificationAdapter): NotificationAdapter + @Singleton @Binds abstract fun provideSystemFeatureAdapter( diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt index 59d04d2a8d..f6974cf554 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/AndroidInputMethodAdapter.kt @@ -215,4 +215,18 @@ class AndroidInputMethodAdapter @Inject constructor( KMError.CycleImeSubtypeFailed } } + + override fun getNextInputMethod(): KMResult { + val chosenImeId = getChosenImeId() + val enabledImes = getInputMethods().filter { it.isEnabled } + + if (enabledImes.isEmpty()) { + return getInfoById(chosenImeId) + } + + val currentIndex = enabledImes.indexOfFirst { it.id == chosenImeId } + val nextIndex = if (currentIndex == -1) 0 else (currentIndex + 1) % enabledImes.size + + return Success(enabledImes[nextIndex]) + } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt index a072a7d949..8f0ee86b03 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/inputmethod/InputMethodAdapter.kt @@ -23,4 +23,10 @@ interface InputMethodAdapter { * around to the first subtype after the last. */ fun cycleInputMethodSubtype(): KMResult + + /** + * Returns the input method that should become active next when cycling through all + * currently enabled input methods, in a fixed order, wrapping around after the last one. + */ + fun getNextInputMethod(): KMResult } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/AndroidNotificationAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt similarity index 81% rename from base/src/main/java/io/github/sds100/keymapper/base/system/notifications/AndroidNotificationAdapter.kt rename to system/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt index 4c72b8efe2..f54340ba0f 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/notifications/AndroidNotificationAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/AndroidNotificationAdapter.kt @@ -1,4 +1,4 @@ -package io.github.sds100.keymapper.base.system.notifications +package io.github.sds100.keymapper.system.notifications import android.Manifest import android.app.NotificationChannel @@ -17,18 +17,16 @@ import androidx.core.app.RemoteInput import androidx.core.content.ContextCompat import com.google.android.material.color.DynamicColors import dagger.hilt.android.qualifiers.ApplicationContext -import io.github.sds100.keymapper.base.R -import io.github.sds100.keymapper.base.utils.ui.color import io.github.sds100.keymapper.common.KeyMapperClassProvider import io.github.sds100.keymapper.common.notifications.KMNotificationAction -import io.github.sds100.keymapper.system.notifications.NotificationAdapter -import io.github.sds100.keymapper.system.notifications.NotificationChannelModel -import io.github.sds100.keymapper.system.notifications.NotificationModel -import io.github.sds100.keymapper.system.notifications.NotificationRemoteInput +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.system.R import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import timber.log.Timber @@ -37,6 +35,7 @@ class AndroidNotificationAdapter @Inject constructor( @ApplicationContext private val ctx: Context, private val coroutineScope: CoroutineScope, private val classProvider: KeyMapperClassProvider, + private val notificationReceiverAdapter: NotificationReceiverAdapter, ) : NotificationAdapter { private val manager: NotificationManagerCompat = NotificationManagerCompat.from(ctx) @@ -44,6 +43,11 @@ class AndroidNotificationAdapter @Inject constructor( override val onNotificationActionClick = MutableSharedFlow() override val onNotificationRemoteInput = MutableSharedFlow() + private val postedNotificationStore = PostedNotificationStore() + + override val activeNotifications: StateFlow> = + postedNotificationStore.active + private val broadcastReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { context ?: return @@ -82,15 +86,16 @@ class AndroidNotificationAdapter @Inject constructor( } val builder = NotificationCompat.Builder(ctx, notification.channel).apply { + // The system tints the notification itself when dynamic colours are available. if (!DynamicColors.isDynamicColorAvailable()) { - color = ctx.color(R.color.md_theme_secondary) + color = ContextCompat.getColor(ctx, R.color.notification_accent) } setContentTitle(notification.title) setContentText(notification.text) if (notification.onClickAction != null) { - val pendingIntent = createActionIntent(notification.onClickAction!!) + val pendingIntent = createActionIntent(notification.onClickAction) setContentIntent(pendingIntent) } @@ -106,7 +111,7 @@ class AndroidNotificationAdapter @Inject constructor( } if (notification.timeout != null) { - this.setTimeoutAfter(notification.timeout!!) + this.setTimeoutAfter(notification.timeout) } if (notification.showIndeterminateProgress) { @@ -216,6 +221,7 @@ class AndroidNotificationAdapter @Inject constructor( is KMNotificationAction.Broadcast -> createBroadcastPendingIntent( notificationAction.intentAction.name, ) + is KMNotificationAction.RemoteInput -> createRemoteInputPendingIntent( notificationAction.intentAction.name, ) @@ -271,4 +277,43 @@ class AndroidNotificationAdapter @Inject constructor( PendingIntent.FLAG_IMMUTABLE, ) } + + override suspend fun dismissAllNotifications(): KMResult<*> = + notificationReceiverAdapter.send(NotificationServiceEvent.DismissAllNotifications) + + override suspend fun dismissLastNotification(): KMResult<*> { + val key = activeNotifications.value.lastOrNull()?.key ?: return Success(Unit) + + return notificationReceiverAdapter.send(NotificationServiceEvent.DismissNotification(key)) + } + + /** + * Replace everything that is posted, for when the listener connects. + * + * Only for [NotificationReceiver]. + */ + fun seedNotifications(notifications: List) { + postedNotificationStore.seed(notifications) + } + + /** + * Only for [NotificationReceiver]. + */ + fun onNotificationPosted(notification: PostedNotification) { + postedNotificationStore.onPosted(notification) + } + + /** + * Only for [NotificationReceiver]. + */ + fun onNotificationRemoved(key: String) { + postedNotificationStore.onRemoved(key) + } + + /** + * Only for [NotificationReceiver]. + */ + fun onNotificationListenerDisconnected() { + postedNotificationStore.clear() + } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationAdapter.kt index f62834cdac..a68b498bcf 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationAdapter.kt @@ -1,7 +1,9 @@ package io.github.sds100.keymapper.system.notifications import io.github.sds100.keymapper.common.notifications.KMNotificationAction +import io.github.sds100.keymapper.common.utils.KMResult import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow interface NotificationAdapter { /** @@ -14,9 +16,25 @@ interface NotificationAdapter { */ val onNotificationRemoteInput: Flow + /** + * The notifications that are posted right now. This requires the notification listener + * permission and is empty without it. + */ + val activeNotifications: StateFlow> + fun showNotification(notification: NotificationModel) fun dismissNotification(notificationId: Int) fun createChannel(channel: NotificationChannelModel) fun deleteChannel(channelId: String) fun openChannelSettings(channelId: String) + + /** + * Dismiss every notification that Key Mapper is allowed to dismiss. + */ + suspend fun dismissAllNotifications(): KMResult<*> + + /** + * Dismiss the notification that was posted most recently. Does nothing if none are posted. + */ + suspend fun dismissLastNotification(): KMResult<*> } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationReceiver.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationReceiver.kt index 0d67e19aac..3a223dee07 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationReceiver.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationReceiver.kt @@ -1,5 +1,6 @@ package io.github.sds100.keymapper.system.notifications +import android.app.Notification import android.content.ComponentName import android.media.session.MediaSessionManager import android.service.notification.NotificationListenerService @@ -37,11 +38,12 @@ class NotificationReceiver : mediaAdapter.onActiveMediaSessionChange(controllers ?: emptyList()) } - private var lastNotificationKey: String? = null - @Inject lateinit var serviceAdapter: NotificationReceiverAdapterImpl + @Inject + lateinit var notificationAdapter: AndroidNotificationAdapter + private lateinit var lifecycleRegistry: LifecycleRegistry override fun onCreate() { @@ -53,13 +55,10 @@ class NotificationReceiver : serviceAdapter.eventsToService .onEach { event -> when (event) { - NotificationServiceEvent.DismissLastNotification -> cancelNotification( - lastNotificationKey, - ) + is NotificationServiceEvent.DismissNotification -> + cancelNotification(event.key) NotificationServiceEvent.DismissAllNotifications -> cancelAllNotifications() - - else -> Unit } }.launchIn(lifecycleScope) } @@ -72,20 +71,20 @@ class NotificationReceiver : override fun onNotificationPosted(sbn: StatusBarNotification?) { super.onNotificationPosted(sbn) - lastNotificationKey = sbn?.key + sbn?.let { notificationAdapter.onNotificationPosted(it.toPostedNotification()) } } override fun onNotificationRemoved(sbn: StatusBarNotification?) { super.onNotificationRemoved(sbn) - if (sbn?.key == lastNotificationKey) { - lastNotificationKey = null - } + sbn?.let { notificationAdapter.onNotificationRemoved(it.key) } } override fun onListenerConnected() { super.onListenerConnected() + seedPostedNotifications() + try { mediaSessionManager.addOnActiveSessionsChangedListener( activeSessionsChangeListener, @@ -108,9 +107,44 @@ class NotificationReceiver : mediaAdapter.onActiveMediaSessionChange(emptyList()) + notificationAdapter.onNotificationListenerDisconnected() + super.onListenerDisconnected() } + /** + * Notifications can be posted while the listener is not running, so the list has to be read + * once on connecting rather than relying on [onNotificationPosted] alone. + */ + private fun seedPostedNotifications() { + try { + val notifications = activeNotifications + ?.map { it.toPostedNotification() } + ?: emptyList() + + notificationAdapter.seedNotifications(notifications) + } catch (e: SecurityException) { + // Thrown when the listener is not connected yet. + Timber.e("NotificationReceiver: Failed to read the posted notifications. $e") + } + } + + private fun StatusBarNotification.toPostedNotification(): PostedNotification { + val extras = notification.extras + + return PostedNotification( + key = key, + packageName = packageName, + title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString(), + // Fall back so that notifications using an expanded style still have matchable text. + text = ( + extras.getCharSequence(Notification.EXTRA_TEXT) + ?: extras.getCharSequence(Notification.EXTRA_BIG_TEXT) + ?: extras.getCharSequence(Notification.EXTRA_SUB_TEXT) + )?.toString(), + ) + } + override val lifecycle: Lifecycle get() = lifecycleRegistry } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationServiceEvent.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationServiceEvent.kt index cfc6fe15bb..8cae664383 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationServiceEvent.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/NotificationServiceEvent.kt @@ -4,8 +4,11 @@ import kotlinx.serialization.Serializable sealed class NotificationServiceEvent { + /** + * @param key the [PostedNotification.key] of the notification to dismiss. + */ @Serializable - data object DismissLastNotification : NotificationServiceEvent() + data class DismissNotification(val key: String) : NotificationServiceEvent() @Serializable data object DismissAllNotifications : NotificationServiceEvent() diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotification.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotification.kt new file mode 100644 index 0000000000..280aef3208 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotification.kt @@ -0,0 +1,15 @@ +package io.github.sds100.keymapper.system.notifications + +/** + * A notification posted by another app. + */ +data class PostedNotification( + /** + * The key the system uses to identify this notification. Apps reuse it when they update a + * notification in place. + */ + val key: String, + val packageName: String, + val title: String?, + val text: String?, +) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotificationStore.kt b/system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotificationStore.kt new file mode 100644 index 0000000000..8237fa1d16 --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/notifications/PostedNotificationStore.kt @@ -0,0 +1,52 @@ +package io.github.sds100.keymapper.system.notifications + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Holds the notifications that [NotificationReceiver] has seen and that are still posted. + * + * This is deliberately free of any Android dependency so that its behaviour can be tested, and it + * lives outside the service because the system destroys and rebinds a notification listener on + * permission changes and memory pressure, which would wipe state kept on the service itself. + * + * Everything here is in memory only and is never written to disk. + */ +class PostedNotificationStore { + private val _active = MutableStateFlow>(emptyList()) + + /** + * The notifications that are posted right now, oldest first. Anything that dismisses the most + * recent notification relies on this order, so new notifications always go on the end. + */ + val active: StateFlow> = _active.asStateFlow() + + /** + * Replace everything that is posted, for when the listener connects and notifications may have + * been posted while it was not running. + */ + fun seed(notifications: List) { + _active.update { notifications } + } + + fun onPosted(notification: PostedNotification) { + _active.update { active -> + // Updating a notification in place moves it to the end, because the app just posted it. + active.filterNot { it.key == notification.key } + notification + } + } + + fun onRemoved(key: String) { + _active.update { active -> active.filterNot { it.key == key } } + } + + /** + * Losing the listener may mean the permission was revoked, so no notification content should be + * left in memory. + */ + fun clear() { + _active.update { emptyList() } + } +} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/phone/AndroidPhoneAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/phone/AndroidPhoneAdapter.kt index 6dc2dd58fe..4ebd1842ec 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/phone/AndroidPhoneAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/phone/AndroidPhoneAdapter.kt @@ -116,7 +116,11 @@ class AndroidPhoneAdapter @Inject constructor( } } - override fun getCallState(): CallState { + override fun getCallState(): CallState? { + if (!hasReadPhoneStatePermission()) { + return null + } + if (telephonyManager == null) { throw Exception("TelephonyManager is null. Does this device support telephony?") } @@ -154,6 +158,13 @@ class AndroidPhoneAdapter @Inject constructor( ) == PackageManager.PERMISSION_GRANTED } + private fun hasReadPhoneStatePermission(): Boolean { + return ActivityCompat.checkSelfPermission( + ctx, + Manifest.permission.READ_PHONE_STATE, + ) == PackageManager.PERMISSION_GRANTED + } + @SuppressLint("MissingPermission") override fun endCall() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -232,8 +243,11 @@ class AndroidPhoneAdapter @Inject constructor( private fun callStateConverter(sdkCallState: Int): CallState { when (sdkCallState) { TelephonyManager.CALL_STATE_IDLE -> return CallState.NONE + TelephonyManager.CALL_STATE_OFFHOOK -> return CallState.IN_PHONE_CALL + TelephonyManager.CALL_STATE_RINGING -> return CallState.RINGING + else -> throw IllegalArgumentException( "Don't know how to convert that call state $sdkCallState", ) diff --git a/system/src/main/java/io/github/sds100/keymapper/system/phone/PhoneAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/phone/PhoneAdapter.kt index 5229852025..13e48be569 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/phone/PhoneAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/phone/PhoneAdapter.kt @@ -10,7 +10,7 @@ interface PhoneAdapter { * Must check if Key Mapper has READ_PHONE_STATE permission before calling this. Otherwise * a security exception will be thrown. */ - fun getCallState(): CallState + fun getCallState(): CallState? fun startCall(number: String): KMResult<*> fun answerCall() fun endCall() diff --git a/system/src/main/java/io/github/sds100/keymapper/system/vibrator/AndroidVibratorAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/vibrator/AndroidVibratorAdapter.kt index 30e14f32a6..e23bcb88dc 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/vibrator/AndroidVibratorAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/vibrator/AndroidVibratorAdapter.kt @@ -5,6 +5,8 @@ import android.os.Build import android.os.VibrationAttributes import android.os.VibrationEffect import android.os.Vibrator +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresApi import androidx.core.content.getSystemService import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -15,26 +17,53 @@ class AndroidVibratorAdapter @Inject constructor(@ApplicationContext private val VibratorAdapter { private val vibrator: Vibrator? = context.getSystemService() + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q) + override val supportsPredefinedEffects: Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + override fun vibrate(duration: Long) { if (duration <= 0) return - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val effect = - VibrationEffect.createOneShot( - duration, - VibrationEffect.DEFAULT_AMPLITUDE, - ) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - vibrator?.vibrate( - effect, - VibrationAttributes.createForUsage(VibrationAttributes.USAGE_ACCESSIBILITY), - ) - } else { - vibrator?.vibrate(effect) - } + val effect = + VibrationEffect.createOneShot( + duration, + VibrationEffect.DEFAULT_AMPLITUDE, + ) + + vibrate(effect) + } + + override fun vibrate(effect: PredefinedVibrationEffect) { + if (supportsPredefinedEffects) { + val platformEffect = VibrationEffect.createPredefined(effect.toPlatformEffectId()) + vibrate(platformEffect) + } else { + // Predefined effects need API 29. Fall back to a short buzz so the + // action/trigger still does something on older devices. + vibrate(FALLBACK_PREDEFINED_EFFECT_DURATION_MS) + } + } + + private fun vibrate(effect: VibrationEffect) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + vibrator?.vibrate( + effect, + VibrationAttributes.createForUsage(VibrationAttributes.USAGE_ACCESSIBILITY), + ) } else { - vibrator?.vibrate(duration) + vibrator?.vibrate(effect) } } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun PredefinedVibrationEffect.toPlatformEffectId(): Int = when (this) { + PredefinedVibrationEffect.CLICK -> VibrationEffect.EFFECT_CLICK + PredefinedVibrationEffect.DOUBLE_CLICK -> VibrationEffect.EFFECT_DOUBLE_CLICK + PredefinedVibrationEffect.HEAVY_CLICK -> VibrationEffect.EFFECT_HEAVY_CLICK + PredefinedVibrationEffect.TICK -> VibrationEffect.EFFECT_TICK + } + + companion object { + private const val FALLBACK_PREDEFINED_EFFECT_DURATION_MS = 40L + } } diff --git a/system/src/main/java/io/github/sds100/keymapper/system/vibrator/PredefinedVibrationEffect.kt b/system/src/main/java/io/github/sds100/keymapper/system/vibrator/PredefinedVibrationEffect.kt new file mode 100644 index 0000000000..096c11f86e --- /dev/null +++ b/system/src/main/java/io/github/sds100/keymapper/system/vibrator/PredefinedVibrationEffect.kt @@ -0,0 +1,8 @@ +package io.github.sds100.keymapper.system.vibrator + +enum class PredefinedVibrationEffect { + CLICK, + DOUBLE_CLICK, + HEAVY_CLICK, + TICK, +} diff --git a/system/src/main/java/io/github/sds100/keymapper/system/vibrator/VibratorAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/vibrator/VibratorAdapter.kt index ac717776b1..faad30a8bb 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/vibrator/VibratorAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/vibrator/VibratorAdapter.kt @@ -1,5 +1,12 @@ package io.github.sds100.keymapper.system.vibrator interface VibratorAdapter { + /** + * Whether [vibrate] with a [PredefinedVibrationEffect] plays a distinct effect on this + * device rather than falling back to a generic buzz. + */ + val supportsPredefinedEffects: Boolean + fun vibrate(duration: Long) + fun vibrate(effect: PredefinedVibrationEffect) } diff --git a/system/src/main/res/values-night/colors.xml b/system/src/main/res/values-night/colors.xml new file mode 100644 index 0000000000..8f285c970c --- /dev/null +++ b/system/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + + + #BEC6DC + diff --git a/system/src/main/res/values/colors.xml b/system/src/main/res/values/colors.xml new file mode 100644 index 0000000000..d94ab92b0d --- /dev/null +++ b/system/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + + #565F71 +