Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a5a63692 authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez
Browse files

Adding a TileHapticsViewModel for haptic playback on QS tiles.

The view-model handles haptic playback for interactions with large and
small quick settings tiles.

Test: manual. Verified correct haptic playback when toggling tiles, as
  well as long-pressing tiles when tiles launch or fail to launch.
Test: TileHapticsViewModelTest
Flag: com.android.systemui.msdl_feedback
Bug: 371193636
Bug: 371193619
Bug: 371193607
Bug: 371193634

Change-Id: I2e01f49ce18504284d2f449f13c257a2950eb7eb
parent 066cfe96
Loading
Loading
Loading
Loading
+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()
    }
}
+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)
    }
}
+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
        }
}
+1 −0
Original line number Diff line number Diff line
@@ -73,6 +73,7 @@ fun SceneScope.QuickQuickSettings(
                squishiness = { squishiness },
                coroutineScope = scope,
                bounceableInfo = bounceables.bounceableInfo(it, spanIndex, column, columns),
                tileHapticsViewModelFactoryProvider = viewModel.tileHapticsViewModelFactoryProvider,
            )
        }
    }
+2 −0
Original line number Diff line number Diff line
@@ -59,6 +59,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.android.compose.modifiers.background
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 {
Loading