diff options
| author | 2024-11-18 10:44:09 +0000 | |
|---|---|---|
| committer | 2024-11-19 10:12:28 +0000 | |
| commit | 75e4420cd29f8f6140739ecde6b15772cc0cfdc7 (patch) | |
| tree | f07deb58f724e30878eceb3384377058f7123129 | |
| parent | 293f2dbfc23bfdc50f33ba33134e25789485ab85 (diff) | |
Capturing user's selected custom key combination for shortcuts
When user presses a key combination we capture it, and display on the
UI.
Test: ShortcutCustomizationViewModelTest
Flag: com.android.systemui.keyboard_shortcut_helper_shortcut_customizer
Bug: 373631227
Change-Id: Iac6718b5377108849e915ea8121be7a8da44c890
6 files changed, 277 insertions, 24 deletions
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml index 53ab686ff0d7..b479c8abb530 100644 --- a/packages/SystemUI/res/values/strings.xml +++ b/packages/SystemUI/res/values/strings.xml @@ -3818,6 +3818,10 @@ assigning a new custom key combination to a shortcut in shortcut helper. The helper is a component that shows the user which keyboard shortcuts they can use. [CHAR LIMIT=NONE] --> <string name="shortcut_helper_customize_dialog_error_message">Key combination already in use. Try another key.</string> + <!-- Plus sign, used in keyboard shortcut helper to combine keys for shortcut. E.g. Ctrl + A + The helper is a component that shows the user which keyboard shortcuts they can use. + [CHAR LIMIT=NONE] --> + <string name="shortcut_helper_plus_symbol">+</string> <!-- Keyboard touchpad tutorial scheduler--> diff --git a/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/ShortcutCustomizationDialogStarter.kt b/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/ShortcutCustomizationDialogStarter.kt index e44bfe30a8bb..2cb822eb0609 100644 --- a/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/ShortcutCustomizationDialogStarter.kt +++ b/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/ShortcutCustomizationDialogStarter.kt @@ -17,6 +17,7 @@ package com.android.systemui.keyboard.shortcut.ui import android.app.Dialog +import android.view.WindowManager.LayoutParams.PRIVATE_FLAG_ALLOW_ACTION_KEY_EVENTS import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight @@ -33,6 +34,7 @@ import com.android.systemui.statusbar.phone.SystemUIDialogFactory import com.android.systemui.statusbar.phone.create import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import kotlinx.coroutines.awaitCancellation class ShortcutCustomizationDialogStarter @AssistedInject @@ -48,7 +50,7 @@ constructor( viewModel.shortcutCustomizationUiState.collect { uiState -> if ( uiState is ShortcutCustomizationUiState.AddShortcutDialog && - !uiState.isDialogShowing + !uiState.isDialogShowing ) { dialog = createAddShortcutDialog().also { it.show() } viewModel.onAddShortcutDialogShown() @@ -57,6 +59,7 @@ constructor( dialog = null } } + awaitCancellation() } fun onShortcutCustomizationRequested(requestInfo: ShortcutCustomizationRequestInfo) { @@ -66,14 +69,21 @@ constructor( private fun createAddShortcutDialog(): Dialog { return dialogFactory.create(dialogDelegate = ShortcutCustomizationDialogDelegate()) { dialog -> - val uiState by viewModel.shortcutCustomizationUiState.collectAsStateWithLifecycle() + val uiState by + viewModel.shortcutCustomizationUiState.collectAsStateWithLifecycle( + initialValue = ShortcutCustomizationUiState.Inactive + ) AssignNewShortcutDialog( uiState = uiState, modifier = Modifier.width(364.dp).wrapContentHeight().padding(vertical = 24.dp), onKeyPress = { viewModel.onKeyPressed(it) }, onCancel = { dialog.dismiss() }, ) - dialog.setOnDismissListener { viewModel.onAddShortcutDialogDismissed() } + dialog.setOnDismissListener { viewModel.onDialogDismissed() } + + // By default, apps cannot intercept action key. The system always handles it. This + // flag is needed to enable customisation dialog window to intercept action key + dialog.window?.addPrivateFlags(PRIVATE_FLAG_ALLOW_ACTION_KEY_EVENTS) } } diff --git a/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/composable/ShortcutCustomizer.kt b/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/composable/ShortcutCustomizer.kt index 43f0f200ab23..955470f426ab 100644 --- a/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/composable/ShortcutCustomizer.kt +++ b/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/composable/ShortcutCustomizer.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -45,12 +46,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.android.compose.ui.graphics.painter.rememberDrawablePainter import com.android.systemui.keyboard.shortcut.shared.model.ShortcutKey import com.android.systemui.keyboard.shortcut.ui.model.ShortcutCustomizationUiState import com.android.systemui.res.R @@ -81,6 +83,7 @@ fun AssignNewShortcutDialog( SelectedKeyCombinationContainer( shouldShowErrorMessage = uiState.shouldShowErrorMessage, onKeyPress = onKeyPress, + pressedKeys = uiState.pressedKeys, ) KeyCombinationAlreadyInUseErrorMessage(uiState.shouldShowErrorMessage) DialogButtons(onCancel, isValidKeyCombination = uiState.isValidKeyCombination) @@ -137,10 +140,9 @@ fun KeyCombinationAlreadyInUseErrorMessage(shouldShowErrorMessage: Boolean) { @Composable fun SelectedKeyCombinationContainer( - keyCombination: String = - stringResource(R.string.shortcut_helper_add_shortcut_dialog_placeholder), shouldShowErrorMessage: Boolean, onKeyPress: (KeyEvent) -> Boolean, + pressedKeys: List<ShortcutKey>, ) { val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() @@ -157,22 +159,18 @@ fun SelectedKeyCombinationContainer( Modifier.padding(all = 16.dp) .sizeIn(minWidth = 332.dp, minHeight = 56.dp) .border(width = 2.dp, color = outlineColor, shape = RoundedCornerShape(50.dp)) - .onPreviewKeyEvent { onKeyPress(it) }, + .onKeyEvent { onKeyPress(it) }, interactionSource = interactionSource, ) { Row( modifier = Modifier.padding(start = 24.dp, top = 16.dp, end = 16.dp, bottom = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = keyCombination, - style = MaterialTheme.typography.headlineSmall, - fontSize = 16.sp, - lineHeight = 24.sp, - fontWeight = FontWeight.W500, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(252.dp), - ) + if (pressedKeys.isEmpty()) { + PressKeyPrompt() + } else { + PressedKeysTextContainer(pressedKeys) + } Spacer(modifier = Modifier.weight(1f)) if (shouldShowErrorMessage) { Icon( @@ -187,6 +185,67 @@ fun SelectedKeyCombinationContainer( } @Composable +private fun RowScope.PressedKeysTextContainer(pressedKeys: List<ShortcutKey>) { + pressedKeys.forEachIndexed { keyIndex, key -> + if (keyIndex > 0) { + ShortcutKeySeparator() + } + if (key is ShortcutKey.Text) { + ShortcutTextKey(key) + } else if (key is ShortcutKey.Icon) { + ShortcutIconKey(key) + } + } +} + +@Composable +private fun ShortcutKeySeparator() { + Text( + text = stringResource(id = R.string.shortcut_helper_plus_symbol), + style = MaterialTheme.typography.titleSmall, + fontSize = 16.sp, + lineHeight = 24.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun RowScope.ShortcutIconKey(key: ShortcutKey.Icon) { + Icon( + painter = + when (key) { + is ShortcutKey.Icon.ResIdIcon -> painterResource(key.drawableResId) + is ShortcutKey.Icon.DrawableIcon -> rememberDrawablePainter(drawable = key.drawable) + }, + contentDescription = null, + modifier = Modifier.align(Alignment.CenterVertically).height(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun PressKeyPrompt() { + Text( + text = stringResource(id = R.string.shortcut_helper_add_shortcut_dialog_placeholder), + style = MaterialTheme.typography.titleSmall, + fontSize = 16.sp, + lineHeight = 24.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun ShortcutTextKey(key: ShortcutKey.Text) { + Text( + text = key.value, + style = MaterialTheme.typography.titleSmall, + fontSize = 16.sp, + lineHeight = 24.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable private fun Title(title: String, modifier: Modifier = Modifier) { Text( text = title, @@ -203,8 +262,6 @@ private fun Description(modifier: Modifier = Modifier) { Text( text = stringResource(id = R.string.shortcut_helper_customize_mode_sub_title), style = MaterialTheme.typography.bodyMedium, - fontSize = 14.sp, - lineHeight = 20.sp, modifier = modifier.wrapContentSize(Alignment.Center), color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/model/ShortcutCustomizationUiState.kt b/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/model/ShortcutCustomizationUiState.kt index e9f2a3b8e5b3..0080afb7160d 100644 --- a/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/model/ShortcutCustomizationUiState.kt +++ b/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/model/ShortcutCustomizationUiState.kt @@ -25,6 +25,7 @@ sealed interface ShortcutCustomizationUiState { val isValidKeyCombination: Boolean, val defaultCustomShortcutModifierKey: ShortcutKey.Icon.ResIdIcon, val isDialogShowing: Boolean, + val pressedKeys: List<ShortcutKey> = emptyList(), ) : ShortcutCustomizationUiState data object Inactive : ShortcutCustomizationUiState diff --git a/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/viewmodel/ShortcutCustomizationViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/viewmodel/ShortcutCustomizationViewModel.kt index e86da5d25b22..9a46ae25cbcb 100644 --- a/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/viewmodel/ShortcutCustomizationViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/keyboard/shortcut/ui/viewmodel/ShortcutCustomizationViewModel.kt @@ -17,14 +17,22 @@ package com.android.systemui.keyboard.shortcut.ui.viewmodel import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.nativeKeyCode +import androidx.compose.ui.input.key.type import com.android.systemui.keyboard.shortcut.domain.interactor.ShortcutCustomizationInteractor +import com.android.systemui.keyboard.shortcut.shared.model.KeyCombination import com.android.systemui.keyboard.shortcut.shared.model.ShortcutCustomizationRequestInfo import com.android.systemui.keyboard.shortcut.ui.model.ShortcutCustomizationUiState import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update class ShortcutCustomizationViewModel @@ -35,7 +43,21 @@ constructor(private val shortcutCustomizationInteractor: ShortcutCustomizationIn private val _shortcutCustomizationUiState = MutableStateFlow<ShortcutCustomizationUiState>(ShortcutCustomizationUiState.Inactive) - val shortcutCustomizationUiState = _shortcutCustomizationUiState.asStateFlow() + val shortcutCustomizationUiState = + shortcutCustomizationInteractor.pressedKeys + .map { keys -> + // Note that Action Key is excluded as it's already displayed on the UI + keys.filter { + it != shortcutCustomizationInteractor.getDefaultCustomShortcutModifierKey() + } + } + .combine(_shortcutCustomizationUiState) { keys, uiState -> + if (uiState is ShortcutCustomizationUiState.AddShortcutDialog) { + uiState.copy(pressedKeys = keys) + } else { + uiState + } + } fun onShortcutCustomizationRequested(requestInfo: ShortcutCustomizationRequestInfo) { when (requestInfo) { @@ -48,6 +70,7 @@ constructor(private val shortcutCustomizationInteractor: ShortcutCustomizationIn defaultCustomShortcutModifierKey = shortcutCustomizationInteractor.getDefaultCustomShortcutModifierKey(), isDialogShowing = false, + pressedKeys = emptyList(), ) _shortcutBeingCustomized.value = requestInfo } @@ -62,18 +85,48 @@ constructor(private val shortcutCustomizationInteractor: ShortcutCustomizationIn } } - fun onAddShortcutDialogDismissed() { + fun onDialogDismissed() { _shortcutBeingCustomized.value = null _shortcutCustomizationUiState.value = ShortcutCustomizationUiState.Inactive + shortcutCustomizationInteractor.updateUserSelectedKeyCombination(null) } fun onKeyPressed(keyEvent: KeyEvent): Boolean { - // TODO Not yet implemented b/373638584 + if ((keyEvent.isMetaPressed && keyEvent.type == KeyEventType.KeyDown)) { + updatePressedKeys(keyEvent) + return true + } return false } + private fun updatePressedKeys(keyEvent: KeyEvent) { + val isModifier = SUPPORTED_MODIFIERS.contains(keyEvent.key) + val keyCombination = + KeyCombination( + modifiers = keyEvent.nativeKeyEvent.modifiers, + keyCode = if (!isModifier) keyEvent.key.nativeKeyCode else null, + ) + shortcutCustomizationInteractor.updateUserSelectedKeyCombination(keyCombination) + } + @AssistedFactory interface Factory { fun create(): ShortcutCustomizationViewModel } + + companion object { + private val SUPPORTED_MODIFIERS = + listOf( + Key.MetaLeft, + Key.MetaRight, + Key.CtrlRight, + Key.CtrlLeft, + Key.AltLeft, + Key.AltRight, + Key.ShiftLeft, + Key.ShiftRight, + Key.Function, + Key.Symbol, + ) + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyboard/shortcut/ui/viewmodel/ShortcutCustomizationViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/keyboard/shortcut/ui/viewmodel/ShortcutCustomizationViewModelTest.kt index a9b6dd16cd95..ef2597f7f96c 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/keyboard/shortcut/ui/viewmodel/ShortcutCustomizationViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/keyboard/shortcut/ui/viewmodel/ShortcutCustomizationViewModelTest.kt @@ -16,6 +16,15 @@ package com.android.systemui.keyboard.shortcut.ui.viewmodel +import android.content.Context +import android.content.Context.INPUT_SERVICE +import android.hardware.input.fakeInputManager +import android.os.SystemClock +import android.view.KeyEvent.ACTION_DOWN +import android.view.KeyEvent.KEYCODE_A +import android.view.KeyEvent.META_CTRL_ON +import android.view.KeyEvent.META_META_ON +import androidx.compose.ui.input.key.KeyEvent import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase @@ -24,24 +33,43 @@ import com.android.systemui.keyboard.shortcut.shared.model.ShortcutCategoryType import com.android.systemui.keyboard.shortcut.shared.model.ShortcutCustomizationRequestInfo import com.android.systemui.keyboard.shortcut.shared.model.ShortcutKey import com.android.systemui.keyboard.shortcut.shortcutCustomizationViewModelFactory +import com.android.systemui.keyboard.shortcut.shortcutHelperTestHelper import com.android.systemui.keyboard.shortcut.ui.model.ShortcutCustomizationUiState import com.android.systemui.kosmos.Kosmos import com.android.systemui.kosmos.testCase import com.android.systemui.kosmos.testScope import com.android.systemui.res.R +import com.android.systemui.settings.FakeUserTracker +import com.android.systemui.settings.userTracker import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever @SmallTest @RunWith(AndroidJUnit4::class) class ShortcutCustomizationViewModelTest : SysuiTestCase() { - private val kosmos = Kosmos().also { it.testCase = this } + private val mockUserContext: Context = mock() + private val kosmos = + Kosmos().also { + it.testCase = this + it.userTracker = FakeUserTracker(onCreateCurrentUserContext = { mockUserContext }) + } private val testScope = kosmos.testScope + private val inputManager = kosmos.fakeInputManager.inputManager + private val helper = kosmos.shortcutHelperTestHelper private val viewModel = kosmos.shortcutCustomizationViewModelFactory.create() + @Before + fun setup() { + helper.showFromActivity() + whenever(mockUserContext.getSystemService(INPUT_SERVICE)).thenReturn(inputManager) + } + @Test fun uiState_inactiveByDefault() { testScope.runTest { @@ -79,11 +107,111 @@ class ShortcutCustomizationViewModelTest : SysuiTestCase() { val uiState by collectLastValue(viewModel.shortcutCustomizationUiState) viewModel.onShortcutCustomizationRequested(standardAddShortcutRequest) viewModel.onAddShortcutDialogShown() - viewModel.onAddShortcutDialogDismissed() + viewModel.onDialogDismissed() assertThat(uiState).isEqualTo(ShortcutCustomizationUiState.Inactive) } } + @Test + fun uiState_pressedKeys_emptyByDefault() { + testScope.runTest { + val uiState by collectLastValue(viewModel.shortcutCustomizationUiState) + viewModel.onShortcutCustomizationRequested(standardAddShortcutRequest) + assertThat((uiState as ShortcutCustomizationUiState.AddShortcutDialog).pressedKeys) + .isEmpty() + } + } + + @Test + fun onKeyPressed_handlesKeyEvents_whereActionKeyIsAlsoPressed() { + testScope.runTest { + viewModel.onShortcutCustomizationRequested(standardAddShortcutRequest) + val isHandled = viewModel.onKeyPressed(keyDownEventWithActionKeyPressed) + + assertThat(isHandled).isTrue() + } + } + + @Test + fun onKeyPressed_doesNotHandleKeyEvents_whenActionKeyIsNotAlsoPressed() { + testScope.runTest { + viewModel.onShortcutCustomizationRequested(standardAddShortcutRequest) + val isHandled = viewModel.onKeyPressed(keyDownEventWithoutActionKeyPressed) + + assertThat(isHandled).isFalse() + } + } + + @Test + fun onKeyPressed_convertsKeyEventsAndUpdatesUiStatesPressedKey() { + testScope.runTest { + val uiState by collectLastValue(viewModel.shortcutCustomizationUiState) + viewModel.onShortcutCustomizationRequested(standardAddShortcutRequest) + viewModel.onKeyPressed(keyDownEventWithActionKeyPressed) + viewModel.onKeyPressed(keyUpEventWithActionKeyPressed) + + // Note that Action Key is excluded as it's already displayed on the UI + assertThat((uiState as ShortcutCustomizationUiState.AddShortcutDialog).pressedKeys) + .containsExactly(ShortcutKey.Text("Ctrl"), ShortcutKey.Text("A")) + } + } + + @Test + fun uiState_pressedKeys_resetsToEmptyListAfterDialogIsDismissedAndReopened() { + testScope.runTest { + val uiState by collectLastValue(viewModel.shortcutCustomizationUiState) + viewModel.onShortcutCustomizationRequested(standardAddShortcutRequest) + viewModel.onKeyPressed(keyDownEventWithActionKeyPressed) + viewModel.onKeyPressed(keyUpEventWithActionKeyPressed) + + // Note that Action Key is excluded as it's already displayed on the UI + assertThat((uiState as ShortcutCustomizationUiState.AddShortcutDialog).pressedKeys) + .containsExactly(ShortcutKey.Text("Ctrl"), ShortcutKey.Text("A")) + + // Close the dialog and show it again + viewModel.onDialogDismissed() + viewModel.onShortcutCustomizationRequested(standardAddShortcutRequest) + assertThat((uiState as ShortcutCustomizationUiState.AddShortcutDialog).pressedKeys) + .isEmpty() + } + } + + private val keyDownEventWithoutActionKeyPressed = + KeyEvent( + android.view.KeyEvent( + /* downTime = */ SystemClock.uptimeMillis(), + /* eventTime = */ SystemClock.uptimeMillis(), + /* action = */ ACTION_DOWN, + /* code = */ KEYCODE_A, + /* repeat = */ 0, + /* metaState = */ META_CTRL_ON, + ) + ) + + private val keyDownEventWithActionKeyPressed = + KeyEvent( + android.view.KeyEvent( + /* downTime = */ SystemClock.uptimeMillis(), + /* eventTime = */ SystemClock.uptimeMillis(), + /* action = */ ACTION_DOWN, + /* code = */ KEYCODE_A, + /* repeat = */ 0, + /* metaState = */ META_CTRL_ON or META_META_ON, + ) + ) + + private val keyUpEventWithActionKeyPressed = + KeyEvent( + android.view.KeyEvent( + /* downTime = */ SystemClock.uptimeMillis(), + /* eventTime = */ SystemClock.uptimeMillis(), + /* action = */ ACTION_DOWN, + /* code = */ KEYCODE_A, + /* repeat = */ 0, + /* metaState = */ 0, + ) + ) + private val standardAddShortcutRequest = ShortcutCustomizationRequestInfo.Add( label = "Standard shortcut", |