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

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

Introducing a SliderHapticsViewModel for haptics in Compose sliders.

The view-model integrates the stack of components needed for slider
haptics in composable slider UIs.

Test: atest SliderHapticsViewModelTest
Flag: NONE usage of the view-model will be flagged separately
Bug: 341968766
Change-Id: I3b4ffee54b3c3f85798bc9f782f86b2605b60c79
parent 9144c23b
Loading
Loading
Loading
Loading
+177 −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.slider.compose.ui

import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.InteractionSource
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.haptics.slider.SeekableSliderTrackerConfig
import com.android.systemui.haptics.slider.SliderEventType
import com.android.systemui.haptics.slider.SliderHapticFeedbackConfig
import com.android.systemui.haptics.slider.sliderHapticsViewModelFactory
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
class SliderHapticsViewModelTest : SysuiTestCase() {

    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    private val interactionSource = DragInteractionSourceTest()
    private val underTest =
        kosmos.sliderHapticsViewModelFactory.create(
            interactionSource,
            0f..1f,
            Orientation.Horizontal,
            SliderHapticFeedbackConfig(),
            SeekableSliderTrackerConfig(),
        )

    @Before
    fun setUp() {
        underTest.activateIn(testScope)
    }

    @Test
    fun onActivated_startsRunning() =
        testScope.runTest {
            // WHEN the view-model is activated
            testScope.runCurrent()

            // THEN the view-model starts running
            assertThat(underTest.isRunning).isTrue()
        }

    @Test
    fun onDragStart_goesToUserStartedDragging() =
        testScope.runTest {
            // WHEN a drag interaction starts
            interactionSource.setDragInteraction(DragInteraction.Start())
            runCurrent()

            // THEN the current slider event type shows that the user started dragging
            assertThat(underTest.currentSliderEventType)
                .isEqualTo(SliderEventType.STARTED_TRACKING_TOUCH)
        }

    @Test
    fun onValueChange_whileUserStartedDragging_goesToUserDragging() =
        testScope.runTest {
            // WHEN a drag interaction starts
            interactionSource.setDragInteraction(DragInteraction.Start())
            runCurrent()

            // WHEN a value changes in the slider
            underTest.onValueChange(0.5f)

            // THEN the current slider event type shows that the user is dragging
            assertThat(underTest.currentSliderEventType)
                .isEqualTo(SliderEventType.PROGRESS_CHANGE_BY_USER)
        }

    @Test
    fun onValueChange_whileUserDragging_staysInUserDragging() =
        testScope.runTest {
            // WHEN a drag interaction starts and the user keeps dragging
            interactionSource.setDragInteraction(DragInteraction.Start())
            runCurrent()
            underTest.onValueChange(0.5f)

            // WHEN value changes continue to occur due to dragging
            underTest.onValueChange(0.6f)

            // THEN the current slider event type reflects that the user continues to drag
            assertThat(underTest.currentSliderEventType)
                .isEqualTo(SliderEventType.PROGRESS_CHANGE_BY_USER)
        }

    @Test
    fun onValueChange_whileNOTHING_goesToProgramStartedDragging() =
        testScope.runTest {
            // WHEN a value change occurs without a drag interaction
            underTest.onValueChange(0.5f)

            // THEN the current slider event type shows that the program started dragging
            assertThat(underTest.currentSliderEventType)
                .isEqualTo(SliderEventType.STARTED_TRACKING_PROGRAM)
        }

    @Test
    fun onValueChange_whileProgramStartedDragging_goesToProgramDragging() =
        testScope.runTest {
            // WHEN the program starts dragging
            underTest.onValueChange(0.5f)

            // WHEN the program continues to make value changes
            underTest.onValueChange(0.6f)

            // THEN the current slider event type shows that program is dragging
            assertThat(underTest.currentSliderEventType)
                .isEqualTo(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM)
        }

    @Test
    fun onValueChange_whileProgramDragging_staysInProgramDragging() =
        testScope.runTest {
            // WHEN the program starts and continues to drag
            underTest.onValueChange(0.5f)
            underTest.onValueChange(0.6f)

            // WHEN value changes continue to occur
            underTest.onValueChange(0.7f)

            // THEN the current slider event type shows that the program is dragging the slider
            assertThat(underTest.currentSliderEventType)
                .isEqualTo(SliderEventType.PROGRESS_CHANGE_BY_PROGRAM)
        }

    @Test
    fun onValueChangeEnded_goesToNOTHING() =
        testScope.runTest {
            // WHEN changes end in the slider
            underTest.onValueChangeEnded()

            // THEN the current slider event type always resets to NOTHING
            assertThat(underTest.currentSliderEventType).isEqualTo(SliderEventType.NOTHING)
        }

    private class DragInteractionSourceTest : InteractionSource {
        private val _interactions = MutableStateFlow<DragInteraction>(IdleDrag)
        override val interactions = _interactions.asStateFlow()

        fun setDragInteraction(interaction: DragInteraction) {
            _interactions.value = interaction
        }
    }

    private object IdleDrag : DragInteraction
}
+193 −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.slider.compose.ui

import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.interaction.DragInteraction
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.unit.Velocity
import com.android.app.tracing.coroutines.launch
import com.android.systemui.haptics.slider.SeekableSliderTrackerConfig
import com.android.systemui.haptics.slider.SliderDragVelocityProvider
import com.android.systemui.haptics.slider.SliderEventType
import com.android.systemui.haptics.slider.SliderHapticFeedbackConfig
import com.android.systemui.haptics.slider.SliderHapticFeedbackProvider
import com.android.systemui.haptics.slider.SliderStateProducer
import com.android.systemui.haptics.slider.SliderStateTracker
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.util.time.SystemClock
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlin.math.abs
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.coroutineScope

class SliderHapticsViewModel
@AssistedInject
constructor(
    @Assisted private val interactionSource: InteractionSource,
    @Assisted private val sliderRange: ClosedFloatingPointRange<Float>,
    @Assisted private val orientation: Orientation,
    @Assisted private val sliderHapticFeedbackConfig: SliderHapticFeedbackConfig,
    @Assisted private val sliderTrackerConfig: SeekableSliderTrackerConfig,
    vibratorHelper: VibratorHelper,
    systemClock: SystemClock,
) : ExclusiveActivatable() {

    var currentSliderEventType = SliderEventType.NOTHING
        private set

    private val velocityTracker = VelocityTracker()
    private val maxVelocity =
        Velocity(
            sliderHapticFeedbackConfig.maxVelocityToScale,
            sliderHapticFeedbackConfig.maxVelocityToScale,
        )
    private val dragVelocityProvider = SliderDragVelocityProvider {
        val velocity =
            when (orientation) {
                Orientation.Horizontal -> velocityTracker.calculateVelocity(maxVelocity).x
                Orientation.Vertical -> velocityTracker.calculateVelocity(maxVelocity).y
            }
        abs(velocity)
    }

    private var startingProgress = 0f

    // Haptic slider stack of components
    private val sliderStateProducer = SliderStateProducer()
    private val sliderHapticFeedbackProvider =
        SliderHapticFeedbackProvider(
            vibratorHelper,
            dragVelocityProvider,
            sliderHapticFeedbackConfig,
            systemClock,
        )
    private var sliderTracker: SliderStateTracker? = null

    private var trackerJob: Job? = null

    val isRunning: Boolean
        get() = trackerJob?.isActive == true && sliderTracker?.isTracking == true

    override suspend fun onActivated(): Nothing {
        coroutineScope {
            trackerJob =
                launch("SliderHapticsViewModel#SliderStateTracker") {
                    try {
                        sliderTracker =
                            SliderStateTracker(
                                sliderHapticFeedbackProvider,
                                sliderStateProducer,
                                this,
                                sliderTrackerConfig,
                            )
                        sliderTracker?.startTracking()
                        awaitCancellation()
                    } finally {
                        sliderTracker?.stopTracking()
                        sliderTracker = null
                        velocityTracker.resetTracking()
                    }
                }

            launch("SliderHapticsViewModel#InteractionSource") {
                interactionSource.interactions.collect { interaction ->
                    if (interaction is DragInteraction.Start) {
                        currentSliderEventType = SliderEventType.STARTED_TRACKING_TOUCH
                        sliderStateProducer.onStartTracking(true)
                    }
                }
            }
            awaitCancellation()
        }
    }

    /**
     * React to a value change in the slider.
     *
     * @param[value] latest value of the slider inside the [sliderRange] provided to the class
     *   constructor.
     */
    fun onValueChange(value: Float) {
        val normalized = value.normalize()
        when (currentSliderEventType) {
            SliderEventType.NOTHING -> {
                currentSliderEventType = SliderEventType.STARTED_TRACKING_PROGRAM
                startingProgress = normalized
                sliderStateProducer.resetWithProgress(normalized)
                sliderStateProducer.onStartTracking(false)
            }
            SliderEventType.STARTED_TRACKING_TOUCH -> {
                startingProgress = normalized
                currentSliderEventType = SliderEventType.PROGRESS_CHANGE_BY_USER
            }
            SliderEventType.PROGRESS_CHANGE_BY_USER -> {
                velocityTracker.addPosition(System.currentTimeMillis(), normalized.toOffset())
                currentSliderEventType = SliderEventType.PROGRESS_CHANGE_BY_USER
                sliderStateProducer.onProgressChanged(true, normalized)
            }
            SliderEventType.STARTED_TRACKING_PROGRAM -> {
                startingProgress = normalized
                currentSliderEventType = SliderEventType.PROGRESS_CHANGE_BY_PROGRAM
            }
            SliderEventType.PROGRESS_CHANGE_BY_PROGRAM -> {
                velocityTracker.addPosition(System.currentTimeMillis(), normalized.toOffset())
                currentSliderEventType = SliderEventType.PROGRESS_CHANGE_BY_PROGRAM
                sliderStateProducer.onProgressChanged(false, normalized)
            }
            else -> {}
        }
    }

    fun onValueChangeEnded() {
        when (currentSliderEventType) {
            SliderEventType.STARTED_TRACKING_PROGRAM,
            SliderEventType.PROGRESS_CHANGE_BY_PROGRAM -> sliderStateProducer.onStopTracking(false)
            SliderEventType.STARTED_TRACKING_TOUCH,
            SliderEventType.PROGRESS_CHANGE_BY_USER -> sliderStateProducer.onStopTracking(true)
            else -> {}
        }
        currentSliderEventType = SliderEventType.NOTHING
        velocityTracker.resetTracking()
    }

    private fun Float.normalize(): Float =
        (this / (sliderRange.endInclusive - sliderRange.start)).coerceIn(0f, 1f)

    private fun Float.toOffset(): Offset =
        when (orientation) {
            Orientation.Horizontal -> Offset(x = this - startingProgress, y = 0f)
            Orientation.Vertical -> Offset(x = 0f, y = this - startingProgress)
        }

    @AssistedFactory
    interface Factory {
        fun create(
            interactionSource: InteractionSource,
            sliderRange: ClosedFloatingPointRange<Float>,
            orientation: Orientation,
            sliderHapticFeedbackConfig: SliderHapticFeedbackConfig,
            sliderTrackerConfig: SeekableSliderTrackerConfig,
        ): SliderHapticsViewModel
    }
}
+46 −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.slider

import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.interaction.InteractionSource
import com.android.systemui.haptics.slider.compose.ui.SliderHapticsViewModel
import com.android.systemui.haptics.vibratorHelper
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.util.time.fakeSystemClock

val Kosmos.sliderHapticsViewModelFactory by
    Kosmos.Fixture {
        object : SliderHapticsViewModel.Factory {
            override fun create(
                interactionSource: InteractionSource,
                sliderRange: ClosedFloatingPointRange<Float>,
                orientation: Orientation,
                sliderHapticFeedbackConfig: SliderHapticFeedbackConfig,
                sliderTrackerConfig: SeekableSliderTrackerConfig,
            ): SliderHapticsViewModel =
                SliderHapticsViewModel(
                    interactionSource,
                    sliderRange,
                    orientation,
                    sliderHapticFeedbackConfig,
                    sliderTrackerConfig,
                    vibratorHelper,
                    fakeSystemClock,
                )
        }
    }