diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bbeb134f6..c0872a3650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Changed - #1369 Add content descriptions to drag handles and custom "Move up"/"Move down" accessibility actions for trigger and action list items, improving TalkBack support for reordering. +- #262 Add "TalkBack gesture" action to simulate TalkBack navigation gestures (swipes, multi-finger taps, and multi-directional swipes). ## [4.1.1](https://github.com/sds100/KeyMapper/releases/tag/v4.1.1) diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/ActionData.kt index 3c608e7277..0fbbf4a6fc 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,5 +1,6 @@ package io.github.sds100.keymapper.base.actions +import io.github.sds100.keymapper.base.actions.talkback.TalkBackGestureType import io.github.sds100.keymapper.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.NodeInteractionType import io.github.sds100.keymapper.common.utils.Orientation @@ -95,6 +96,7 @@ sealed class ActionData : Comparable { { it.showVolumeUi }, { it.volumeStream }, ) + else -> super.compareTo(other) } } @@ -111,6 +113,7 @@ sealed class ActionData : Comparable { { it.showVolumeUi }, { it.volumeStream }, ) + else -> super.compareTo(other) } } @@ -1040,6 +1043,17 @@ sealed class ActionData : Comparable { { it.settingKey }, { it.value }, ) + + else -> super.compareTo(other) + } + } + + @Serializable + data class TalkBackGesture(val gesture: TalkBackGestureType) : ActionData() { + override val id: ActionId = ActionId.TALKBACK_GESTURE + + override fun compareTo(other: ActionData) = when (other) { + is TalkBackGesture -> gesture.compareTo(other.gesture) else -> super.compareTo(other) } } 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 b8caede87b..1a84d7c59f 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 @@ -2,6 +2,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.common.models.ShellExecutionMode import io.github.sds100.keymapper.common.utils.KMError import io.github.sds100.keymapper.common.utils.KMResult @@ -69,6 +70,7 @@ object ActionDataEntityMapper { ActionEntity.Type.MODIFY_SETTING -> ActionId.MODIFY_SETTING ActionEntity.Type.CREATE_NOTIFICATION -> ActionId.CREATE_NOTIFICATION + ActionEntity.Type.TOAST -> ActionId.TOAST } @@ -874,6 +876,20 @@ object ActionDataEntityMapper { ActionId.CLEAR_RECENT_APP -> ActionData.ClearRecentApp + ActionId.TALKBACK_GESTURE -> { + val gestureTypeString = + entity.extras.getData(ActionEntity.EXTRA_TALKBACK_GESTURE_TYPE) + .valueOrNull() ?: return null + + val gestureType = try { + TalkBackGestureType.valueOf(gestureTypeString) + } catch (_: IllegalArgumentException) { + return null + } + + ActionData.TalkBackGesture(gesture = gestureType) + } + ActionId.MODIFY_SETTING -> { val value = entity.extras.getData(ActionEntity.EXTRA_SETTING_VALUE) .valueOrNull() ?: return null @@ -1324,6 +1340,10 @@ object ActionDataEntityMapper { EntityExtra(ActionEntity.EXTRA_TOAST_DURATION, data.duration.name), ) + is ActionData.TalkBackGesture -> listOf( + EntityExtra(ActionEntity.EXTRA_TALKBACK_GESTURE_TYPE, data.gesture.name), + ) + else -> emptyList() } @@ -1510,5 +1530,7 @@ object ActionDataEntityMapper { ActionId.CLEAR_RECENT_APP to "clear_recent_app", ActionId.MODIFY_SETTING to "modify_setting", + + ActionId.TALKBACK_GESTURE to "talkback_gesture", ) } 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 6c3e5c78ed..4dcc63aa74 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 @@ -265,6 +265,10 @@ class LazyActionErrorSnapshot( } } + is ActionData.TalkBackGesture -> { + return getAppError(TALKBACK_PACKAGE_NAME) + } + else -> {} } @@ -317,3 +321,5 @@ interface ActionErrorSnapshot { fun getError(action: ActionData): KMError? fun getErrors(actions: List): Map } + +private const val TALKBACK_PACKAGE_NAME = "com.google.android.marvin.talkback" 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 63f4e62f07..d7f3694432 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 @@ -161,4 +161,6 @@ enum class ActionId { CLEAR_RECENT_APP, MODIFY_SETTING, + + TALKBACK_GESTURE, } 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 fe8323f74d..bfa3840b36 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 @@ -5,6 +5,7 @@ import android.view.KeyEvent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Android import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.talkback.TalkBackGestureStrings import io.github.sds100.keymapper.base.keymaps.KeyMap import io.github.sds100.keymapper.base.utils.DndModeStrings import io.github.sds100.keymapper.base.utils.KeyCodeStrings @@ -209,22 +210,31 @@ class ActionUiHelper( 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 } @@ -235,22 +245,31 @@ class ActionUiHelper( val resId = when (action) { is ActionData.ControlMediaForApp.Play -> R.string.action_play_media_package + is ActionData.ControlMediaForApp.FastForward -> R.string.action_fast_forward_package + is ActionData.ControlMediaForApp.NextTrack -> R.string.action_next_track_package + is ActionData.ControlMediaForApp.Pause -> R.string.action_pause_media_package + is ActionData.ControlMediaForApp.PlayPause -> R.string.action_play_pause_media_package + is ActionData.ControlMediaForApp.PreviousTrack -> R.string.action_previous_track_package + is ActionData.ControlMediaForApp.Rewind -> R.string.action_rewind_package + is ActionData.ControlMediaForApp.Stop -> R.string.action_stop_media_package + is ActionData.ControlMediaForApp.StepForward -> R.string.action_step_forward_media_package + is ActionData.ControlMediaForApp.StepBackward -> R.string.action_step_backward_media_package } @@ -448,7 +467,9 @@ class ActionUiHelper( } is ActionData.Text -> getString(R.string.description_text_block, action.text) + is ActionData.Url -> getString(R.string.description_url, action.url) + is ActionData.Sound.SoundFile -> getString( R.string.description_sound, action.soundDescription, @@ -462,57 +483,87 @@ class ActionUiHelper( } ActionData.AirplaneMode.Disable -> getString(R.string.action_disable_airplane_mode) + ActionData.AirplaneMode.Enable -> getString(R.string.action_enable_airplane_mode) + ActionData.AirplaneMode.Toggle -> getString(R.string.action_toggle_airplane_mode) ActionData.Bluetooth.Disable -> getString(R.string.action_disable_bluetooth) + ActionData.Bluetooth.Enable -> getString(R.string.action_enable_bluetooth) + ActionData.Bluetooth.Toggle -> getString(R.string.action_toggle_bluetooth) ActionData.Brightness.Decrease -> getString(R.string.action_decrease_brightness) + ActionData.Brightness.DisableAuto -> getString(R.string.action_disable_auto_brightness) + ActionData.Brightness.EnableAuto -> getString(R.string.action_enable_auto_brightness) + ActionData.Brightness.Increase -> getString(R.string.action_increase_brightness) + ActionData.Brightness.ToggleAuto -> getString(R.string.action_toggle_auto_brightness) ActionData.NightShift.Disable -> getString(R.string.action_disable_night_shift) + ActionData.NightShift.Enable -> getString(R.string.action_enable_night_shift) + ActionData.NightShift.Toggle -> getString(R.string.action_toggle_night_shift) ActionData.ConsumeKeyEvent -> getString(R.string.action_consume_keyevent) ActionData.ControlMedia.FastForward -> getString(R.string.action_fast_forward) + ActionData.ControlMedia.NextTrack -> getString(R.string.action_next_track) + ActionData.ControlMedia.Pause -> getString(R.string.action_pause_media) + ActionData.ControlMedia.Play -> getString(R.string.action_play_media) + ActionData.ControlMedia.PlayPause -> getString(R.string.action_play_pause_media) + ActionData.ControlMedia.PreviousTrack -> getString(R.string.action_previous_track) + ActionData.ControlMedia.Rewind -> getString(R.string.action_rewind) + ActionData.ControlMedia.Stop -> getString(R.string.action_stop_media) + ActionData.ControlMedia.StepForward -> getString(R.string.action_step_forward_media) + ActionData.ControlMedia.StepBackward -> getString(R.string.action_step_backward_media) ActionData.CopyText -> getString(R.string.action_text_copy) + ActionData.CutText -> getString(R.string.action_text_cut) + ActionData.PasteText -> getString(R.string.action_text_paste) ActionData.DeviceAssistant -> getString(R.string.action_open_device_assistant) ActionData.GoBack -> getString(R.string.action_go_back) + ActionData.GoHome -> getString(R.string.action_go_home) + ActionData.GoLastApp -> getString(R.string.action_go_last_app) + ActionData.OpenMenu -> getString(R.string.action_open_menu) + ActionData.OpenRecents -> getString(R.string.action_open_recents) ActionData.HideKeyboard -> getString(R.string.action_hide_keyboard) + ActionData.LockDevice -> getString(R.string.action_lock_device) ActionData.MobileData.Disable -> getString(R.string.action_disable_mobile_data) + ActionData.MobileData.Enable -> getString(R.string.action_enable_mobile_data) + ActionData.MobileData.Toggle -> getString(R.string.action_toggle_mobile_data) ActionData.Hotspot.Disable -> getString(R.string.action_disable_hotspot) + ActionData.Hotspot.Enable -> getString(R.string.action_enable_hotspot) + ActionData.Hotspot.Toggle -> getString(R.string.action_toggle_hotspot) is ActionData.MoveCursor -> { @@ -522,15 +573,19 @@ class ActionUiHelper( ActionData.MoveCursor.Type.CHAR -> getString( R.string.action_move_cursor_prev_character, ) + ActionData.MoveCursor.Type.WORD -> getString( R.string.action_move_cursor_start_word, ) + ActionData.MoveCursor.Type.LINE -> getString( R.string.action_move_cursor_start_line, ) + ActionData.MoveCursor.Type.PARAGRAPH -> getString( R.string.action_move_cursor_start_paragraph, ) + ActionData.MoveCursor.Type.PAGE -> getString( R.string.action_move_cursor_start_page, ) @@ -542,15 +597,19 @@ class ActionUiHelper( ActionData.MoveCursor.Type.CHAR -> getString( R.string.action_move_cursor_next_character, ) + ActionData.MoveCursor.Type.WORD -> getString( R.string.action_move_cursor_end_word, ) + ActionData.MoveCursor.Type.LINE -> getString( R.string.action_move_cursor_end_line, ) + ActionData.MoveCursor.Type.PARAGRAPH -> getString( R.string.action_move_cursor_end_paragraph, ) + ActionData.MoveCursor.Type.PAGE -> getString( R.string.action_move_cursor_end_page, ) @@ -560,10 +619,13 @@ class ActionUiHelper( } ActionData.Nfc.Disable -> getString(R.string.action_nfc_disable) + ActionData.Nfc.Enable -> getString(R.string.action_nfc_enable) + ActionData.Nfc.Toggle -> getString(R.string.action_nfc_toggle) ActionData.OpenCamera -> getString(R.string.action_open_camera) + ActionData.OpenSettings -> getString(R.string.action_open_settings) is ActionData.Rotation.CycleRotations -> { @@ -583,48 +645,73 @@ class ActionUiHelper( } ActionData.Rotation.DisableAuto -> getString(R.string.action_disable_auto_rotate) + ActionData.Rotation.EnableAuto -> getString(R.string.action_enable_auto_rotate) + ActionData.Rotation.Landscape -> getString(R.string.action_landscape_mode) + ActionData.Rotation.Portrait -> getString(R.string.action_portrait_mode) + ActionData.Rotation.SwitchOrientation -> getString(R.string.action_switch_orientation) + ActionData.Rotation.ToggleAuto -> getString(R.string.action_toggle_auto_rotate) ActionData.ScreenOnOff -> getString(R.string.action_power_on_off_device) + ActionData.Screenshot -> getString(R.string.action_screenshot) + ActionData.SecureLock -> getString(R.string.action_secure_lock_device) + ActionData.SelectWordAtCursor -> getString(R.string.action_select_word_at_cursor) + ActionData.SelectAllText -> getString(R.string.action_select_all_text) + ActionData.ShowKeyboard -> getString(R.string.action_show_keyboard) + ActionData.ShowKeyboardPicker -> getString(R.string.action_show_keyboard_picker) + ActionData.PerformImeAction -> getString(R.string.action_perform_ime_action) + ActionData.ShowPowerMenu -> getString(R.string.action_show_power_menu) ActionData.StatusBar.Collapse -> getString(R.string.action_collapse_status_bar) + ActionData.StatusBar.ExpandNotifications -> getString( R.string.action_expand_notification_drawer, ) + 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.ToggleKeyboard -> getString(R.string.action_toggle_keyboard) + ActionData.ToggleSplitScreen -> getString(R.string.action_toggle_split_screen) + ActionData.VoiceAssistant -> getString(R.string.action_open_assistant) ActionData.Wifi.Disable -> getString(R.string.action_disable_wifi) + ActionData.Wifi.Enable -> getString(R.string.action_enable_wifi) + ActionData.Wifi.Toggle -> getString(R.string.action_toggle_wifi) + ActionData.DismissAllNotifications -> getString(R.string.action_dismiss_all_notifications) + ActionData.DismissLastNotification -> getString( R.string.action_dismiss_most_recent_notification, ) ActionData.AnswerCall -> getString(R.string.action_answer_call) + ActionData.EndCall -> getString(R.string.action_end_call) ActionData.DeviceControls -> getString(R.string.action_device_controls) + is ActionData.HttpRequest -> action.description is ActionData.ShellCommand -> when (action.executionMode) { @@ -647,7 +734,9 @@ class ActionUiHelper( is ActionData.InteractUiElement -> action.description ActionData.ClearRecentApp -> getString(R.string.action_clear_recent_app) + ActionData.ForceStopApp -> getString(R.string.action_force_stop_app) + is ActionData.ComposeSms -> getString( R.string.action_compose_sms_description, arrayOf(action.message, action.number), @@ -659,7 +748,9 @@ class ActionUiHelper( ) ActionData.Microphone.Mute -> getString(R.string.action_mute_microphone) + ActionData.Microphone.Toggle -> getString(R.string.action_toggle_mute_microphone) + ActionData.Microphone.Unmute -> getString(R.string.action_unmute_microphone) is ActionData.ModifySetting -> { @@ -681,11 +772,17 @@ class ActionUiHelper( ActionData.Toast.Duration.SHORT -> { getString(R.string.action_toast_description_short, action.message) } + ActionData.Toast.Duration.LONG -> { getString(R.string.action_toast_description_long, action.message) } } } + + is ActionData.TalkBackGesture -> { + val actionLabel = getString(TalkBackGestureStrings.getActionLabel(action.gesture)) + getString(R.string.action_talkback_gesture_formatted, actionLabel) + } } fun getIcon(action: ActionData): ComposeIconInfo = when (action) { @@ -743,7 +840,9 @@ class ActionUiHelper( ) is ActionData.Text -> null + is ActionData.Url -> null + is ActionData.Sound -> IconInfo( getDrawable(R.drawable.ic_outline_volume_up_24), TintType.OnSurface, @@ -764,7 +863,10 @@ class ActionUiHelper( val repeatLimit = when { action.repeatLimit != null -> action.repeatLimit - action.repeatMode == RepeatMode.LIMIT_REACHED -> 1 // and is null + + action.repeatMode == RepeatMode.LIMIT_REACHED -> 1 + + // and is null else -> null } 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 8707fa2c9a..becac27282 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 @@ -13,6 +13,7 @@ import androidx.compose.material.icons.automirrored.outlined.Undo import androidx.compose.material.icons.automirrored.outlined.VolumeDown import androidx.compose.material.icons.automirrored.outlined.VolumeMute import androidx.compose.material.icons.automirrored.outlined.VolumeUp +import androidx.compose.material.icons.outlined.Accessibility import androidx.compose.material.icons.outlined.AirplanemodeActive import androidx.compose.material.icons.outlined.AirplanemodeInactive import androidx.compose.material.icons.outlined.Assistant @@ -248,6 +249,7 @@ object ActionUtils { ActionId.CLEAR_RECENT_APP -> ActionCategory.APPS ActionId.MODIFY_SETTING -> ActionCategory.APPS ActionId.CONSUME_KEY_EVENT -> ActionCategory.SPECIAL + ActionId.TALKBACK_GESTURE -> ActionCategory.INTERFACE } @StringRes @@ -519,6 +521,8 @@ object ActionUtils { ActionId.ENABLE_HOTSPOT -> R.string.action_enable_hotspot ActionId.DISABLE_HOTSPOT -> R.string.action_disable_hotspot + + ActionId.TALKBACK_GESTURE -> R.string.action_talkback_gesture } @DrawableRes @@ -1090,6 +1094,7 @@ object ActionUtils { ActionId.TOGGLE_HOTSPOT -> Icons.Outlined.WifiTethering ActionId.ENABLE_HOTSPOT -> Icons.Outlined.WifiTethering ActionId.DISABLE_HOTSPOT -> Icons.Outlined.WifiTetheringOff + ActionId.TALKBACK_GESTURE -> Icons.Outlined.Accessibility } } @@ -1140,6 +1145,7 @@ fun ActionData.isEditable(): Boolean = when (this) { is ActionData.InteractUiElement, is ActionData.MoveCursor, is ActionData.ModifySetting, + is ActionData.TalkBackGesture, -> true else -> false 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 3ad37eb413..39a36e39af 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 @@ -39,6 +39,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.talkback.PickTalkBackGestureDialog 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.SearchAppBarActions @@ -59,6 +60,7 @@ fun HandleActionBottomSheets(delegate: CreateActionDelegate) { ModifySettingActionBottomSheet(delegate) CreateNotificationActionBottomSheet(delegate) ToastActionBottomSheet(delegate) + PickTalkBackGestureDialog(delegate) } @Composable 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 0f125641dc..9776d3073e 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 @@ -8,6 +8,8 @@ import androidx.compose.runtime.snapshotFlow import io.github.sds100.keymapper.base.R import io.github.sds100.keymapper.base.actions.pinchscreen.PinchPickCoordinateResult import io.github.sds100.keymapper.base.actions.swipescreen.SwipePickCoordinateResult +import io.github.sds100.keymapper.base.actions.talkback.TalkBackGestureDialogState +import io.github.sds100.keymapper.base.actions.talkback.TalkBackGestureType import io.github.sds100.keymapper.base.actions.tapscreen.PickCoordinateResult import io.github.sds100.keymapper.base.system.intents.ConfigIntentResult import io.github.sds100.keymapper.base.utils.DndModeStrings @@ -61,6 +63,7 @@ class CreateActionDelegate( var httpRequestBottomSheetState: ActionData.HttpRequest? by mutableStateOf(null) var smsActionBottomSheetState: SmsActionBottomSheetState? by mutableStateOf(null) var volumeActionState: VolumeActionBottomSheetState? by mutableStateOf(null) + var talkBackGestureDialogState: TalkBackGestureDialogState? by mutableStateOf(null) var modifySettingActionBottomSheetState: ModifySettingActionBottomSheetState? by mutableStateOf(null) var createNotificationActionBottomSheetState: CreateNotificationActionBottomSheetState? @@ -202,6 +205,13 @@ class CreateActionDelegate( actionResult.update { action } } + fun onDoneConfigTalkBackGestureClick() { + talkBackGestureDialogState?.also { state -> + talkBackGestureDialogState = null + actionResult.update { ActionData.TalkBackGesture(state.selectedGesture) } + } + } + fun onDoneConfigVolumeClick() { volumeActionState?.also { state -> val action = when (state.actionId) { @@ -1213,6 +1223,13 @@ class CreateActionDelegate( return null } + + ActionId.TALKBACK_GESTURE -> { + val initialGesture = (oldData as? ActionData.TalkBackGesture)?.gesture + ?: TalkBackGestureType.entries.first() + talkBackGestureDialogState = TalkBackGestureDialogState(initialGesture) + return null + } } } 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 d193ee28fe..eac8372e48 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 @@ -1115,6 +1115,10 @@ class PerformActionsUseCaseImpl @AssistedInject constructor( newValue, ) } + + is ActionData.TalkBackGesture -> { + result = service.performTalkBackGesture(action.gesture) + } } when (result) { diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/PickTalkBackGestureDialog.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/PickTalkBackGestureDialog.kt new file mode 100644 index 0000000000..47dcd83d72 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/PickTalkBackGestureDialog.kt @@ -0,0 +1,184 @@ +package io.github.sds100.keymapper.base.actions.talkback + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.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.actions.CreateActionDelegate +import io.github.sds100.keymapper.base.compose.KeyMapperTheme +import io.github.sds100.keymapper.base.utils.ui.compose.CustomDialog + +data class TalkBackGestureDialogState(val selectedGesture: TalkBackGestureType) + +@Composable +fun PickTalkBackGestureDialog(delegate: CreateActionDelegate) { + val state = delegate.talkBackGestureDialogState ?: return + + var selected by remember(state) { mutableStateOf(state.selectedGesture) } + + PickTalkBackGestureDialog( + selected = selected, + onSelectGesture = { selected = it }, + onDismissRequest = { delegate.talkBackGestureDialogState = null }, + onConfirm = { + delegate.talkBackGestureDialogState = + delegate.talkBackGestureDialogState?.copy(selectedGesture = selected) + delegate.onDoneConfigTalkBackGestureClick() + }, + ) +} + +@Composable +private fun PickTalkBackGestureDialog( + selected: TalkBackGestureType, + onSelectGesture: (TalkBackGestureType) -> Unit, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + val groups = remember { + listOf( + R.string.talkback_gesture_section_1_finger to listOf( + TalkBackGestureType.SWIPE_UP, + TalkBackGestureType.SWIPE_DOWN, + TalkBackGestureType.SWIPE_LEFT, + TalkBackGestureType.SWIPE_RIGHT, + TalkBackGestureType.SWIPE_UP_THEN_DOWN, + TalkBackGestureType.SWIPE_DOWN_THEN_UP, + TalkBackGestureType.SWIPE_LEFT_THEN_RIGHT, + TalkBackGestureType.SWIPE_RIGHT_THEN_LEFT, + TalkBackGestureType.SWIPE_RIGHT_THEN_UP, + ), + R.string.talkback_gesture_section_2_finger to listOf( + TalkBackGestureType.TWO_FINGER_TAP, + TalkBackGestureType.TWO_FINGER_DOUBLE_TAP_HOLD, + TalkBackGestureType.TWO_FINGER_TRIPLE_TAP, + TalkBackGestureType.TWO_FINGER_TRIPLE_TAP_HOLD, + ), + R.string.talkback_gesture_section_3_finger to listOf( + TalkBackGestureType.THREE_FINGER_TAP, + TalkBackGestureType.THREE_FINGER_TAP_HOLD, + TalkBackGestureType.THREE_FINGER_TRIPLE_TAP_HOLD, + TalkBackGestureType.THREE_FINGER_SWIPE_UP, + TalkBackGestureType.THREE_FINGER_SWIPE_DOWN, + ), + R.string.talkback_gesture_section_4_finger to listOf( + TalkBackGestureType.FOUR_FINGER_TAP, + TalkBackGestureType.FOUR_FINGER_DOUBLE_TAP, + TalkBackGestureType.FOUR_FINGER_SWIPE_UP, + TalkBackGestureType.FOUR_FINGER_SWIPE_DOWN, + TalkBackGestureType.FOUR_FINGER_SWIPE_LEFT, + TalkBackGestureType.FOUR_FINGER_SWIPE_RIGHT, + ), + ) + } + + CustomDialog( + title = stringResource(R.string.action_talkback_gesture), + confirmButton = { + Button(onClick = onConfirm) { + Text(stringResource(R.string.pos_done)) + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text(stringResource(R.string.neg_cancel)) + } + }, + onDismissRequest = onDismissRequest, + ) { + LazyColumn { + for ((headerResId, gestures) in groups) { + stickyHeader(key = headerResId) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(headerResId), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + } + items(gestures, key = { it.name }) { gesture -> + TalkBackGestureItem( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + gesture = gesture, + isSelected = selected == gesture, + onSelected = { onSelectGesture(gesture) }, + ) + } + } + } + } +} + +@Composable +private fun TalkBackGestureItem( + modifier: Modifier = Modifier, + gesture: TalkBackGestureType, + isSelected: Boolean, + onSelected: () -> Unit, +) { + Surface(modifier = modifier, shape = MaterialTheme.shapes.medium, color = Color.Transparent) { + Row( + modifier = Modifier + .clickable(onClick = onSelected) + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + RadioButton(selected = isSelected, onClick = null) + + Column(modifier = Modifier.padding(start = 8.dp)) { + Text( + text = stringResource(TalkBackGestureStrings.getActionLabel(gesture)), + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringResource(TalkBackGestureStrings.getGestureLabel(gesture)), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Preview +@Composable +private fun PreviewPickTalkBackGestureDialog() { + KeyMapperTheme { + var selected by remember { mutableStateOf(TalkBackGestureType.SWIPE_UP) } + + PickTalkBackGestureDialog( + selected = selected, + onSelectGesture = { selected = it }, + onDismissRequest = {}, + onConfirm = {}, + ) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureStrings.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureStrings.kt new file mode 100644 index 0000000000..e6956c4f69 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureStrings.kt @@ -0,0 +1,153 @@ +package io.github.sds100.keymapper.base.actions.talkback + +import io.github.sds100.keymapper.base.R + +object TalkBackGestureStrings { + fun getActionLabel(gesture: TalkBackGestureType): Int = when (gesture) { + TalkBackGestureType.SWIPE_UP -> + R.string.talkback_gesture_action_swipe_up + + TalkBackGestureType.SWIPE_DOWN -> + R.string.talkback_gesture_action_swipe_down + + TalkBackGestureType.SWIPE_LEFT -> + R.string.talkback_gesture_action_swipe_left + + TalkBackGestureType.SWIPE_RIGHT -> + R.string.talkback_gesture_action_swipe_right + + TalkBackGestureType.SWIPE_UP_THEN_DOWN -> + R.string.talkback_gesture_action_swipe_up_then_down + + TalkBackGestureType.SWIPE_DOWN_THEN_UP -> + R.string.talkback_gesture_action_swipe_down_then_up + + TalkBackGestureType.SWIPE_LEFT_THEN_RIGHT -> + R.string.talkback_gesture_action_swipe_left_then_right + + TalkBackGestureType.SWIPE_RIGHT_THEN_LEFT -> + R.string.talkback_gesture_action_swipe_right_then_left + + TalkBackGestureType.SWIPE_RIGHT_THEN_UP -> + R.string.talkback_gesture_action_swipe_right_then_up + + TalkBackGestureType.TWO_FINGER_TAP -> + R.string.talkback_gesture_action_two_finger_tap + + TalkBackGestureType.TWO_FINGER_DOUBLE_TAP_HOLD -> + R.string.talkback_gesture_action_two_finger_double_tap_hold + + TalkBackGestureType.TWO_FINGER_TRIPLE_TAP -> + R.string.talkback_gesture_action_two_finger_triple_tap + + TalkBackGestureType.TWO_FINGER_TRIPLE_TAP_HOLD -> + R.string.talkback_gesture_action_two_finger_triple_tap_hold + + TalkBackGestureType.THREE_FINGER_TAP -> + R.string.talkback_gesture_action_three_finger_tap + + TalkBackGestureType.THREE_FINGER_TAP_HOLD -> + R.string.talkback_gesture_action_three_finger_tap_hold + + TalkBackGestureType.THREE_FINGER_TRIPLE_TAP_HOLD -> + R.string.talkback_gesture_action_three_finger_triple_tap_hold + + TalkBackGestureType.THREE_FINGER_SWIPE_UP -> + R.string.talkback_gesture_action_three_finger_swipe_up + + TalkBackGestureType.THREE_FINGER_SWIPE_DOWN -> + R.string.talkback_gesture_action_three_finger_swipe_down + + TalkBackGestureType.FOUR_FINGER_TAP -> + R.string.talkback_gesture_action_four_finger_tap + + TalkBackGestureType.FOUR_FINGER_DOUBLE_TAP -> + R.string.talkback_gesture_action_four_finger_double_tap + + TalkBackGestureType.FOUR_FINGER_SWIPE_UP -> + R.string.talkback_gesture_action_four_finger_swipe_up + + TalkBackGestureType.FOUR_FINGER_SWIPE_DOWN -> + R.string.talkback_gesture_action_four_finger_swipe_down + + TalkBackGestureType.FOUR_FINGER_SWIPE_LEFT -> + R.string.talkback_gesture_action_four_finger_swipe_left + + TalkBackGestureType.FOUR_FINGER_SWIPE_RIGHT -> + R.string.talkback_gesture_action_four_finger_swipe_right + } + + fun getGestureLabel(gesture: TalkBackGestureType): Int = when (gesture) { + TalkBackGestureType.SWIPE_UP -> + R.string.talkback_gesture_name_swipe_up + + TalkBackGestureType.SWIPE_DOWN -> + R.string.talkback_gesture_name_swipe_down + + TalkBackGestureType.SWIPE_LEFT -> + R.string.talkback_gesture_name_swipe_left + + TalkBackGestureType.SWIPE_RIGHT -> + R.string.talkback_gesture_name_swipe_right + + TalkBackGestureType.SWIPE_UP_THEN_DOWN -> + R.string.talkback_gesture_name_swipe_up_then_down + + TalkBackGestureType.SWIPE_DOWN_THEN_UP -> + R.string.talkback_gesture_name_swipe_down_then_up + + TalkBackGestureType.SWIPE_LEFT_THEN_RIGHT -> + R.string.talkback_gesture_name_swipe_left_then_right + + TalkBackGestureType.SWIPE_RIGHT_THEN_LEFT -> + R.string.talkback_gesture_name_swipe_right_then_left + + TalkBackGestureType.SWIPE_RIGHT_THEN_UP -> + R.string.talkback_gesture_name_swipe_right_then_up + + TalkBackGestureType.TWO_FINGER_TAP -> + R.string.talkback_gesture_name_two_finger_tap + + TalkBackGestureType.TWO_FINGER_DOUBLE_TAP_HOLD -> + R.string.talkback_gesture_name_two_finger_double_tap_hold + + TalkBackGestureType.TWO_FINGER_TRIPLE_TAP -> + R.string.talkback_gesture_name_two_finger_triple_tap + + TalkBackGestureType.TWO_FINGER_TRIPLE_TAP_HOLD -> + R.string.talkback_gesture_name_two_finger_triple_tap_hold + + TalkBackGestureType.THREE_FINGER_TAP -> + R.string.talkback_gesture_name_three_finger_tap + + TalkBackGestureType.THREE_FINGER_TAP_HOLD -> + R.string.talkback_gesture_name_three_finger_tap_hold + + TalkBackGestureType.THREE_FINGER_TRIPLE_TAP_HOLD -> + R.string.talkback_gesture_name_three_finger_triple_tap_hold + + TalkBackGestureType.THREE_FINGER_SWIPE_UP -> + R.string.talkback_gesture_name_three_finger_swipe_up + + TalkBackGestureType.THREE_FINGER_SWIPE_DOWN -> + R.string.talkback_gesture_name_three_finger_swipe_down + + TalkBackGestureType.FOUR_FINGER_TAP -> + R.string.talkback_gesture_name_four_finger_tap + + TalkBackGestureType.FOUR_FINGER_DOUBLE_TAP -> + R.string.talkback_gesture_name_four_finger_double_tap + + TalkBackGestureType.FOUR_FINGER_SWIPE_UP -> + R.string.talkback_gesture_name_four_finger_swipe_up + + TalkBackGestureType.FOUR_FINGER_SWIPE_DOWN -> + R.string.talkback_gesture_name_four_finger_swipe_down + + TalkBackGestureType.FOUR_FINGER_SWIPE_LEFT -> + R.string.talkback_gesture_name_four_finger_swipe_left + + TalkBackGestureType.FOUR_FINGER_SWIPE_RIGHT -> + R.string.talkback_gesture_name_four_finger_swipe_right + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureType.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureType.kt new file mode 100644 index 0000000000..db81147d59 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkBackGestureType.kt @@ -0,0 +1,42 @@ +package io.github.sds100.keymapper.base.actions.talkback + +enum class TalkBackGestureType { + // 1-finger swipes + SWIPE_UP, + SWIPE_DOWN, + SWIPE_LEFT, + SWIPE_RIGHT, + + // 1-finger angular swipes (two-direction) + SWIPE_UP_THEN_DOWN, + SWIPE_DOWN_THEN_UP, + SWIPE_LEFT_THEN_RIGHT, + SWIPE_RIGHT_THEN_LEFT, + SWIPE_RIGHT_THEN_UP, + + // 2-finger gestures + TWO_FINGER_TAP, + TWO_FINGER_DOUBLE_TAP_HOLD, + TWO_FINGER_TRIPLE_TAP, + TWO_FINGER_TRIPLE_TAP_HOLD, + + // 3-finger gestures + THREE_FINGER_TAP, + THREE_FINGER_TAP_HOLD, + THREE_FINGER_TRIPLE_TAP_HOLD, + THREE_FINGER_SWIPE_UP, + THREE_FINGER_SWIPE_DOWN, + + // 4-finger gestures + FOUR_FINGER_TAP, + FOUR_FINGER_DOUBLE_TAP, + FOUR_FINGER_SWIPE_UP, + FOUR_FINGER_SWIPE_DOWN, + FOUR_FINGER_SWIPE_LEFT, + FOUR_FINGER_SWIPE_RIGHT, + + // NOTE: 4-finger triple tap is not possible. + // Android limits GestureDescription to 10 strokes. + // Four-finger triple-tap would require 12 strokes and is not included in the gesture set + // for this reason. +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkbackGesturePerformer.kt b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkbackGesturePerformer.kt new file mode 100644 index 0000000000..1823e6ab54 --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/actions/talkback/TalkbackGesturePerformer.kt @@ -0,0 +1,350 @@ +package io.github.sds100.keymapper.base.actions.talkback + +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.GestureDescription +import android.os.Handler +import io.github.sds100.keymapper.base.system.accessibility.AccessibilityGestureUtils +import io.github.sds100.keymapper.common.utils.KMError +import io.github.sds100.keymapper.common.utils.KMResult +import io.github.sds100.keymapper.common.utils.Success + +object TalkbackGesturePerformer { + fun performTalkBackGesture( + service: AccessibilityService, + gesture: TalkBackGestureType, + gestureHandler: Handler?, + ): KMResult<*> { + val dm = service.resources.displayMetrics + val cx = dm.widthPixels / 2f + val cy = dm.heightPixels / 2f + // Use 40% of the smaller screen dimension as swipe length + val swipeLen = minOf(dm.widthPixels, dm.heightPixels) * 0.4f + // Finger spacing for multi-finger gestures (pixels) + val fingerSpacing = dm.density * 40f + + val gestureBuilder = GestureDescription.Builder() + + when (gesture) { + TalkBackGestureType.SWIPE_UP -> + gestureBuilder.addStroke( + AccessibilityGestureUtils.buildSwipe( + cx, + cy, + cx, + cy - swipeLen, + 200, + ), + ) + + TalkBackGestureType.SWIPE_DOWN -> + gestureBuilder.addStroke( + AccessibilityGestureUtils.buildSwipe( + cx, + cy, + cx, + cy + swipeLen, + 200, + ), + ) + + TalkBackGestureType.SWIPE_LEFT -> + gestureBuilder.addStroke( + AccessibilityGestureUtils.buildSwipe( + cx, + cy, + cx - swipeLen, + cy, + 200, + ), + ) + + TalkBackGestureType.SWIPE_RIGHT -> + gestureBuilder.addStroke( + AccessibilityGestureUtils.buildSwipe( + cx, + cy, + cx + swipeLen, + cy, + 200, + ), + ) + + TalkBackGestureType.SWIPE_UP_THEN_DOWN -> { + val s1 = AccessibilityGestureUtils.buildChainedSwipe( + cx, + cy, + cx, + cy - swipeLen, + 150, + willContinue = true, + ) + val s2 = s1.continueStroke( + AccessibilityGestureUtils.buildPath( + cx, + cy - swipeLen, + cx, + cy, + ), + 150, + 150, + false, + ) + gestureBuilder.addStroke(s1).addStroke(s2) + } + + TalkBackGestureType.SWIPE_DOWN_THEN_UP -> { + val s1 = AccessibilityGestureUtils.buildChainedSwipe( + cx, + cy, + cx, + cy + swipeLen, + 150, + willContinue = true, + ) + val s2 = s1.continueStroke( + AccessibilityGestureUtils.buildPath( + cx, + cy + swipeLen, + cx, + cy, + ), + 150, + 150, + false, + ) + gestureBuilder.addStroke(s1).addStroke(s2) + } + + TalkBackGestureType.SWIPE_LEFT_THEN_RIGHT -> { + val s1 = AccessibilityGestureUtils.buildChainedSwipe( + cx, + cy, + cx - swipeLen, + cy, + 150, + willContinue = true, + ) + val s2 = s1.continueStroke( + AccessibilityGestureUtils.buildPath( + cx - swipeLen, + cy, + cx, + cy, + ), + 150, + 150, + false, + ) + gestureBuilder.addStroke(s1).addStroke(s2) + } + + TalkBackGestureType.SWIPE_RIGHT_THEN_LEFT -> { + val s1 = AccessibilityGestureUtils.buildChainedSwipe( + cx, + cy, + cx + swipeLen, + cy, + 150, + willContinue = true, + ) + val s2 = s1.continueStroke( + AccessibilityGestureUtils.buildPath( + cx + swipeLen, + cy, + cx, + cy, + ), + 150, + 150, + false, + ) + gestureBuilder.addStroke(s1).addStroke(s2) + } + + TalkBackGestureType.SWIPE_RIGHT_THEN_UP -> { + val s1 = AccessibilityGestureUtils.buildChainedSwipe( + cx, + cy, + cx + swipeLen, + cy, + 150, + willContinue = true, + ) + val s2 = s1.continueStroke( + AccessibilityGestureUtils.buildPath(cx + swipeLen, cy, cx, cy - swipeLen), + 150, + 150, + false, + ) + gestureBuilder.addStroke(s1).addStroke(s2) + } + + TalkBackGestureType.TWO_FINGER_TAP -> + AccessibilityGestureUtils.addMultiFingerTaps( + gestureBuilder, + cx, + cy, + fingerSpacing, + fingerCount = 2, + tapCount = 1, + holdDuration = 50, + ) + + TalkBackGestureType.TWO_FINGER_DOUBLE_TAP_HOLD -> + AccessibilityGestureUtils.addMultiFingerDoubleTapHold( + gestureBuilder, + cx, + cy, + fingerSpacing, + fingerCount = 2, + ) + + TalkBackGestureType.TWO_FINGER_TRIPLE_TAP -> + AccessibilityGestureUtils.addMultiFingerTaps( + gestureBuilder, + cx, + cy, + fingerSpacing, + fingerCount = 2, + tapCount = 3, + holdDuration = 50, + ) + + TalkBackGestureType.TWO_FINGER_TRIPLE_TAP_HOLD -> + AccessibilityGestureUtils.addMultiFingerTripleTapHold( + gestureBuilder, + cx, + cy, + fingerSpacing, + fingerCount = 2, + ) + + TalkBackGestureType.THREE_FINGER_TAP -> + AccessibilityGestureUtils.addMultiFingerTaps( + gestureBuilder, + cx, + cy, + fingerSpacing, + fingerCount = 3, + tapCount = 1, + holdDuration = 50, + ) + + TalkBackGestureType.THREE_FINGER_TAP_HOLD -> + AccessibilityGestureUtils.addMultiFingerTaps( + gestureBuilder, + cx, + cy, + fingerSpacing, + fingerCount = 3, + tapCount = 1, + holdDuration = 600, + ) + + TalkBackGestureType.THREE_FINGER_TRIPLE_TAP_HOLD -> + AccessibilityGestureUtils.addMultiFingerTripleTapHold( + gestureBuilder, + cx, + cy, + fingerSpacing, + fingerCount = 3, + ) + + TalkBackGestureType.THREE_FINGER_SWIPE_UP -> + AccessibilityGestureUtils.addMultiFingerSwipe( + gestureBuilder, + cx, + cy, + cx, + cy - swipeLen, + fingerSpacing, + fingerCount = 3, + ) + + TalkBackGestureType.THREE_FINGER_SWIPE_DOWN -> + AccessibilityGestureUtils.addMultiFingerSwipe( + gestureBuilder, + cx, + cy, + cx, + cy + swipeLen, + fingerSpacing, + fingerCount = 3, + ) + + TalkBackGestureType.FOUR_FINGER_TAP -> + AccessibilityGestureUtils.addMultiFingerTaps( + gestureBuilder, + cx, + cy, + fingerSpacing, + fingerCount = 4, + tapCount = 1, + holdDuration = 50, + ) + + TalkBackGestureType.FOUR_FINGER_DOUBLE_TAP -> + AccessibilityGestureUtils.addMultiFingerTaps( + gestureBuilder, + cx, + cy, + fingerSpacing, + fingerCount = 4, + tapCount = 2, + holdDuration = 50, + ) + + TalkBackGestureType.FOUR_FINGER_SWIPE_UP -> + AccessibilityGestureUtils.addMultiFingerSwipe( + gestureBuilder, + cx, + cy, + cx, + cy - swipeLen, + fingerSpacing, + fingerCount = 4, + ) + + TalkBackGestureType.FOUR_FINGER_SWIPE_DOWN -> + AccessibilityGestureUtils.addMultiFingerSwipe( + gestureBuilder, + cx, + cy, + cx, + cy + swipeLen, + fingerSpacing, + fingerCount = 4, + ) + + TalkBackGestureType.FOUR_FINGER_SWIPE_LEFT -> + AccessibilityGestureUtils.addMultiFingerSwipe( + gestureBuilder, + cx, + cy, + cx - swipeLen, + cy, + fingerSpacing, + fingerCount = 4, + ) + + TalkBackGestureType.FOUR_FINGER_SWIPE_RIGHT -> + AccessibilityGestureUtils.addMultiFingerSwipe( + gestureBuilder, + cx, + cy, + cx + swipeLen, + cy, + fingerSpacing, + fingerCount = 4, + ) + } + + val success = service.dispatchGesture(gestureBuilder.build(), null, gestureHandler) + + return if (success) { + Success(Unit) + } else { + KMError.FailedToDispatchGesture + } + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityGestureUtils.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityGestureUtils.kt new file mode 100644 index 0000000000..2dbdda4b0b --- /dev/null +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/AccessibilityGestureUtils.kt @@ -0,0 +1,105 @@ +package io.github.sds100.keymapper.base.system.accessibility + +import android.accessibilityservice.GestureDescription +import android.accessibilityservice.GestureDescription.StrokeDescription +import android.graphics.Path + +object AccessibilityGestureUtils { + + fun buildSwipe(x1: Float, y1: Float, x2: Float, y2: Float, duration: Long): StrokeDescription = + StrokeDescription(buildPath(x1, y1, x2, y2), 0, duration) + + fun buildChainedSwipe( + x1: Float, + y1: Float, + x2: Float, + y2: Float, + duration: Long, + willContinue: Boolean, + ): StrokeDescription = StrokeDescription(buildPath(x1, y1, x2, y2), 0, duration, willContinue) + + fun fingerPositions( + cx: Float, + cy: Float, + spacing: Float, + count: Int, + ): List> { + val total = (count - 1) * spacing + val start = cx - total / 2f + return (0 until count).map { i -> Pair(start + i * spacing, cy) } + } + + fun addMultiFingerTaps( + builder: GestureDescription.Builder, + cx: Float, + cy: Float, + spacing: Float, + fingerCount: Int, + tapCount: Int, + holdDuration: Long, + ) { + val positions = fingerPositions(cx, cy, spacing, fingerCount) + val tapInterval = 200L + for (tap in 0 until tapCount) { + val startTime = tap * tapInterval + for ((x, y) in positions) { + val path = Path().apply { moveTo(x, y) } + builder.addStroke(StrokeDescription(path, startTime, holdDuration)) + } + } + } + + fun addMultiFingerDoubleTapHold( + builder: GestureDescription.Builder, + cx: Float, + cy: Float, + spacing: Float, + fingerCount: Int, + ) { + val positions = fingerPositions(cx, cy, spacing, fingerCount) + for ((x, y) in positions) { + val path = Path().apply { moveTo(x, y) } + builder.addStroke(StrokeDescription(path, 0, 50)) + builder.addStroke(StrokeDescription(path, 200, 600)) + } + } + + fun addMultiFingerTripleTapHold( + builder: GestureDescription.Builder, + cx: Float, + cy: Float, + spacing: Float, + fingerCount: Int, + ) { + val positions = fingerPositions(cx, cy, spacing, fingerCount) + for ((x, y) in positions) { + val path = Path().apply { moveTo(x, y) } + builder.addStroke(StrokeDescription(path, 0, 50)) + builder.addStroke(StrokeDescription(path, 200, 50)) + builder.addStroke(StrokeDescription(path, 400, 600)) + } + } + + fun addMultiFingerSwipe( + builder: GestureDescription.Builder, + xStart: Float, + yStart: Float, + xEnd: Float, + yEnd: Float, + spacing: Float, + fingerCount: Int, + ) { + val startPositions = fingerPositions(xStart, yStart, spacing, fingerCount) + val endPositions = fingerPositions(xEnd, yEnd, spacing, fingerCount) + for (i in 0 until fingerCount) { + val (sx, sy) = startPositions[i] + val (ex, ey) = endPositions[i] + builder.addStroke(StrokeDescription(buildPath(sx, sy, ex, ey), 0, 200)) + } + } + + fun buildPath(x1: Float, y1: Float, x2: Float, y2: Float): Path = Path().apply { + moveTo(x1, y1) + lineTo(x2, y2) + } +} diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt index c1d5991a29..d247d41075 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/BaseAccessibilityService.kt @@ -29,6 +29,8 @@ import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import dagger.hilt.android.AndroidEntryPoint import io.github.sds100.keymapper.base.R +import io.github.sds100.keymapper.base.actions.talkback.TalkBackGestureType +import io.github.sds100.keymapper.base.actions.talkback.TalkbackGesturePerformer import io.github.sds100.keymapper.base.input.InputEventDetectionSource import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.common.utils.KMError @@ -561,6 +563,7 @@ abstract class BaseAccessibilityService : return imeWindow != null && imeWindow.root?.isVisibleToUser == true } + @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun injectText(text: String) { inputMethod?.currentInputConnection?.commitText( text, @@ -570,7 +573,12 @@ abstract class BaseAccessibilityService : ) } + @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun performImeAction() { inputMethod?.currentInputConnection?.performEditorAction(EditorInfo.IME_ACTION_UNSPECIFIED) } + + override fun performTalkBackGesture(gesture: TalkBackGestureType): KMResult<*> { + return TalkbackGesturePerformer.performTalkBackGesture(this, gesture, gestureHandler) + } } diff --git a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt index 7ae1761d8c..df4cd6c0d9 100644 --- a/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt +++ b/base/src/main/java/io/github/sds100/keymapper/base/system/accessibility/IAccessibilityService.kt @@ -2,6 +2,7 @@ package io.github.sds100.keymapper.base.system.accessibility import android.os.Build import androidx.annotation.RequiresApi +import io.github.sds100.keymapper.base.actions.talkback.TalkBackGestureType import io.github.sds100.keymapper.base.system.inputmethod.SwitchImeInterface import io.github.sds100.keymapper.common.utils.InputEventAction import io.github.sds100.keymapper.common.utils.KMResult @@ -69,4 +70,6 @@ interface IAccessibilityService : SwitchImeInterface { @RequiresApi(Build.VERSION_CODES.TIRAMISU) fun performImeAction() + + fun performTalkBackGesture(gesture: TalkBackGestureType): KMResult<*> } diff --git a/base/src/main/res/values/strings.xml b/base/src/main/res/values/strings.xml index c7ff462120..3fe19c6b92 100644 --- a/base/src/main/res/values/strings.xml +++ b/base/src/main/res/values/strings.xml @@ -1244,6 +1244,66 @@ Force stop app Close and clear app from recents Modify setting + TalkBack gesture + TalkBack: %s + Choose TalkBack gesture + %1$s (%2$s) + 1-finger gestures + 2-finger gestures + 3-finger gestures + 4-finger gestures + + + Move reading control up or backwards + Move reading control down or forwards + Previous item + Next item + Previous reading control + Next reading control + Scroll back + Scroll forwards + Start voice command + Pause or resume speech + Start or end selection mode + Read from focused item + Turn speech on or off + Open TalkBack menu + Screen search + Tap to assign + Previous reading control + Next reading control + Practice gestures + Open tutorial + Previous window + Next window + Previous container + Next container + + + Swipe up + Swipe down + Swipe left + Swipe right + Swipe up then down + Swipe down then up + Swipe left then right + Swipe right then left + Swipe right then up + Tap with 2 fingers + Double-tap and hold with 2 fingers + Triple-tap with 2 fingers + Triple-tap and hold with 2 fingers + Tap with 3 fingers + Tap and hold with 3 fingers + Triple-tap and hold with 3 fingers + Swipe up with 3 fingers + Swipe down with 3 fingers + Tap with 4 fingers + Double-tap with 4 fingers + Swipe up with 4 fingers + Swipe down with 4 fingers + Swipe left with 4 fingers + Swipe right with 4 fingers Key Value Setting key cannot be empty 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 65f644da15..8b0074dbb2 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 @@ -146,6 +146,8 @@ data class ActionEntity( const val EXTRA_SETTING_VALUE = "extra_setting_value" const val EXTRA_SETTING_TYPE = "extra_setting_type" + const val EXTRA_TALKBACK_GESTURE_TYPE = "extra_talkback_gesture_type" + val DESERIALIZER = jsonDeserializer { val typeString by it.json.byNullableString(NAME_ACTION_TYPE) // If it is an unknown type then do not deserialize