diff options
| author | 2024-10-24 17:01:57 +0000 | |
|---|---|---|
| committer | 2024-10-24 17:01:57 +0000 | |
| commit | 2b52c719d6eeb94162fa4cf0c89d7e9b4eefe296 (patch) | |
| tree | 25c06abdb9a7332f7355e3f98dea5bdcc74c4fae | |
| parent | 0c6f881e25f47e93d9617341ba6cbd855c3e111f (diff) | |
| parent | a5a63692fc6530f106353a7e0801289b442cebd7 (diff) | |
Merge "Adding a TileHapticsViewModel for haptic playback on QS tiles." into main
13 files changed, 568 insertions, 10 deletions
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/msdl/qs/TileHapticsViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/msdl/qs/TileHapticsViewModelTest.kt new file mode 100644 index 000000000000..5efb6171cdde --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/msdl/qs/TileHapticsViewModelTest.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.haptics.msdl.qs + +import android.service.quicksettings.Tile +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.haptics.msdl.fakeMSDLPlayer +import com.android.systemui.haptics.msdl.tileHapticsViewModelFactory +import com.android.systemui.kosmos.testScope +import com.android.systemui.lifecycle.activateIn +import com.android.systemui.plugins.qs.QSTile +import com.android.systemui.qs.panels.ui.viewmodel.fakeQsTile +import com.android.systemui.qs.panels.ui.viewmodel.tileViewModel +import com.android.systemui.testKosmos +import com.google.android.msdl.data.model.MSDLToken +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class TileHapticsViewModelTest : SysuiTestCase() { + + private val kosmos = testKosmos() + private val testScope = kosmos.testScope + private val qsTile = kosmos.fakeQsTile + private val msdlPlayer = kosmos.fakeMSDLPlayer + private val tileViewModel = kosmos.tileViewModel + + private val underTest = kosmos.tileHapticsViewModelFactory.create(tileViewModel) + + @Before + fun setUp() { + underTest.activateIn(testScope) + } + + @Test + fun whenTileTogglesOnFromClick_playsSwitchOnHaptics() = + testScope.runTest { + // WHEN the tile toggles on after being clicked + underTest.setTileInteractionState(TileHapticsViewModel.TileInteractionState.CLICKED) + toggleOn() + + // THEN the switch on token plays + assertThat(msdlPlayer.latestTokenPlayed).isEqualTo(MSDLToken.SWITCH_ON) + assertThat(msdlPlayer.latestPropertiesPlayed).isNull() + } + + @Test + fun whenTileTogglesOffFromClick_playsSwitchOffHaptics() = + testScope.runTest { + // WHEN the tile toggles off after being clicked + underTest.setTileInteractionState(TileHapticsViewModel.TileInteractionState.CLICKED) + toggleOff() + + // THEN the switch off token plays + assertThat(msdlPlayer.latestTokenPlayed).isEqualTo(MSDLToken.SWITCH_OFF) + assertThat(msdlPlayer.latestPropertiesPlayed).isNull() + } + + @Test + fun whenTileTogglesOnWhileIdle_doesNotPlaySwitchOnHaptics() = + testScope.runTest { + // WHEN the tile toggles on without being clicked + toggleOn() + + // THEN no token plays + assertThat(msdlPlayer.latestTokenPlayed).isNull() + assertThat(msdlPlayer.latestPropertiesPlayed).isNull() + } + + @Test + fun whenTileTogglesOffWhileIdle_doesNotPlaySwitchOffHaptics() = + testScope.runTest { + // WHEN the tile toggles off without being clicked + toggleOff() + + // THEN no token plays + assertThat(msdlPlayer.latestTokenPlayed).isNull() + assertThat(msdlPlayer.latestPropertiesPlayed).isNull() + } + + @Test + fun whenLaunchingFromLongClick_playsLongPressHaptics() = + testScope.runTest { + // WHEN the tile is long-clicked and its action state changes accordingly + underTest.setTileInteractionState( + TileHapticsViewModel.TileInteractionState.LONG_CLICKED + ) + // WHEN the activity transition (from the long-click) starts + underTest.onActivityLaunchTransitionStart() + runCurrent() + + // THEN the long-press token plays + assertThat(msdlPlayer.latestTokenPlayed).isEqualTo(MSDLToken.LONG_PRESS) + assertThat(msdlPlayer.latestPropertiesPlayed).isNull() + } + + @Test + fun onLongClick_whenTileDoesNotHandleLongClick_playsFailureHaptics() = + testScope.runTest { + // WHEN the tile is long-clicked but the tile does not handle a long-click + val state = QSTile.State().apply { handlesLongClick = false } + qsTile.changeState(state) + underTest.setTileInteractionState( + TileHapticsViewModel.TileInteractionState.LONG_CLICKED + ) + runCurrent() + + // THEN the failure token plays + assertThat(msdlPlayer.latestTokenPlayed).isEqualTo(MSDLToken.FAILURE) + assertThat(msdlPlayer.latestPropertiesPlayed).isNull() + } + + @Test + fun whenLaunchingFromClick_doesNotPlayHaptics() = + testScope.runTest { + // WHEN the tile is clicked and its action state changes accordingly + underTest.setTileInteractionState(TileHapticsViewModel.TileInteractionState.CLICKED) + // WHEN an activity transition starts (from clicking) + underTest.onActivityLaunchTransitionStart() + runCurrent() + + // THEN no haptics play + assertThat(msdlPlayer.latestTokenPlayed).isNull() + assertThat(msdlPlayer.latestPropertiesPlayed).isNull() + } + + private fun TestScope.toggleOn() { + qsTile.changeState(QSTile.State().apply { state = Tile.STATE_INACTIVE }) + runCurrent() + + qsTile.changeState(QSTile.State().apply { state = Tile.STATE_ACTIVE }) + runCurrent() + } + + private fun TestScope.toggleOff() { + qsTile.changeState(QSTile.State().apply { state = Tile.STATE_ACTIVE }) + runCurrent() + + qsTile.changeState(QSTile.State().apply { state = Tile.STATE_INACTIVE }) + runCurrent() + } +} diff --git a/packages/SystemUI/src/com/android/systemui/haptics/msdl/qs/StateAwareExpandable.kt b/packages/SystemUI/src/com/android/systemui/haptics/msdl/qs/StateAwareExpandable.kt new file mode 100644 index 000000000000..215ceacaef14 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/haptics/msdl/qs/StateAwareExpandable.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.haptics.msdl.qs + +import android.content.ComponentName +import android.view.ViewGroup +import com.android.systemui.animation.ActivityTransitionAnimator +import com.android.systemui.animation.DialogCuj +import com.android.systemui.animation.DialogTransitionAnimator +import com.android.systemui.animation.Expandable + +private fun ActivityTransitionAnimator.Controller.withStateAwareness( + onActivityLaunchTransitionStart: () -> Unit, + onActivityLaunchTransitionEnd: () -> Unit, +): ActivityTransitionAnimator.Controller { + val delegate = this + return object : ActivityTransitionAnimator.Controller by delegate { + override fun onTransitionAnimationStart(isExpandingFullyAbove: Boolean) { + onActivityLaunchTransitionStart() + delegate.onTransitionAnimationStart(isExpandingFullyAbove) + } + + override fun onTransitionAnimationCancelled(newKeyguardOccludedState: Boolean?) { + onActivityLaunchTransitionEnd() + delegate.onTransitionAnimationCancelled(newKeyguardOccludedState) + } + + override fun onTransitionAnimationEnd(isExpandingFullyAbove: Boolean) { + onActivityLaunchTransitionEnd() + delegate.onTransitionAnimationEnd(isExpandingFullyAbove) + } + } +} + +private fun DialogTransitionAnimator.Controller.withStateAwareness( + onDialogDrawingStart: () -> Unit, + onDialogDrawingEnd: () -> Unit, +): DialogTransitionAnimator.Controller { + val delegate = this + return object : DialogTransitionAnimator.Controller by delegate { + + override fun startDrawingInOverlayOf(viewGroup: ViewGroup) { + onDialogDrawingStart() + delegate.startDrawingInOverlayOf(viewGroup) + } + + override fun stopDrawingInOverlay() { + onDialogDrawingEnd() + delegate.stopDrawingInOverlay() + } + } +} + +fun Expandable.withStateAwareness( + onDialogDrawingStart: () -> Unit, + onDialogDrawingEnd: () -> Unit, + onActivityLaunchTransitionStart: () -> Unit, + onActivityLaunchTransitionEnd: () -> Unit, +): Expandable { + val delegate = this + return object : Expandable { + override fun activityTransitionController( + launchCujType: Int?, + cookie: ActivityTransitionAnimator.TransitionCookie?, + component: ComponentName?, + returnCujType: Int?, + ): ActivityTransitionAnimator.Controller? = + delegate + .activityTransitionController(launchCujType, cookie, component, returnCujType) + ?.withStateAwareness(onActivityLaunchTransitionStart, onActivityLaunchTransitionEnd) + + override fun dialogTransitionController( + cuj: DialogCuj? + ): DialogTransitionAnimator.Controller? = + delegate + .dialogTransitionController(cuj) + ?.withStateAwareness(onDialogDrawingStart, onDialogDrawingEnd) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/haptics/msdl/qs/TileHapticsViewModel.kt b/packages/SystemUI/src/com/android/systemui/haptics/msdl/qs/TileHapticsViewModel.kt new file mode 100644 index 000000000000..79059506b727 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/haptics/msdl/qs/TileHapticsViewModel.kt @@ -0,0 +1,190 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.haptics.msdl.qs + +import android.service.quicksettings.Tile +import com.android.systemui.Flags +import com.android.systemui.animation.Expandable +import com.android.systemui.lifecycle.ExclusiveActivatable +import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel +import com.android.systemui.util.kotlin.pairwise +import com.google.android.msdl.data.model.MSDLToken +import com.google.android.msdl.domain.MSDLPlayer +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.transform + +/** A view-model to trigger haptics feedback on Quick Settings tiles */ +@OptIn(ExperimentalCoroutinesApi::class) +class TileHapticsViewModel +@AssistedInject +constructor( + private val msdlPlayer: MSDLPlayer, + @Assisted private val tileViewModel: TileViewModel, +) : ExclusiveActivatable() { + + private val tileInteractionState = MutableStateFlow(TileInteractionState.IDLE) + private val tileAnimationState = MutableStateFlow(TileAnimationState.IDLE) + private val canPlayToggleHaptics: Boolean + get() = + tileAnimationState.value == TileAnimationState.IDLE && + tileInteractionState.value == TileInteractionState.CLICKED + + val isIdle: Boolean + get() = + tileAnimationState.value == TileAnimationState.IDLE && + tileInteractionState.value == TileInteractionState.IDLE + + private val toggleHapticsState: Flow<TileHapticsState> = + tileViewModel.state + .mapLatest { it.state } + .pairwise() + .transform { (previous, current) -> + val toggleState = + when { + !canPlayToggleHaptics -> TileHapticsState.NO_HAPTICS + previous == Tile.STATE_INACTIVE && current == Tile.STATE_ACTIVE -> + TileHapticsState.TOGGLE_ON + previous == Tile.STATE_ACTIVE && current == Tile.STATE_INACTIVE -> + TileHapticsState.TOGGLE_OFF + else -> TileHapticsState.NO_HAPTICS + } + emit(toggleState) + } + .distinctUntilChanged() + + private val interactionHapticsState: Flow<TileHapticsState> = + combine(tileInteractionState, tileAnimationState) { interactionState, animationState -> + when { + interactionState == TileInteractionState.LONG_CLICKED && + animationState == TileAnimationState.ACTIVITY_LAUNCH -> + TileHapticsState.LONG_PRESS + interactionState == TileInteractionState.LONG_CLICKED && + !tileViewModel.currentState.handlesLongClick -> + TileHapticsState.FAILED_LONGPRESS + else -> TileHapticsState.NO_HAPTICS + } + } + .distinctUntilChanged() + + private val hapticsState: Flow<TileHapticsState> = + merge(toggleHapticsState, interactionHapticsState) + + override suspend fun onActivated(): Nothing { + try { + hapticsState.collect { hapticsState -> + val tokenToPlay: MSDLToken? = + when (hapticsState) { + TileHapticsState.TOGGLE_ON -> MSDLToken.SWITCH_ON + TileHapticsState.TOGGLE_OFF -> MSDLToken.SWITCH_OFF + TileHapticsState.LONG_PRESS -> MSDLToken.LONG_PRESS + TileHapticsState.FAILED_LONGPRESS -> MSDLToken.FAILURE + TileHapticsState.NO_HAPTICS -> null + } + tokenToPlay?.let { + msdlPlayer.playToken(it) + resetStates() + } + } + awaitCancellation() + } finally { + resetStates() + } + } + + private fun resetStates() { + tileInteractionState.value = TileInteractionState.IDLE + tileAnimationState.value = TileAnimationState.IDLE + } + + fun onDialogDrawingStart() { + tileAnimationState.value = TileAnimationState.DIALOG_LAUNCH + } + + fun onDialogDrawingEnd() { + tileAnimationState.value = TileAnimationState.IDLE + } + + fun onActivityLaunchTransitionStart() { + tileAnimationState.value = TileAnimationState.ACTIVITY_LAUNCH + } + + fun onActivityLaunchTransitionEnd() { + tileAnimationState.value = TileAnimationState.IDLE + } + + fun setTileInteractionState(actionState: TileInteractionState) { + tileInteractionState.value = actionState + } + + fun createStateAwareExpandable(baseExpandable: Expandable): Expandable = + baseExpandable.withStateAwareness( + onDialogDrawingStart = ::onDialogDrawingStart, + onDialogDrawingEnd = ::onDialogDrawingEnd, + onActivityLaunchTransitionStart = ::onActivityLaunchTransitionStart, + onActivityLaunchTransitionEnd = ::onActivityLaunchTransitionEnd, + ) + + /** Models the state of toggle haptics to play */ + enum class TileHapticsState { + TOGGLE_ON, + TOGGLE_OFF, + LONG_PRESS, + FAILED_LONGPRESS, + NO_HAPTICS, + } + + /** Models the interaction that took place on the tile */ + enum class TileInteractionState { + IDLE, + CLICKED, + LONG_CLICKED, + } + + /** Models the animation state of dialogs and activity launches from a tile */ + enum class TileAnimationState { + IDLE, + DIALOG_LAUNCH, + ACTIVITY_LAUNCH, + } + + @AssistedFactory + interface Factory { + fun create(tileViewModel: TileViewModel): TileHapticsViewModel + } +} + +class TileHapticsViewModelFactoryProvider +@Inject +constructor(private val tileHapticsViewModelFactory: TileHapticsViewModel.Factory) { + fun getHapticsViewModelFactory(): TileHapticsViewModel.Factory? = + if (Flags.msdlFeedback()) { + tileHapticsViewModelFactory + } else { + null + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt index 1c7a334d3ef2..99a6cda8cbf5 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt @@ -73,6 +73,7 @@ fun SceneScope.QuickQuickSettings( squishiness = { squishiness }, coroutineScope = scope, bounceableInfo = bounceables.bounceableInfo(it, spanIndex, column, columns), + tileHapticsViewModelFactoryProvider = viewModel.tileHapticsViewModelFactoryProvider, ) } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/CommonTile.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/CommonTile.kt index 28f546300d40..978a3534e95b 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/CommonTile.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/CommonTile.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.semantics.toggleableState import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.android.compose.modifiers.thenIf +import com.android.systemui.Flags import com.android.systemui.common.shared.model.Icon import com.android.systemui.common.ui.compose.Icon import com.android.systemui.common.ui.compose.load @@ -97,6 +98,7 @@ fun LargeTileContent( onClick = toggleClick!!, onLongClick = onLongClick, onLongClickLabel = longPressLabel, + hapticFeedbackEnabled = !Flags.msdlFeedback(), ) .thenIf(accessibilityUiState != null) { Modifier.semantics { diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/InfiniteGridLayout.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/InfiniteGridLayout.kt index 366bc9a7a29a..91f2da2b90f7 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/InfiniteGridLayout.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/InfiniteGridLayout.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.compose.animation.scene.SceneScope import com.android.systemui.dagger.SysUISingleton import com.android.systemui.grid.ui.compose.VerticalSpannedGrid +import com.android.systemui.haptics.msdl.qs.TileHapticsViewModelFactoryProvider import com.android.systemui.lifecycle.rememberViewModel import com.android.systemui.qs.panels.shared.model.SizedTileImpl import com.android.systemui.qs.panels.ui.compose.PaginatableGridLayout @@ -49,6 +50,7 @@ class InfiniteGridLayout constructor( private val iconTilesViewModel: IconTilesViewModel, private val viewModelFactory: InfiniteGridViewModel.Factory, + private val tileHapticsViewModelFactoryProvider: TileHapticsViewModelFactoryProvider, ) : PaginatableGridLayout { @Composable @@ -92,6 +94,7 @@ constructor( iconOnly = iconTilesViewModel.isIconTile(it.tile.spec), modifier = Modifier.element(it.tile.spec.toElementKey(spanIndex)), squishiness = { squishiness }, + tileHapticsViewModelFactoryProvider = tileHapticsViewModelFactoryProvider, coroutineScope = scope, bounceableInfo = bounceables.bounceableInfo(it, spanIndex, column, columns), ) diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/Tile.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/Tile.kt index 4104e53cfcb0..e1583e3fa3d3 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/Tile.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/Tile.kt @@ -64,9 +64,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.compose.animation.Expandable import com.android.compose.animation.bounceable import com.android.compose.modifiers.thenIf +import com.android.systemui.Flags import com.android.systemui.animation.Expandable import com.android.systemui.common.shared.model.Icon import com.android.systemui.compose.modifiers.sysuiResTag +import com.android.systemui.haptics.msdl.qs.TileHapticsViewModel +import com.android.systemui.haptics.msdl.qs.TileHapticsViewModelFactoryProvider +import com.android.systemui.lifecycle.rememberViewModel import com.android.systemui.plugins.qs.QSTile import com.android.systemui.qs.panels.ui.compose.BounceableInfo import com.android.systemui.qs.panels.ui.compose.infinitegrid.CommonTileDefaults.InactiveCornerRadius @@ -109,6 +113,7 @@ fun Tile( squishiness: () -> Float, coroutineScope: CoroutineScope, bounceableInfo: BounceableInfo, + tileHapticsViewModelFactoryProvider: TileHapticsViewModelFactoryProvider, modifier: Modifier = Modifier, ) { val state by tile.state.collectAsStateWithLifecycle(tile.currentState) @@ -116,6 +121,10 @@ fun Tile( val resources = resources() val uiState = remember(state, resources) { state.toUiState(resources) } val colors = TileDefaults.getColorForState(uiState) + val hapticsViewModel: TileHapticsViewModel? = + rememberViewModel(traceName = "TileHapticsViewModel") { + tileHapticsViewModelFactoryProvider.getHapticsViewModelFactory()?.create(tile) + } // TODO(b/361789146): Draw the shapes instead of clipping val tileShape = TileDefaults.animateTileShape(uiState.state) @@ -129,6 +138,7 @@ fun Tile( }, shape = tileShape, squishiness = squishiness, + hapticsViewModel = hapticsViewModel, modifier = modifier .fillMaxWidth() @@ -143,11 +153,19 @@ fun Tile( TileContainer( onClick = { tile.onClick(expandable) + hapticsViewModel?.setTileInteractionState( + TileHapticsViewModel.TileInteractionState.CLICKED + ) if (uiState.accessibilityUiState.toggleableState != null) { coroutineScope.launch { currentBounceableInfo.bounceable.animateBounce() } } }, - onLongClick = { tile.onLongClick(expandable) }, + onLongClick = { + hapticsViewModel?.setTileInteractionState( + TileHapticsViewModel.TileInteractionState.LONG_CLICKED + ) + tile.onLongClick(expandable) + }, uiState = uiState, iconOnly = iconOnly, ) { @@ -161,9 +179,21 @@ fun Tile( } else { val iconShape = TileDefaults.animateIconShape(uiState.state) val secondaryClick: (() -> Unit)? = - { tile.onSecondaryClick() }.takeIf { uiState.handlesSecondaryClick } + { + hapticsViewModel?.setTileInteractionState( + TileHapticsViewModel.TileInteractionState.CLICKED + ) + tile.onSecondaryClick() + } + .takeIf { uiState.handlesSecondaryClick } val longClick: (() -> Unit)? = - { tile.onLongClick(expandable) }.takeIf { uiState.handlesLongClick } + { + hapticsViewModel?.setTileInteractionState( + TileHapticsViewModel.TileInteractionState.LONG_CLICKED + ) + tile.onLongClick(expandable) + } + .takeIf { uiState.handlesLongClick } LargeTileContent( label = uiState.label, secondaryLabel = uiState.secondaryLabel, @@ -185,6 +215,7 @@ private fun TileExpandable( color: Color, shape: Shape, squishiness: () -> Float, + hapticsViewModel: TileHapticsViewModel?, modifier: Modifier = Modifier, content: @Composable (Expandable) -> Unit, ) { @@ -193,7 +224,7 @@ private fun TileExpandable( shape = shape, modifier = modifier.clip(shape).verticalSquish(squishiness), ) { - content(it) + content(hapticsViewModel?.createStateAwareExpandable(it) ?: it) } } @@ -254,6 +285,7 @@ fun Modifier.tileCombinedClickable( onLongClick = onLongClick, onClickLabel = uiState.accessibilityUiState.clickLabel, onLongClickLabel = longPressLabel, + hapticFeedbackEnabled = !Flags.msdlFeedback(), ) .semantics { role = uiState.accessibilityUiState.accessibilityRole diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/QuickQuickSettingsViewModel.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/QuickQuickSettingsViewModel.kt index 72b586a2f4df..887a70f39f6a 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/QuickQuickSettingsViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/viewmodel/QuickQuickSettingsViewModel.kt @@ -18,6 +18,7 @@ package com.android.systemui.qs.panels.ui.viewmodel import com.android.systemui.dagger.SysUISingleton import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.haptics.msdl.qs.TileHapticsViewModelFactoryProvider import com.android.systemui.qs.panels.domain.interactor.QuickQuickSettingsRowInteractor import com.android.systemui.qs.panels.shared.model.SizedTile import com.android.systemui.qs.panels.shared.model.SizedTileImpl @@ -45,6 +46,7 @@ constructor( val squishinessViewModel: TileSquishinessViewModel, private val iconTilesViewModel: IconTilesViewModel, @Application private val applicationScope: CoroutineScope, + val tileHapticsViewModelFactoryProvider: TileHapticsViewModelFactoryProvider, ) { val columns = qsColumnsViewModel.columns diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/haptics/msdl/TileHapticsViewModelFactoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/haptics/msdl/TileHapticsViewModelFactoryKosmos.kt new file mode 100644 index 000000000000..a798eb746943 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/haptics/msdl/TileHapticsViewModelFactoryKosmos.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.haptics.msdl + +import com.android.systemui.haptics.msdl.qs.TileHapticsViewModel +import com.android.systemui.haptics.msdl.qs.TileHapticsViewModelFactoryProvider +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.qs.panels.ui.viewmodel.TileViewModel + +val Kosmos.tileHapticsViewModelFactory by + Kosmos.Fixture { + object : TileHapticsViewModel.Factory { + override fun create(tileViewModel: TileViewModel): TileHapticsViewModel = + TileHapticsViewModel(fakeMSDLPlayer, tileViewModel) + } + } + +val Kosmos.tileHapticsViewModelFactoryProvider by + Kosmos.Fixture { TileHapticsViewModelFactoryProvider(tileHapticsViewModelFactory) } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/FakeQSTile.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/FakeQSTile.kt index 093ebd6c6b63..562980d43485 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/FakeQSTile.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/FakeQSTile.kt @@ -20,13 +20,10 @@ import com.android.internal.logging.InstanceId import com.android.systemui.animation.Expandable import com.android.systemui.plugins.qs.QSTile -class FakeQSTile( - var user: Int, - var available: Boolean = true, -) : QSTile { +class FakeQSTile(var user: Int, var available: Boolean = true) : QSTile { private var tileSpec: String? = null var destroyed = false - private val state = QSTile.State() + private var state = QSTile.State() val callbacks = mutableListOf<QSTile.Callback>() override fun getTileSpec(): String? { @@ -93,4 +90,9 @@ class FakeQSTile( override fun isListening(): Boolean { return false } + + fun changeState(newState: QSTile.State) { + state = newState + callbacks.forEach { it.onStateChanged(state) } + } } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridLayoutKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridLayoutKosmos.kt index b6b0a4168c5d..b5a6bf126719 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridLayoutKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/domain/interactor/InfiniteGridLayoutKosmos.kt @@ -16,10 +16,17 @@ package com.android.systemui.qs.panels.domain.interactor +import com.android.systemui.haptics.msdl.tileHapticsViewModelFactoryProvider import com.android.systemui.kosmos.Kosmos import com.android.systemui.qs.panels.ui.compose.infinitegrid.InfiniteGridLayout import com.android.systemui.qs.panels.ui.viewmodel.iconTilesViewModel import com.android.systemui.qs.panels.ui.viewmodel.infiniteGridViewModelFactory val Kosmos.infiniteGridLayout by - Kosmos.Fixture { InfiniteGridLayout(iconTilesViewModel, infiniteGridViewModelFactory) } + Kosmos.Fixture { + InfiniteGridLayout( + iconTilesViewModel, + infiniteGridViewModelFactory, + tileHapticsViewModelFactoryProvider, + ) + } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/QuickQuickSettingsViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/QuickQuickSettingsViewModelKosmos.kt index 67d9e0ed552b..41ee260fd5dd 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/QuickQuickSettingsViewModelKosmos.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/QuickQuickSettingsViewModelKosmos.kt @@ -16,6 +16,7 @@ package com.android.systemui.qs.panels.ui.viewmodel +import com.android.systemui.haptics.msdl.tileHapticsViewModelFactoryProvider import com.android.systemui.kosmos.Kosmos import com.android.systemui.kosmos.applicationCoroutineScope import com.android.systemui.qs.panels.domain.interactor.quickQuickSettingsRowInteractor @@ -30,5 +31,6 @@ val Kosmos.quickQuickSettingsViewModel by tileSquishinessViewModel, iconTilesViewModel, applicationCoroutineScope, + tileHapticsViewModelFactoryProvider, ) } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/TileViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/TileViewModelKosmos.kt new file mode 100644 index 000000000000..223755d7636d --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/panels/ui/viewmodel/TileViewModelKosmos.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.panels.ui.viewmodel + +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.qs.FakeQSTile +import com.android.systemui.qs.pipeline.shared.TileSpec + +val Kosmos.fakeQsTile by Kosmos.Fixture { FakeQSTile(user = 0, available = true) } +val Kosmos.tileViewModel by + Kosmos.Fixture { TileViewModel(fakeQsTile, TileSpec.Companion.create("test")) } |