Loading packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/msdl/qs/TileHapticsViewModelTest.kt 0 → 100644 +166 −0 Original line number Diff line number Diff line /* * 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() } } packages/SystemUI/src/com/android/systemui/haptics/msdl/qs/StateAwareExpandable.kt 0 → 100644 +93 −0 Original line number Diff line number Diff line /* * 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) } } packages/SystemUI/src/com/android/systemui/haptics/msdl/qs/TileHapticsViewModel.kt 0 → 100644 +190 −0 Original line number Diff line number Diff line /* * 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 } } packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt +1 −0 Original line number Diff line number Diff line Loading @@ -73,6 +73,7 @@ fun SceneScope.QuickQuickSettings( squishiness = { squishiness }, coroutineScope = scope, bounceableInfo = bounceables.bounceableInfo(it, spanIndex, column, columns), tileHapticsViewModelFactoryProvider = viewModel.tileHapticsViewModelFactoryProvider, ) } } Loading packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/CommonTile.kt +2 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -97,6 +98,7 @@ fun LargeTileContent( onClick = toggleClick!!, onLongClick = onLongClick, onLongClickLabel = longPressLabel, hapticFeedbackEnabled = !Flags.msdlFeedback(), ) .thenIf(accessibilityUiState != null) { Modifier.semantics { Loading Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/msdl/qs/TileHapticsViewModelTest.kt 0 → 100644 +166 −0 Original line number Diff line number Diff line /* * 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() } }
packages/SystemUI/src/com/android/systemui/haptics/msdl/qs/StateAwareExpandable.kt 0 → 100644 +93 −0 Original line number Diff line number Diff line /* * 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) } }
packages/SystemUI/src/com/android/systemui/haptics/msdl/qs/TileHapticsViewModel.kt 0 → 100644 +190 −0 Original line number Diff line number Diff line /* * 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 } }
packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/QuickQuickSettings.kt +1 −0 Original line number Diff line number Diff line Loading @@ -73,6 +73,7 @@ fun SceneScope.QuickQuickSettings( squishiness = { squishiness }, coroutineScope = scope, bounceableInfo = bounceables.bounceableInfo(it, spanIndex, column, columns), tileHapticsViewModelFactoryProvider = viewModel.tileHapticsViewModelFactoryProvider, ) } } Loading
packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/CommonTile.kt +2 −0 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -97,6 +98,7 @@ fun LargeTileContent( onClick = toggleClick!!, onLongClick = onLongClick, onLongClickLabel = longPressLabel, hapticFeedbackEnabled = !Flags.msdlFeedback(), ) .thenIf(accessibilityUiState != null) { Modifier.semantics { Loading