diff --git a/CHANGELOG.md b/CHANGELOG.md index be7db7909c..03e80b40e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,23 @@ +## [4.0.0 Beta 5](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.05) + +#### TO BE RELEASED + +## Added +- #1947 show tip to use expert mode where the old option for screen off remapping used to be + +## Bug fixes + +- #1955 step forward and step backward media actions support more apps. +- #1940 improve reliability of clicking pairing code button in Wireless Debugging settings. + ## [4.0.0 Beta 4](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.04) #### 25 December 2025 Merry Christmas from the Key Mapper team! 🎄 -Renamed PRO mode to Expert mode because it sounded like a paid premium feature even though it is free. +Renamed PRO mode to Expert mode because it sounded like a paid premium feature even though it is +free. ## Added diff --git a/app/version.properties b/app/version.properties index 6a17534a00..6d3cc998f5 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=4.0.0-beta.04 -VERSION_CODE=217 +VERSION_NAME=4.0.0-beta.05 +VERSION_CODE=220 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 0385eedbb0..a9b7fa1402 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 @@ -785,6 +785,14 @@ object ActionUtils { listOf(Permission.ROOT) } + ActionId.PLAY_PAUSE_MEDIA, + ActionId.PLAY_MEDIA, + ActionId.PAUSE_MEDIA, + ActionId.FAST_FORWARD, + ActionId.REWIND, + ActionId.STOP_MEDIA, + ActionId.NEXT_TRACK, + ActionId.PREVIOUS_TRACK, ActionId.PLAY_PAUSE_MEDIA_PACKAGE, ActionId.PAUSE_MEDIA_PACKAGE, ActionId.PLAY_MEDIA_PACKAGE, @@ -792,6 +800,7 @@ object ActionUtils { ActionId.PREVIOUS_TRACK_PACKAGE, ActionId.FAST_FORWARD_PACKAGE, ActionId.REWIND_PACKAGE, + ActionId.STOP_MEDIA_PACKAGE, -> return listOf(Permission.NOTIFICATION_LISTENER) ActionId.VOLUME_UP, 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 a49fb86f53..df76275e5f 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 @@ -152,7 +152,9 @@ class ChooseActionViewModel @Inject constructor( error == SystemError.PermissionDenied( Permission.ROOT, ) -> getString(R.string.choose_action_warning_requires_root) + error != null -> error.getFullMessage(this@ChooseActionViewModel) + else -> null } @@ -200,13 +202,6 @@ class ChooseActionViewModel @Inject constructor( val messageToShow: Int? = when (id) { ActionId.FAST_FORWARD_PACKAGE, - ActionId.FAST_FORWARD, - -> R.string.action_fast_forward_message - - ActionId.REWIND_PACKAGE, - ActionId.REWIND, - -> R.string.action_rewind_message - ActionId.TOGGLE_KEYBOARD, ActionId.SHOW_KEYBOARD, ActionId.HIDE_KEYBOARD, 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 a451bdc146..39290b9595 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 @@ -208,10 +208,12 @@ class CreateActionDelegate( showVolumeUi = state.showVolumeUi, volumeStream = state.volumeStream, ) + ActionId.VOLUME_DOWN -> ActionData.Volume.Down( showVolumeUi = state.showVolumeUi, volumeStream = state.volumeStream, ) + else -> return } @@ -405,8 +407,7 @@ class CreateActionDelegate( navigate( "choose_app_for_media_action", NavDestination.ChooseApp(allowHiddenApps = true), - ) - ?: return null + ) ?: return null val action = when (actionId) { ActionId.PAUSE_MEDIA_PACKAGE -> @@ -494,7 +495,9 @@ class CreateActionDelegate( val action = when (actionId) { ActionId.VOLUME_MUTE -> ActionData.Volume.Mute(showVolumeUi) + ActionId.VOLUME_UNMUTE -> ActionData.Volume.UnMute(showVolumeUi) + ActionId.VOLUME_TOGGLE_MUTE -> ActionData.Volume.ToggleMute( showVolumeUi, ) @@ -574,9 +577,7 @@ class CreateActionDelegate( val action = when (actionId) { ActionId.TOGGLE_DND_MODE -> ActionData.DoNotDisturb.Toggle(dndMode) - ActionId.ENABLE_DND_MODE -> ActionData.DoNotDisturb.Enable(dndMode) - else -> throw Exception("don't know how to create action for $actionId") } @@ -584,7 +585,7 @@ class CreateActionDelegate( } ActionId.CYCLE_ROTATIONS -> { - val items = Orientation.values().map { orientation -> + val items = Orientation.entries.map { orientation -> val isChecked = if (oldData is ActionData.Rotation.CycleRotations) { oldData.orientations.contains(orientation) } else { @@ -756,11 +757,7 @@ class CreateActionDelegate( NavDestination.PickCoordinate(oldResult), ) ?: return null - val description = if (result.description.isEmpty()) { - null - } else { - result.description - } + val description = result.description.ifEmpty { null } return ActionData.TapScreen( result.x, @@ -789,11 +786,7 @@ class CreateActionDelegate( NavDestination.PickSwipeCoordinate(oldResult), ) ?: return null - val description = if (result.description.isEmpty()) { - null - } else { - result.description - } + val description = result.description.ifEmpty { null } return ActionData.SwipeScreen( result.xStart, @@ -826,11 +819,7 @@ class CreateActionDelegate( NavDestination.PickPinchCoordinate(oldResult), ) ?: return null - val description = if (result.description.isEmpty()) { - null - } else { - result.description - } + val description = result.description.ifEmpty { null } return ActionData.PinchScreen( result.x, @@ -964,93 +953,153 @@ class CreateActionDelegate( } ActionId.TOGGLE_WIFI -> return ActionData.Wifi.Toggle + ActionId.ENABLE_WIFI -> return ActionData.Wifi.Enable + ActionId.DISABLE_WIFI -> return ActionData.Wifi.Disable ActionId.TOGGLE_BLUETOOTH -> return ActionData.Bluetooth.Toggle + ActionId.ENABLE_BLUETOOTH -> return ActionData.Bluetooth.Enable + ActionId.DISABLE_BLUETOOTH -> return ActionData.Bluetooth.Disable ActionId.TOGGLE_MOBILE_DATA -> return ActionData.MobileData.Toggle + ActionId.ENABLE_MOBILE_DATA -> return ActionData.MobileData.Enable + ActionId.DISABLE_MOBILE_DATA -> return ActionData.MobileData.Disable ActionId.TOGGLE_HOTSPOT -> return ActionData.Hotspot.Toggle + ActionId.ENABLE_HOTSPOT -> return ActionData.Hotspot.Enable + ActionId.DISABLE_HOTSPOT -> return ActionData.Hotspot.Disable ActionId.TOGGLE_AUTO_BRIGHTNESS -> return ActionData.Brightness.ToggleAuto + ActionId.DISABLE_AUTO_BRIGHTNESS -> return ActionData.Brightness.DisableAuto + ActionId.ENABLE_AUTO_BRIGHTNESS -> return ActionData.Brightness.EnableAuto + ActionId.INCREASE_BRIGHTNESS -> return ActionData.Brightness.Increase + ActionId.DECREASE_BRIGHTNESS -> return ActionData.Brightness.Decrease ActionId.TOGGLE_AUTO_ROTATE -> return ActionData.Rotation.ToggleAuto + ActionId.ENABLE_AUTO_ROTATE -> return ActionData.Rotation.EnableAuto + ActionId.DISABLE_AUTO_ROTATE -> return ActionData.Rotation.DisableAuto + ActionId.PORTRAIT_MODE -> return ActionData.Rotation.Portrait + ActionId.LANDSCAPE_MODE -> return ActionData.Rotation.Landscape + ActionId.SWITCH_ORIENTATION -> return ActionData.Rotation.SwitchOrientation ActionId.VOLUME_SHOW_DIALOG -> return ActionData.Volume.ShowDialog + ActionId.CYCLE_RINGER_MODE -> return ActionData.Volume.CycleRingerMode + ActionId.CYCLE_VIBRATE_RING -> return ActionData.Volume.CycleVibrateRing ActionId.EXPAND_NOTIFICATION_DRAWER -> return ActionData.StatusBar.ExpandNotifications + ActionId.TOGGLE_NOTIFICATION_DRAWER -> return ActionData.StatusBar.ToggleNotifications + ActionId.EXPAND_QUICK_SETTINGS -> return ActionData.StatusBar.ExpandQuickSettings + ActionId.TOGGLE_QUICK_SETTINGS -> return ActionData.StatusBar.ToggleQuickSettings + ActionId.COLLAPSE_STATUS_BAR -> return ActionData.StatusBar.Collapse ActionId.PAUSE_MEDIA -> return ActionData.ControlMedia.Pause + ActionId.PLAY_MEDIA -> return ActionData.ControlMedia.Play + ActionId.PLAY_PAUSE_MEDIA -> return ActionData.ControlMedia.PlayPause + ActionId.NEXT_TRACK -> return ActionData.ControlMedia.NextTrack + ActionId.PREVIOUS_TRACK -> return ActionData.ControlMedia.PreviousTrack + ActionId.FAST_FORWARD -> return ActionData.ControlMedia.FastForward + ActionId.REWIND -> return ActionData.ControlMedia.Rewind + ActionId.STOP_MEDIA -> return ActionData.ControlMedia.Stop + ActionId.STEP_FORWARD -> return ActionData.ControlMedia.StepForward + ActionId.STEP_BACKWARD -> return ActionData.ControlMedia.StepBackward ActionId.GO_BACK -> return ActionData.GoBack + ActionId.GO_HOME -> return ActionData.GoHome + ActionId.OPEN_RECENTS -> return ActionData.OpenRecents + ActionId.TOGGLE_SPLIT_SCREEN -> return ActionData.ToggleSplitScreen + ActionId.GO_LAST_APP -> return ActionData.GoLastApp + ActionId.OPEN_MENU -> return ActionData.OpenMenu ActionId.ENABLE_NFC -> return ActionData.Nfc.Enable + ActionId.DISABLE_NFC -> return ActionData.Nfc.Disable + ActionId.TOGGLE_NFC -> return ActionData.Nfc.Toggle ActionId.TOGGLE_KEYBOARD -> return ActionData.ToggleKeyboard + ActionId.SHOW_KEYBOARD -> return ActionData.ShowKeyboard + ActionId.HIDE_KEYBOARD -> return ActionData.HideKeyboard + ActionId.SHOW_KEYBOARD_PICKER -> return ActionData.ShowKeyboardPicker + ActionId.TEXT_CUT -> return ActionData.CutText + ActionId.TEXT_COPY -> return ActionData.CopyText + ActionId.TEXT_PASTE -> return ActionData.PasteText + ActionId.SELECT_WORD_AT_CURSOR -> return ActionData.SelectWordAtCursor ActionId.TOGGLE_AIRPLANE_MODE -> return ActionData.AirplaneMode.Toggle + ActionId.ENABLE_AIRPLANE_MODE -> return ActionData.AirplaneMode.Enable + ActionId.DISABLE_AIRPLANE_MODE -> return ActionData.AirplaneMode.Disable ActionId.SCREENSHOT -> return ActionData.Screenshot + ActionId.OPEN_VOICE_ASSISTANT -> return ActionData.VoiceAssistant + ActionId.OPEN_DEVICE_ASSISTANT -> return ActionData.DeviceAssistant ActionId.OPEN_CAMERA -> return ActionData.OpenCamera + ActionId.LOCK_DEVICE -> return ActionData.LockDevice + ActionId.POWER_ON_OFF_DEVICE -> return ActionData.ScreenOnOff + ActionId.SECURE_LOCK_DEVICE -> return ActionData.SecureLock + ActionId.CONSUME_KEY_EVENT -> return ActionData.ConsumeKeyEvent + ActionId.OPEN_SETTINGS -> return ActionData.OpenSettings + ActionId.SHOW_POWER_MENU -> return ActionData.ShowPowerMenu + ActionId.DISABLE_DND_MODE -> return ActionData.DoNotDisturb.Disable + ActionId.DISMISS_MOST_RECENT_NOTIFICATION -> return ActionData.DismissLastNotification + ActionId.DISMISS_ALL_NOTIFICATIONS -> return ActionData.DismissAllNotifications + ActionId.CREATE_NOTIFICATION -> { val oldAction = oldData as? ActionData.CreateNotification @@ -1063,9 +1112,13 @@ class CreateActionDelegate( return null } + ActionId.ANSWER_PHONE_CALL -> return ActionData.AnswerCall + ActionId.END_PHONE_CALL -> return ActionData.EndCall + ActionId.DEVICE_CONTROLS -> return ActionData.DeviceControls + ActionId.HTTP_REQUEST -> { if (oldData == null) { httpRequestBottomSheetState = ActionData.HttpRequest( @@ -1104,7 +1157,9 @@ class CreateActionDelegate( } ActionId.MOVE_CURSOR -> return createMoverCursorAction() + ActionId.FORCE_STOP_APP -> return ActionData.ForceStopApp + ActionId.CLEAR_RECENT_APP -> return ActionData.ClearRecentApp ActionId.MODIFY_SETTING -> { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupAssistantController.kt b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupAssistantController.kt index 0589a80761..94b5bdc966 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupAssistantController.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/expertmode/SystemBridgeSetupAssistantController.kt @@ -1,6 +1,7 @@ package io.github.sds100.keymapper.base.expertmode import android.app.ActivityManager +import android.graphics.Rect import android.os.Build import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo @@ -19,6 +20,7 @@ import io.github.sds100.keymapper.base.utils.ui.ResourceProvider import io.github.sds100.keymapper.common.KeyMapperClassProvider import io.github.sds100.keymapper.common.notifications.KMNotificationAction import io.github.sds100.keymapper.common.utils.Constants +import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.common.utils.onFailure import io.github.sds100.keymapper.common.utils.onSuccess import io.github.sds100.keymapper.data.Keys @@ -74,6 +76,16 @@ class SystemBridgeSetupAssistantController @AssistedInject constructor( Regex( "^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", ) + + private val PAIRING_CODE_BUTTON_TEXT_FILTER = arrayOf( + "six-digit code", // English + "six digit code", // English + "kode enam digit", // Indonesian + "código de seis dígitos", // Spanish (US) and Portuguese (Brazil) + "छह अंकों वाला कोड इस्तेमाल", // Hindi (India) + "шестизначный код", // Russian (Russia) + "من 6 أعداد", // Arabic (Egypt) + ) } private enum class InteractionStep { @@ -240,24 +252,22 @@ class SystemBridgeSetupAssistantController @AssistedInject constructor( } private fun clickPairWithCodeButton(rootNode: AccessibilityNodeInfo) { - rootNode - .findNodeRecursively { it.className == "androidx.recyclerview.widget.RecyclerView" } - ?.takeIf { recyclerView -> - // There are many settings screens with RecyclerViews so make sure - // the correct page is showing before clicking. It is not as simple - // as checking the words on the screen due to different languages. - val ipAddressPortText: CharSequence? = - runCatching { - // RecyclerView -> LinearLayout -> RelativeLayout -> TextView - recyclerView.getChild(1).getChild(0).getChild(1) - }.getOrNull()?.text - - val ipText = ipAddressPortText?.split(":")?.firstOrNull() - ipText != null && IPV4_REGEX.matches(ipText) - } - ?.runCatching { getChild(3) } - ?.getOrNull() - ?.performAction(AccessibilityNodeInfo.ACTION_CLICK) + // This works more maintainable/adaptable then traversing the tree + // and trying to find the clickable node. This can change subtly between + // Android devices and ROMs. + val textNode = rootNode.findNodeRecursively { node -> + Timber.e(node.text?.toString()) + PAIRING_CODE_BUTTON_TEXT_FILTER.any { text -> node.text?.contains(text) == true } + } ?: return + + val bounds = Rect() + textNode.getBoundsInScreen(bounds) + + accessibilityService.tapScreen( + bounds.centerX(), + bounds.centerY(), + InputEventAction.DOWN_UP, + ) } private fun showNotification( 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 63657c3fab..b51cb76ec8 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 @@ -6,7 +6,11 @@ import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.ActionUiHelper import io.github.sds100.keymapper.base.shortcuts.CreateKeyMapShortcutUseCase import io.github.sds100.keymapper.base.trigger.ConfigTriggerUseCase +import io.github.sds100.keymapper.base.trigger.EvdevTriggerKey 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 @@ -16,12 +20,14 @@ import io.github.sds100.keymapper.common.utils.State import io.github.sds100.keymapper.common.utils.dataOrNull import io.github.sds100.keymapper.common.utils.mapData import io.github.sds100.keymapper.common.utils.onFailure +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionManager +import io.github.sds100.keymapper.sysbridge.manager.SystemBridgeConnectionState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -30,16 +36,22 @@ class ConfigKeyMapOptionsViewModel( private val config: ConfigTriggerUseCase, private val displayUseCase: DisplayKeyMapUseCase, private val createKeyMapShortcut: CreateKeyMapShortcutUseCase, + private val systemBridgeConnectionManager: SystemBridgeConnectionManager, private val dialogProvider: DialogProvider, + navigationProvider: NavigationProvider, resourceProvider: ResourceProvider, ) : ResourceProvider by resourceProvider, DialogProvider by dialogProvider, + NavigationProvider by navigationProvider, KeyMapOptionsCallback { private val actionUiHelper = ActionUiHelper(displayUseCase, resourceProvider) - val state: StateFlow> = config.keyMap.map { keyMapState -> - keyMapState.mapData { keyMap -> buildState(keyMap) } + val state: StateFlow> = combine( + config.keyMap, + systemBridgeConnectionManager.connectionState, + ) { keyMapState, systemBridgeConnectionState -> + keyMapState.mapData { keyMap -> buildState(keyMap, systemBridgeConnectionState) } }.stateIn(coroutineScope, SharingStarted.Eagerly, State.Loading) override fun onLongPressDelayChanged(delay: Int) { @@ -74,6 +86,12 @@ class ConfigKeyMapOptionsViewModel( config.setTriggerFromOtherAppsEnabled(checked) } + override fun onOpenExpertModeSettings() { + coroutineScope.launch { + navigate("screen_off_trigger_tip", NavDestination.ExpertMode) + } + } + override fun onCreateShortcutClick() { coroutineScope.launch { val mapping = config.keyMap.firstOrNull()?.dataOrNull() ?: return@launch @@ -100,7 +118,9 @@ class ConfigKeyMapOptionsViewModel( // 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 -> {} } @@ -132,7 +152,10 @@ class ConfigKeyMapOptionsViewModel( } } - private suspend fun buildState(keyMap: KeyMap): KeyMapOptionsState { + private suspend fun buildState( + keyMap: KeyMap, + systemBridgeConnectionState: SystemBridgeConnectionState, + ): KeyMapOptionsState { val defaultLongPressDelay = config.defaultLongPressDelay.first() val defaultDoublePressDelay = config.defaultDoublePressDelay.first() val defaultSequenceTriggerTimeout = config.defaultSequenceTriggerTimeout.first() @@ -167,6 +190,9 @@ class ConfigKeyMapOptionsViewModel( isLauncherShortcutButtonEnabled = createKeyMapShortcut.isSupported, showToast = keyMap.trigger.showToast, + showScreenOffTip = keyMap.trigger.keys.none { it is EvdevTriggerKey }, + isExpertModeStarted = + systemBridgeConnectionState is SystemBridgeConnectionState.Connected, ) } } @@ -199,4 +225,7 @@ data class KeyMapOptionsState( val isLauncherShortcutButtonEnabled: Boolean, val showToast: Boolean, + + val showScreenOffTip: Boolean, + val isExpertModeStarted: Boolean, ) 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 6d78c9bce2..e2c7e887f0 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 @@ -43,6 +43,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.compose.KeyMapperTheme +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 @@ -98,9 +99,27 @@ private fun Loaded( modifier = modifier .verticalScroll(rememberScrollState()) .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Spacer(modifier = Modifier.height(8.dp)) + if (state.showScreenOffTip) { + TipCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + title = stringResource(R.string.tip_screen_off_trigger_title), + message = stringResource(R.string.tip_screen_off_trigger_message), + isDismissable = false, + buttonText = if (state.isExpertModeStarted) { + null + } else { + stringResource(R.string.button_enable_expert_mode) + }, + onButtonClick = callback::onOpenExpertModeSettings, + ) + } + TriggerFromOtherAppsSection( modifier = Modifier .fillMaxWidth() @@ -112,8 +131,6 @@ private fun Loaded( onCreateShortcutClick = callback::onCreateShortcutClick, ) - Spacer(Modifier.height(8.dp)) - CheckBoxText( modifier = Modifier .padding(horizontal = 8.dp) @@ -122,7 +139,6 @@ private fun Loaded( isChecked = state.showToast, onCheckedChange = callback::onShowToastChanged, ) - Spacer(Modifier.height(8.dp)) if (state.showVibrate) { CheckBoxText( @@ -133,7 +149,6 @@ private fun Loaded( isChecked = state.vibrate, onCheckedChange = callback::onVibrateChanged, ) - Spacer(Modifier.height(8.dp)) } if (state.showVibrateDuration) { @@ -151,7 +166,6 @@ private fun Loaded( valueRange = vibrateDurationMin.toFloat()..vibrateDurationMax.toFloat(), stepSize = SliderStepSizes.VIBRATION_DURATION, ) - Spacer(Modifier.height(8.dp)) } if (state.showLongPressDoubleVibration) { @@ -163,7 +177,6 @@ private fun Loaded( isChecked = state.longPressDoubleVibration, onCheckedChange = callback::onLongPressDoubleVibrationChanged, ) - Spacer(Modifier.height(8.dp)) } if (state.showLongPressDelay) { @@ -181,7 +194,6 @@ private fun Loaded( valueRange = longPressDelayMin.toFloat()..longPressDelayMax.toFloat(), stepSize = SliderStepSizes.TRIGGER_LONG_PRESS_DELAY, ) - Spacer(Modifier.height(8.dp)) } if (state.showDoublePressDelay) { @@ -199,7 +211,6 @@ private fun Loaded( valueRange = doublePressDelayMin.toFloat()..doublePressDelayMax.toFloat(), stepSize = SliderStepSizes.TRIGGER_DOUBLE_PRESS_DELAY, ) - Spacer(Modifier.height(8.dp)) } if (state.showSequenceTriggerTimeout) { @@ -219,7 +230,6 @@ private fun Loaded( valueRange = sequenceTriggerTimeoutMin..sequenceTriggerTimeoutMax, stepSize = SliderStepSizes.TRIGGER_SEQUENCE_TRIGGER_TIMEOUT, ) - Spacer(Modifier.height(8.dp)) } Spacer(Modifier.height(8.dp)) @@ -359,6 +369,7 @@ interface KeyMapOptionsCallback { fun onShowToastChanged(checked: Boolean) = run { } fun onTriggerFromOtherAppsChanged(checked: Boolean) = run {} fun onCreateShortcutClick() = run { } + fun onOpenExpertModeSettings() = run {} } @Preview @@ -396,6 +407,8 @@ private fun Preview() { isLauncherShortcutButtonEnabled = false, showToast = true, + showScreenOffTip = true, + isExpertModeStarted = false, ), ), callback = object : KeyMapOptionsCallback {}, 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 e55d4413b8..52f09bbe3f 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 @@ -98,7 +98,9 @@ abstract class BaseConfigTriggerViewModel( config, displayKeyMap, createKeyMapShortcut, + systemBridgeConnectionManager, dialogProvider, + navigationProvider, resourceProvider, ) @@ -256,11 +258,9 @@ abstract class BaseConfigTriggerViewModel( val clickTypeButtons = mutableSetOf() - /** - * The click type radio buttons are only visible if there is one key - * or there are only key code keys in the trigger. It is not possible to do a long press of - * non-key code keys in a parallel trigger. - */ + // The click type radio buttons are only visible if there is one key + // or there are only key code keys in the trigger. It is not possible to do a long press of + // non-key code keys in a parallel trigger. if (trigger.keys.size == 1 && trigger.keys.all { it.allowedDoublePress }) { clickTypeButtons.add(ClickType.SHORT_PRESS) clickTypeButtons.add(ClickType.DOUBLE_PRESS) 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 72513034f9..a791092b9a 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 @@ -22,48 +22,63 @@ fun KMError.getFullMessage(ctx: Context): String { fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { return when (this) { - is SystemError.PermissionDenied -> - { - val resId = when (permission) { - Permission.WRITE_SETTINGS -> - R.string.error_action_requires_write_settings_permission - Permission.CAMERA -> - R.string.error_action_requires_camera_permission - Permission.DEVICE_ADMIN -> - R.string.error_need_to_enable_device_admin - Permission.READ_PHONE_STATE -> - R.string.error_action_requires_read_phone_state_permission - Permission.ACCESS_NOTIFICATION_POLICY -> - R.string.error_action_notification_policy_permission - Permission.WRITE_SECURE_SETTINGS -> - R.string.error_need_write_secure_settings_permission - Permission.NOTIFICATION_LISTENER -> - R.string.error_denied_notification_listener_service_permission - Permission.CALL_PHONE -> - R.string.error_denied_call_phone_permission - Permission.SEND_SMS -> - R.string.error_denied_send_sms_permission - Permission.ROOT -> - R.string.error_requires_root - Permission.IGNORE_BATTERY_OPTIMISATION -> - R.string.error_battery_optimisation_enabled - Permission.SHIZUKU -> - R.string.error_shizuku_permission_denied - Permission.ACCESS_FINE_LOCATION -> - R.string.error_access_fine_location_permission_denied - Permission.ANSWER_PHONE_CALL -> - R.string.error_answer_end_phone_call_permission_denied - Permission.FIND_NEARBY_DEVICES -> - R.string.error_find_nearby_devices_permission_denied - Permission.POST_NOTIFICATIONS -> - R.string.error_notifications_permission_denied - Permission.READ_LOGS -> - R.string.error_read_logs_permission_denied - } - - resourceProvider.getString(resId) + is SystemError.PermissionDenied -> { + val resId = when (permission) { + Permission.WRITE_SETTINGS -> + R.string.error_action_requires_write_settings_permission + + Permission.CAMERA -> + R.string.error_action_requires_camera_permission + + Permission.DEVICE_ADMIN -> + R.string.error_need_to_enable_device_admin + + Permission.READ_PHONE_STATE -> + R.string.error_action_requires_read_phone_state_permission + + Permission.ACCESS_NOTIFICATION_POLICY -> + R.string.error_action_notification_policy_permission + + Permission.WRITE_SECURE_SETTINGS -> + R.string.error_need_write_secure_settings_permission + + Permission.NOTIFICATION_LISTENER -> + R.string.error_denied_notification_listener_service_permission + + Permission.CALL_PHONE -> + R.string.error_denied_call_phone_permission + + Permission.SEND_SMS -> + R.string.error_denied_send_sms_permission + + Permission.ROOT -> + R.string.error_requires_root + + Permission.IGNORE_BATTERY_OPTIMISATION -> + R.string.error_battery_optimisation_enabled + + Permission.SHIZUKU -> + R.string.error_shizuku_permission_denied + + Permission.ACCESS_FINE_LOCATION -> + R.string.error_access_fine_location_permission_denied + + Permission.ANSWER_PHONE_CALL -> + R.string.error_answer_end_phone_call_permission_denied + + Permission.FIND_NEARBY_DEVICES -> + R.string.error_find_nearby_devices_permission_denied + + Permission.POST_NOTIFICATIONS -> + R.string.error_notifications_permission_denied + + Permission.READ_LOGS -> + R.string.error_read_logs_permission_denied } + resourceProvider.getString(resId) + } + is KMError.AppNotFound -> resourceProvider.getString( R.string.error_app_isnt_installed, @@ -80,40 +95,49 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { resourceProvider.getString( R.string.error_key_mapper_ime_service_disabled, ) + is KMError.NoCompatibleImeChosen -> resourceProvider.getString( R.string.error_ime_must_be_chosen, ) + is KMError.SystemFeatureNotSupported -> when (this.feature) { PackageManager.FEATURE_NFC -> resourceProvider.getString( R.string.error_system_feature_nfc_unsupported, ) + PackageManager.FEATURE_CAMERA -> resourceProvider.getString( R.string.error_system_feature_camera_unsupported, ) + PackageManager.FEATURE_FINGERPRINT -> resourceProvider.getString( R.string.error_system_feature_fingerprint_unsupported, ) + PackageManager.FEATURE_WIFI -> resourceProvider.getString( R.string.error_system_feature_wifi_unsupported, ) + PackageManager.FEATURE_BLUETOOTH -> resourceProvider.getString( R.string.error_system_feature_bluetooth_unsupported, ) + PackageManager.FEATURE_DEVICE_ADMIN -> resourceProvider.getString( R.string.error_system_feature_device_admin_unsupported, ) + PackageManager.FEATURE_CAMERA_FLASH -> resourceProvider.getString( R.string.error_system_feature_camera_flash_unsupported, ) + PackageManager.FEATURE_TELEPHONY, PackageManager.FEATURE_TELEPHONY_DATA, PackageManager.FEATURE_TELEPHONY_MESSAGING, @@ -156,18 +180,24 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { resourceProvider.getString( R.string.error_front_flash_not_found, ) + is KMError.BackFlashNotFound -> resourceProvider.getString( R.string.error_back_flash_not_found, ) + is KMError.DeviceNotFound -> resourceProvider.getString(R.string.error_device_not_found) + is KMError.Exception -> exception.toString() + is KMError.EmptyJson -> resourceProvider.getString(R.string.error_empty_json) + is KMError.InvalidNumber -> resourceProvider.getString(R.string.error_invalid_number) + is KMError.NumberTooSmall -> resourceProvider.getString( R.string.error_number_too_small, @@ -176,46 +206,58 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { is KMError.NumberTooBig -> resourceProvider.getString(R.string.error_number_too_big, max) + is KMError.EmptyText -> resourceProvider.getString(R.string.error_cant_be_empty) + KMError.BackupVersionTooNew -> resourceProvider.getString( R.string.error_backup_version_too_new, ) + KMError.NoIncompatibleKeyboardsInstalled -> resourceProvider.getString( R.string.error_no_incompatible_input_methods_installed, ) + KMError.NoMediaSessions -> resourceProvider.getString(R.string.error_no_media_sessions) + KMError.NoVoiceAssistant -> resourceProvider.getString( R.string.error_voice_assistant_not_found, ) + AccessibilityServiceError.Disabled -> resourceProvider.getString( R.string.error_accessibility_service_disabled, ) + AccessibilityServiceError.Crashed -> resourceProvider.getString( R.string.error_accessibility_service_crashed, ) + KMError.LauncherShortcutsNotSupported -> resourceProvider.getString( R.string.error_launcher_shortcuts_not_supported, ) + KMError.CantFindImeSettings -> resourceProvider.getString( R.string.error_cant_find_ime_settings, ) + KMError.CantShowImePickerInBackground -> resourceProvider.getString( R.string.error_cant_show_ime_picker_in_background, ) + KMError.FailedToFindAccessibilityNode -> resourceProvider.getString( R.string.error_failed_to_find_accessibility_node, ) + is KMError.FailedToPerformAccessibilityGlobalAction -> resourceProvider.getString( R.string.error_failed_to_perform_accessibility_global_action, @@ -226,76 +268,95 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { resourceProvider.getString( R.string.error_failed_to_dispatch_gesture, ) + KMError.AppShortcutCantBeOpened -> resourceProvider.getString( R.string.error_opening_app_shortcut, ) + KMError.InsufficientPermissionsToOpenAppShortcut -> resourceProvider.getString( R.string.error_keymapper_doesnt_have_permission_app_shortcut, ) + KMError.NoAppToPhoneCall -> resourceProvider.getString(R.string.error_no_app_to_phone_call) + KMError.NoAppToSendSms -> resourceProvider.getString(R.string.error_no_app_to_send_sms) - is KMError.SendSmsError -> - { - when (resultCode) { - SmsManager.RESULT_ERROR_GENERIC_FAILURE -> - resourceProvider.getString( - R.string.error_sms_generic_failure, - ) - SmsManager.RESULT_ERROR_RADIO_OFF -> - resourceProvider.getString( - R.string.error_sms_radio_off, - ) - SmsManager.RESULT_ERROR_NO_SERVICE -> - resourceProvider.getString( - R.string.error_sms_no_service, - ) - SmsManager.RESULT_ERROR_LIMIT_EXCEEDED -> - resourceProvider.getString( - R.string.error_sms_limit_exceeded, - ) - SmsManager.RESULT_NETWORK_REJECT -> - resourceProvider.getString( - R.string.error_sms_network_reject, - ) - SmsManager.RESULT_NO_MEMORY -> - resourceProvider.getString( - R.string.error_sms_no_memory, - ) - SmsManager.RESULT_INVALID_SMS_FORMAT -> - resourceProvider.getString( - R.string.error_sms_invalid_format, - ) - SmsManager.RESULT_NETWORK_ERROR -> - resourceProvider.getString( - R.string.error_sms_network_error, - ) - SmsManager.RESULT_SMS_BLOCKED_DURING_EMERGENCY -> - resourceProvider.getString( - R.string.error_sms_blocked_during_emergency, - ) - SmsManager.RESULT_RIL_SIM_ABSENT -> - resourceProvider.getString( - R.string.error_sms_no_sim, - ) - else -> - resourceProvider.getString(R.string.error_sms_generic_failure) - } + + is KMError.SendSmsError -> { + when (resultCode) { + SmsManager.RESULT_ERROR_GENERIC_FAILURE -> + resourceProvider.getString( + R.string.error_sms_generic_failure, + ) + + SmsManager.RESULT_ERROR_RADIO_OFF -> + resourceProvider.getString( + R.string.error_sms_radio_off, + ) + + SmsManager.RESULT_ERROR_NO_SERVICE -> + resourceProvider.getString( + R.string.error_sms_no_service, + ) + + SmsManager.RESULT_ERROR_LIMIT_EXCEEDED -> + resourceProvider.getString( + R.string.error_sms_limit_exceeded, + ) + + SmsManager.RESULT_NETWORK_REJECT -> + resourceProvider.getString( + R.string.error_sms_network_reject, + ) + + SmsManager.RESULT_NO_MEMORY -> + resourceProvider.getString( + R.string.error_sms_no_memory, + ) + + SmsManager.RESULT_INVALID_SMS_FORMAT -> + resourceProvider.getString( + R.string.error_sms_invalid_format, + ) + + SmsManager.RESULT_NETWORK_ERROR -> + resourceProvider.getString( + R.string.error_sms_network_error, + ) + + SmsManager.RESULT_SMS_BLOCKED_DURING_EMERGENCY -> + resourceProvider.getString( + R.string.error_sms_blocked_during_emergency, + ) + + SmsManager.RESULT_RIL_SIM_ABSENT -> + resourceProvider.getString( + R.string.error_sms_no_sim, + ) + + else -> + resourceProvider.getString(R.string.error_sms_generic_failure) } + } KMError.CameraInUse -> resourceProvider.getString(R.string.error_camera_in_use) + KMError.CameraError -> resourceProvider.getString(R.string.error_camera_error) + KMError.CameraDisabled -> resourceProvider.getString(R.string.error_camera_disabled) + KMError.CameraDisconnected -> resourceProvider.getString(R.string.error_camera_disconnected) + KMError.MaxCamerasInUse -> resourceProvider.getString(R.string.error_max_cameras_in_use) + KMError.CameraVariableFlashlightStrengthUnsupported -> resourceProvider.getString( R.string.error_variable_flashlight_strength_unsupported, @@ -315,19 +376,25 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { KMError.SwitchImeFailed -> resourceProvider.getString(R.string.error_failed_to_change_ime) + KMError.EnableImeFailed -> resourceProvider.getString(R.string.error_failed_to_enable_ime) + KMError.NoCameraApp -> resourceProvider.getString(R.string.error_no_camera_app) + KMError.NoDeviceAssistant -> resourceProvider.getString(R.string.error_no_device_assistant) + KMError.NoSettingsApp -> resourceProvider.getString(R.string.error_no_settings_app) + KMError.NoAppToOpenUrl -> resourceProvider.getString(R.string.error_no_app_to_open_url) KMError.CantFindSoundFile -> resourceProvider.getString(R.string.error_cant_find_sound_file) + is KMError.CorruptJsonFile -> reason @@ -341,6 +408,7 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { resourceProvider.getString( R.string.error_file_operation_cancelled, ) + is KMError.NoSpaceLeftOnTarget -> resourceProvider.getString( R.string.error_no_space_left_at_target, @@ -349,8 +417,10 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { is KMError.NotADirectory -> resourceProvider.getString(R.string.error_not_a_directory, uri) + is KMError.NotAFile -> resourceProvider.getString(R.string.error_not_a_file, uri) + is KMError.SourceFileNotFound -> resourceProvider.getString( R.string.error_source_file_not_found, @@ -361,10 +431,12 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { resourceProvider.getString( R.string.error_storage_permission_denied, ) + KMError.TargetDirectoryMatchesSourceDirectory -> resourceProvider.getString( R.string.error_matching_source_and_target_paths, ) + is KMError.TargetDirectoryNotFound -> resourceProvider.getString( R.string.error_directory_not_found, @@ -379,18 +451,23 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { KMError.UnknownIOError -> resourceProvider.getString(R.string.error_io_error) + KMError.ShizukuNotStarted -> resourceProvider.getString(R.string.error_shizuku_not_started) + KMError.NoFileName -> resourceProvider.getString(R.string.error_no_file_name) + KMError.CantDetectKeyEventsInPhoneCall -> resourceProvider.getString( R.string.trigger_error_cant_detect_in_phone_call_explanation, ) + KMError.GestureStrokeCountTooHigh -> resourceProvider.getString( R.string.trigger_error_gesture_stroke_count_too_high, ) + KMError.GestureDurationTooHigh -> resourceProvider.getString( R.string.trigger_error_gesture_duration_too_high, @@ -400,17 +477,22 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { resourceProvider.getString( R.string.trigger_error_dpad_ime_not_selected, ) + KMError.InvalidBackup -> resourceProvider.getString(R.string.error_invalid_backup) + KMError.MalformedUrl -> resourceProvider.getString(R.string.error_malformed_url) + KMError.UiElementNotFound -> resourceProvider.getString(R.string.error_ui_element_not_found) + is KMError.ShellCommandTimeout -> resourceProvider.getString( R.string.error_shell_command_timeout, timeoutMillis / 1000, ) + is SystemBridgeError.Disconnected -> resourceProvider.getString( R.string.error_system_bridge_disconnected, @@ -471,6 +553,7 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { resourceProvider.getString( R.string.error_fix_key_event_action, ) + is KMError.KeyMapperSmsRateLimit -> resourceProvider.getString( R.string.error_sms_rate_limit, @@ -479,6 +562,9 @@ fun KMError.getFullMessage(resourceProvider: ResourceProvider): String { is SystemBridgeError.WriteEvdevEventFailed -> resourceProvider.getString(R.string.error_write_evdev_event_failed) + is KMError.MediaActionUnsupported -> + resourceProvider.getString(R.string.error_media_action_unsupported) + else -> this.toString() } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index 1d70f24dac..0a1c49f101 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -328,34 +328,34 @@ https://play.google.com/store/apps/details?id=io.github.sds100.keymapper https://github.com/keymapperorg/KeyMapper/blob/master/LICENSE.md https://github.com/keymapperorg/KeyMapper/blob/develop/CHANGELOG.md - https://docs.keymapper.club/contributing/#translating?utm_source=in_app + https://keymapper.app/contributing/#translating?utm_source=in_app https://github.com/keymapperorg/KeyMapper/blob/master/PRIVACY_POLICY.md https://discord.gg/Suj6nyw - https://docs.keymapper.club/redirects/trigger-by-intent - https://docs.keymapper.club/redirects/trigger - https://docs.keymapper.club/redirects/trigger-options - https://docs.keymapper.club/redirects/action - https://docs.keymapper.club/redirects/constraints - https://docs.keymapper.club/redirects/fingerprint-map-options - https://docs.keymapper.club/redirects/quick-start - https://docs.keymapper.club/redirects/faq + https://keymapper.app/redirects/trigger-by-intent + https://keymapper.app/redirects/trigger + https://keymapper.app/redirects/trigger-options + https://keymapper.app/redirects/action + https://keymapper.app/redirects/constraints + https://keymapper.app/redirects/fingerprint-map-options + https://keymapper.app/redirects/quick-start + https://keymapper.app/redirects/faq https://dontkillmyapp.com - https://docs.keymapper.club/redirects/keymap-action-options - https://docs.keymapper.club/redirects/trigger-key-options - https://docs.keymapper.club/redirects/android-11-device-id-bug-work-around - https://docs.keymapper.club/redirects/cant-find-accessibility-settings - https://docs.keymapper.club/redirects/restricted-settings - https://docs.keymapper.club/redirects/shizuku-benefits - https://docs.keymapper.club/redirects/settings + https://keymapper.app/redirects/keymap-action-options + https://keymapper.app/redirects/trigger-key-options + https://keymapper.app/redirects/android-11-device-id-bug-work-around + https://keymapper.app/redirects/cant-find-accessibility-settings + https://keymapper.app/redirects/restricted-settings + https://keymapper.app/redirects/shizuku-benefits + https://keymapper.app/redirects/settings https://developer.android.com/reference/android/content/Intent#setFlags(int) https://github.com/keymapperorg/KeyMapper https://play.google.com/store/apps/details?id=io.github.sds100.keymapper - https://docs.keymapper.club?utm_source=in_app - https://docs.keymapper.club/redirects/advanced-triggers - https://docs.keymapper.club/redirects/assistant-trigger - https://docs.keymapper.club/redirects/floating-buttons - https://docs.keymapper.club/redirects/floating-layouts - https://docs.keymapper.club/redirects/floating-button-config + https://keymapper.app?utm_source=in_app + https://keymapper.app/redirects/advanced-triggers + https://keymapper.app/redirects/assistant-trigger + https://keymapper.app/redirects/floating-buttons + https://keymapper.app/redirects/floating-layouts + https://keymapper.app/redirects/floating-button-config https://github.com/keymapperorg/KeyMapper/issues/new/choose https://youtube.com/shorts/v7l2JYP14L0?feature=share @@ -408,6 +408,7 @@ Add action Tap to record trigger Record with Expert Mode + Enable Expert Mode NEW! Done Fix @@ -444,6 +445,8 @@ There is a timeout to input this trigger. You can change this timeout in the "Options" tab. How to use this trigger Sequence triggers + Trigger when screen is off + This key map cannot be detected when the screen is off because the trigger was not recorded with Expert Mode. Re-record the trigger with Expert Mode to enable screen-off remapping. Android doesn\'t allow apps to get a list of connected (not paired) Bluetooth devices. Apps can only detect when they are connected and disconnected. So if your Bluetooth device is already connected to your device when the accessibility service starts, you will have to reconnect it for the app to know it is connected. Automatic backup Change location or turn off automatic back up? @@ -896,6 +899,7 @@ Command timed out after %1$d seconds Expert Mode needs starting Rate limit reached. You can only send once per second. + Unsupported by this app @@ -982,12 +986,10 @@ Fast forward Fast forward for an app Fast forward for %s - Not all media apps support fast forwarding. E.g Google Play Music. Rewind Rewind for an app Rewind for %s - Not all media apps support rewinding. E.g Google Play Music. Stop media Stop media for an app 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 7b12c77b24..0e9f9f9f52 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 @@ -27,6 +27,7 @@ abstract class KMError : KMResult() { data object EmptyText : KMError() data object NoIncompatibleKeyboardsInstalled : KMError() data object NoMediaSessions : KMError() + data object MediaActionUnsupported : KMError() data object BackupVersionTooNew : KMError() data object LauncherShortcutsNotSupported : KMError() diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 6863b48341..476e968105 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -46,10 +46,10 @@ It will NOT collect any user data or connect to the internet to send any data an Our accessibility service is only triggered by the user when pressing a physical key on their device. It can be turned off any time by the user in the system accessibility settings. Come say hi in our Discord community! -www.keymapper.club +discord.keymapper.app See the code for yourself! (Open source) -code.keymapper.club +github.com/keymapperorg/KeyMapper Read the documentation: -docs.keymapper.club \ No newline at end of file +keymapper.app \ No newline at end of file diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbKey.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbKey.kt index 9e315389b3..9421319938 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbKey.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbKey.kt @@ -45,7 +45,6 @@ import rikka.core.ktx.unsafeLazy private const val TAG = "AdbKey" -@RequiresApi(Build.VERSION_CODES.M) internal class AdbKey(private val adbKeyStore: AdbKeyStore, name: String) { companion object { diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbManager.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbManager.kt index 6a9b938788..dbc48555be 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbManager.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/adb/AdbManager.kt @@ -76,11 +76,7 @@ class AdbManagerImpl @Inject constructor(@ApplicationContext private val ctx: Co override suspend fun pair(code: String): KMResult { return pairMutex.withLock { - val port = adbPairMdns.discoverPort() - - if (port == null) { - return@withLock AdbError.ServerNotFound - } + val port = adbPairMdns.discoverPort() ?: return@withLock AdbError.ServerNotFound return@withLock getAdbKey().then { key -> val pairingClient = AdbPairingClient(LOCALHOST, port, code, key) diff --git a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt index 7e1a6dc013..98a6cdefd9 100644 --- a/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt +++ b/sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/manager/SystemBridgeConnectionManager.kt @@ -257,16 +257,26 @@ class SystemBridgeConnectionManagerImpl @Inject constructor( } @SuppressLint("ObsoleteSdkInt") -@RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) interface SystemBridgeConnectionManager { + // Do not require min API to check the state. val connectionState: StateFlow + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) fun run(block: (ISystemBridge) -> T): KMResult + + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) fun stopSystemBridge() + + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) fun restartSystemBridge() + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) suspend fun startWithRoot() + + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) fun startWithShizuku() + + @RequiresApi(Constants.SYSTEM_BRIDGE_MIN_API) suspend fun startWithAdb() } diff --git a/system/src/main/AndroidManifest.xml b/system/src/main/AndroidManifest.xml index 0551dc8037..5825ce9263 100644 --- a/system/src/main/AndroidManifest.xml +++ b/system/src/main/AndroidManifest.xml @@ -22,7 +22,8 @@ - @@ -108,9 +109,9 @@ + android:theme="@android:style/Theme.NoDisplay" /> \ No newline at end of file diff --git a/system/src/main/java/io/github/sds100/keymapper/system/media/AndroidMediaAdapter.kt b/system/src/main/java/io/github/sds100/keymapper/system/media/AndroidMediaAdapter.kt index d7461da2a3..0c8e6458e8 100644 --- a/system/src/main/java/io/github/sds100/keymapper/system/media/AndroidMediaAdapter.kt +++ b/system/src/main/java/io/github/sds100/keymapper/system/media/AndroidMediaAdapter.kt @@ -1,25 +1,26 @@ package io.github.sds100.keymapper.system.media +import android.content.ComponentName import android.content.Context import android.media.AudioAttributes import android.media.AudioManager import android.media.AudioPlaybackConfiguration import android.media.MediaPlayer import android.media.session.MediaController +import android.media.session.MediaSessionManager import android.media.session.PlaybackState -import android.net.Uri -import android.os.Build -import android.view.KeyEvent -import androidx.annotation.RequiresApi import androidx.core.content.getSystemService +import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult import io.github.sds100.keymapper.common.utils.Success +import io.github.sds100.keymapper.system.notifications.NotificationReceiver import io.github.sds100.keymapper.system.volume.VolumeStream import java.io.FileNotFoundException import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.max import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -29,25 +30,33 @@ import kotlinx.coroutines.launch @Singleton class AndroidMediaAdapter @Inject constructor( - @ApplicationContext private val context: Context, + @ApplicationContext private val ctx: Context, private val coroutineScope: CoroutineScope, ) : MediaAdapter { - private val ctx = context.applicationContext + companion object { + /** + * How many milliseconds to skip when seeking forward or backward. + */ + private const val SEEK_AMOUNT = 30000L + } + private val mediaSessionManager: MediaSessionManager by lazy { ctx.getSystemService()!! } private val audioManager: AudioManager by lazy { ctx.getSystemService()!! } + private val notificationListenerComponent by lazy { + ComponentName( + ctx, + NotificationReceiver::class.java, + ) + } + private val activeMediaSessions: MutableStateFlow> = MutableStateFlow(emptyList()) private val audioVolumeControlStreams: MutableStateFlow> = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - MutableStateFlow(getActiveAudioVolumeStreams()) - } else { - MutableStateFlow(emptySet()) - } + MutableStateFlow(getActiveAudioVolumeStreams()) - private val audioPlaybackCallback by lazy { - @RequiresApi(Build.VERSION_CODES.O) + private val audioPlaybackCallback = object : AudioManager.AudioPlaybackCallback() { override fun onPlaybackConfigChanged( configs: MutableList?, @@ -55,75 +64,148 @@ class AndroidMediaAdapter @Inject constructor( audioVolumeControlStreams.update { getActiveAudioVolumeStreams() } } } - } private var mediaPlayerLock = Any() private var mediaPlayer: MediaPlayer? = null init { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - coroutineScope.launch { - audioVolumeControlStreams.subscriptionCount.collect { count -> - if (count == 0) { - audioManager.unregisterAudioPlaybackCallback(audioPlaybackCallback) - } else { - audioManager.registerAudioPlaybackCallback(audioPlaybackCallback, null) - } + coroutineScope.launch { + audioVolumeControlStreams.subscriptionCount.collect { count -> + if (count == 0) { + audioManager.unregisterAudioPlaybackCallback(audioPlaybackCallback) + } else { + audioManager.registerAudioPlaybackCallback(audioPlaybackCallback, null) } } } } - override fun fastForward(packageName: String?): KMResult<*> = - sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, packageName) + override fun fastForward(packageName: String?): KMResult<*> { + val session = getPackageMediaSession(packageName) ?: return KMError.NoMediaSessions - override fun rewind(packageName: String?): KMResult<*> = - sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_REWIND, packageName) + if (session.isPlaybackActionSupported(PlaybackState.ACTION_FAST_FORWARD)) { + session.transportControls.fastForward() + return Success(Unit) + } else { + return KMError.MediaActionUnsupported + } + } - override fun play(packageName: String?): KMResult<*> = - sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY, packageName) + override fun rewind(packageName: String?): KMResult<*> { + val session = getPackageMediaSession(packageName) ?: return KMError.NoMediaSessions + + if (session.isPlaybackActionSupported(PlaybackState.ACTION_REWIND)) { + session.transportControls.rewind() + return Success(Unit) + } else { + return KMError.MediaActionUnsupported + } + } - override fun pause(packageName: String?): KMResult<*> = - sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PAUSE, packageName) + override fun play(packageName: String?): KMResult<*> { + val session = getPackageMediaSession(packageName) ?: return KMError.NoMediaSessions - override fun playPause(packageName: String?): KMResult<*> = - sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, packageName) + if (session.isPlaybackActionSupported(PlaybackState.ACTION_PLAY)) { + session.transportControls.play() + return Success(Unit) + } else { + return KMError.MediaActionUnsupported + } + } - override fun previousTrack(packageName: String?): KMResult<*> = - sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_PREVIOUS, packageName) + override fun pause(packageName: String?): KMResult<*> { + val session = getPackageMediaSession(packageName) ?: return KMError.NoMediaSessions - override fun nextTrack(packageName: String?): KMResult<*> = - sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_NEXT, packageName) + if (session.isPlaybackActionSupported(PlaybackState.ACTION_PAUSE)) { + session.transportControls.pause() + return Success(Unit) + } else { + return KMError.MediaActionUnsupported + } + } - override fun stop(packageName: String?): KMResult<*> = - sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_STOP, packageName) + override fun playPause(packageName: String?): KMResult<*> { + val session = getPackageMediaSession(packageName) ?: return KMError.NoMediaSessions - override fun stopFileMedia(): KMResult<*> { - synchronized(mediaPlayerLock) { - mediaPlayer?.stop() - mediaPlayer?.release() - mediaPlayer = null + if (session.isPlaybackActionSupported(PlaybackState.ACTION_PLAY_PAUSE)) { + when (session.playbackState?.state) { + PlaybackState.STATE_PLAYING -> session.transportControls.pause() + PlaybackState.STATE_PAUSED -> session.transportControls.play() + else -> {} + } + return Success(Unit) + } else { + return KMError.MediaActionUnsupported } + } - return Success(Unit) + override fun previousTrack(packageName: String?): KMResult<*> { + val session = getPackageMediaSession(packageName) ?: return KMError.NoMediaSessions + + if (session.isPlaybackActionSupported(PlaybackState.ACTION_SKIP_TO_PREVIOUS)) { + session.transportControls.skipToPrevious() + return Success(Unit) + } else { + return KMError.MediaActionUnsupported + } + } + + override fun nextTrack(packageName: String?): KMResult<*> { + val session = getPackageMediaSession(packageName) ?: return KMError.NoMediaSessions + + if (session.isPlaybackActionSupported(PlaybackState.ACTION_SKIP_TO_NEXT)) { + session.transportControls.skipToNext() + return Success(Unit) + } else { + return KMError.MediaActionUnsupported + } + } + + override fun stop(packageName: String?): KMResult<*> { + val session = getPackageMediaSession(packageName) ?: return KMError.NoMediaSessions + + if (session.isPlaybackActionSupported(PlaybackState.ACTION_STOP)) { + session.transportControls.stop() + return Success(Unit) + } else { + return KMError.MediaActionUnsupported + } } override fun stepForward(packageName: String?): KMResult<*> { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_STEP_FORWARD, packageName) + val session = getPackageMediaSession(packageName) ?: return KMError.NoMediaSessions + + if (session.isPlaybackActionSupported(PlaybackState.ACTION_SEEK_TO)) { + val position = session.playbackState?.position ?: return KMError.NoMediaSessions + session.transportControls.seekTo(position + SEEK_AMOUNT) + return Success(Unit) } else { - return KMError.SdkVersionTooLow(Build.VERSION_CODES.M) + return KMError.MediaActionUnsupported } } override fun stepBackward(packageName: String?): KMResult<*> { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - sendMediaKeyEvent(KeyEvent.KEYCODE_MEDIA_STEP_BACKWARD, packageName) + val session = getPackageMediaSession(packageName) ?: return KMError.NoMediaSessions + + if (session.isPlaybackActionSupported(PlaybackState.ACTION_SEEK_TO)) { + val position = session.playbackState?.position ?: return KMError.NoMediaSessions + session.transportControls.seekTo(max(0, position - SEEK_AMOUNT)) + return Success(Unit) } else { - return KMError.SdkVersionTooLow(Build.VERSION_CODES.M) + return KMError.MediaActionUnsupported } } + override fun stopFileMedia(): KMResult<*> { + synchronized(mediaPlayerLock) { + mediaPlayer?.stop() + mediaPlayer?.release() + mediaPlayer = null + } + + return Success(Unit) + } + override fun getActiveMediaSessionPackages(): List { return activeMediaSessions.value .filter { it.playbackState?.state == PlaybackState.STATE_PLAYING } @@ -136,7 +218,6 @@ class AndroidMediaAdapter @Inject constructor( .map { it.packageName } } - @RequiresApi(Build.VERSION_CODES.O) override fun getActiveAudioVolumeStreams(): Set { return audioManager.activePlaybackConfigurations .map { it.audioAttributes.volumeControlStream } @@ -158,6 +239,7 @@ class AndroidMediaAdapter @Inject constructor( mediaPlayer = MediaPlayer().apply { val usage = when (stream) { VolumeStream.ACCESSIBILITY -> AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY + else -> throw Exception( "Don't know how to convert volume stream to audio usage attribute", ) @@ -170,7 +252,7 @@ class AndroidMediaAdapter @Inject constructor( .build(), ) - setDataSource(ctx, Uri.parse(uri)) + setDataSource(ctx, uri.toUri()) setOnCompletionListener { synchronized(mediaPlayerLock) { @@ -195,20 +277,22 @@ class AndroidMediaAdapter @Inject constructor( activeMediaSessions.update { mediaSessions } } - private fun sendMediaKeyEvent(keyCode: Int, packageName: String?): KMResult<*> { + private fun MediaController.isPlaybackActionSupported(action: Long): Boolean = + (playbackState?.actions ?: 0) and action != 0L + + private fun getPackageMediaSession(packageName: String?): MediaController? { + val mediaSessions = mediaSessionManager.getActiveSessions(notificationListenerComponent) + if (packageName == null) { - audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) - audioManager.dispatchMediaKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode)) + return mediaSessions.firstOrNull() } else { - for (session in activeMediaSessions.value) { + for (session in mediaSessions) { if (session.packageName == packageName) { - session.dispatchMediaButtonEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) - session.dispatchMediaButtonEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode)) - break + return session } } } - return Success(Unit) + return null } } 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 974b2948c5..0d67e19aac 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 @@ -56,7 +56,9 @@ class NotificationReceiver : NotificationServiceEvent.DismissLastNotification -> cancelNotification( lastNotificationKey, ) + NotificationServiceEvent.DismissAllNotifications -> cancelAllNotifications() + else -> Unit } }.launchIn(lifecycleScope)