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

Commit 036c9104 authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez Committed by Android (Google) Code Review
Browse files

Merge changes from topic "cherrypicker-L38500000962976264:N42100001403649209" into main

* changes:
  Introducing haptic components to the brightness slider.
  Introducing Slider Tracker components for sliders in SysUI.
  Introducing the Slider State Listener components for sliders in SysUI.
parents a1e92cd4 3068b00e
Loading
Loading
Loading
Loading
+212 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.annotation.VisibleForTesting
import com.android.systemui.dagger.qualifiers.Main
import kotlin.math.abs
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

/**
 * Slider tracker attached to a seekable slider.
 *
 * The tracker runs a state machine to execute actions on touch-based events typical of a seekable
 * slider such as [android.widget.SeekBar]. Coroutines responsible for running the state machine,
 * collecting slider events and maintaining waiting states are run on the main thread via the
 * [com.android.systemui.dagger.qualifiers.Main] coroutine dispatcher.
 *
 * @param[sliderStateListener] Listener of the slider state.
 * @param[sliderEventProducer] Producer of slider events arising from the slider.
 * @property[scope] [CoroutineScope] where the collection of slider events and the launch of timer
 *   jobs occur.
 * @property[config] Configuration parameters of the slider tracker.
 */
class SeekableSliderTracker(
    sliderStateListener: SliderStateListener,
    sliderEventProducer: SliderEventProducer,
    @Main mainDispatcher: CoroutineDispatcher,
    private val config: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(),
) : SliderTracker(CoroutineScope(mainDispatcher), sliderStateListener, sliderEventProducer) {

    // History of the latest progress collected from slider events
    private var latestProgress = 0f
    // Timer job for the wait state
    private var timerJob: Job? = null
    // Indicator that there is waiting job active
    var isWaiting = false
        private set
        get() = timerJob != null && timerJob?.isActive == true

    override suspend fun iterateState(event: SliderEvent) {
        when (currentState) {
            SliderState.IDLE -> handleIdle(event.type)
            SliderState.WAIT -> handleWait(event.type, event.currentProgress)
            SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH -> handleAcquired(event.type)
            SliderState.DRAG_HANDLE_DRAGGING -> handleDragging(event.type, event.currentProgress)
            SliderState.DRAG_HANDLE_REACHED_BOOKEND ->
                handleReachedBookend(event.type, event.currentProgress)
            SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH -> setState(SliderState.IDLE)
            SliderState.JUMP_TRACK_LOCATION_SELECTED -> handleJumpToTrack(event.type)
            SliderState.JUMP_BOOKEND_SELECTED -> handleJumpToBookend(event.type)
        }
        latestProgress = event.currentProgress
    }

    private fun handleIdle(newEventType: SliderEventType) {
        if (newEventType == SliderEventType.STARTED_TRACKING_TOUCH) {
            timerJob = launchTimer()
            // The WAIT state will wait for the timer to complete or a slider progress to occur.
            // This will disambiguate between an imprecise touch that acquires the slider handle,
            // and a select and jump operation in the slider track.
            setState(SliderState.WAIT)
        }
    }

    private fun launchTimer() =
        scope.launch {
            delay(config.waitTimeMillis)
            if (isActive && currentState == SliderState.WAIT) {
                setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH)
                // This transitory state must also trigger the corresponding action
                executeOnState(currentState)
            }
        }

    private fun handleWait(newEventType: SliderEventType, currentProgress: Float) {
        // The timer may have completed and may have already modified the state
        if (currentState != SliderState.WAIT) return

        // The timer is still running but the state may be modified by the progress change
        val deltaProgressIsJump = deltaProgressIsAboveThreshold(currentProgress)
        if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) {
            if (bookendReached(currentProgress)) {
                setState(SliderState.JUMP_BOOKEND_SELECTED)
            } else if (deltaProgressIsJump) {
                setState(SliderState.JUMP_TRACK_LOCATION_SELECTED)
            } else {
                setState(SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH)
            }
        } else if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) {
            setState(SliderState.IDLE)
        }

        // If the state changed, the timer does not need to complete. No further synchronization
        // will be required onwards until WAIT is reached again.
        if (currentState != SliderState.WAIT) {
            timerJob?.cancel()
            timerJob = null
        }
    }

    private fun handleAcquired(newEventType: SliderEventType) {
        if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) {
            setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
        } else if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) {
            setState(SliderState.DRAG_HANDLE_DRAGGING)
        }
    }

    private fun handleDragging(newEventType: SliderEventType, currentProgress: Float) {
        if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) {
            setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
        } else if (
            newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER &&
                bookendReached(currentProgress)
        ) {
            setState(SliderState.DRAG_HANDLE_REACHED_BOOKEND)
        }
    }

    private fun handleReachedBookend(newEventType: SliderEventType, currentProgress: Float) {
        if (newEventType == SliderEventType.PROGRESS_CHANGE_BY_USER) {
            if (!bookendReached(currentProgress)) {
                setState(SliderState.DRAG_HANDLE_DRAGGING)
            }
        } else if (newEventType == SliderEventType.STOPPED_TRACKING_TOUCH) {
            setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
        }
    }

    private fun handleJumpToTrack(newEventType: SliderEventType) {
        when (newEventType) {
            SliderEventType.PROGRESS_CHANGE_BY_USER -> setState(SliderState.DRAG_HANDLE_DRAGGING)
            SliderEventType.STOPPED_TRACKING_TOUCH ->
                setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
            else -> {}
        }
    }

    private fun handleJumpToBookend(newEventType: SliderEventType) {
        when (newEventType) {
            SliderEventType.PROGRESS_CHANGE_BY_USER -> setState(SliderState.DRAG_HANDLE_DRAGGING)
            SliderEventType.STOPPED_TRACKING_TOUCH ->
                setState(SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH)
            else -> {}
        }
    }

    override fun executeOnState(currentState: SliderState) {
        when (currentState) {
            SliderState.DRAG_HANDLE_ACQUIRED_BY_TOUCH -> sliderListener.onHandleAcquiredByTouch()
            SliderState.DRAG_HANDLE_RELEASED_FROM_TOUCH -> {
                sliderListener.onHandleReleasedFromTouch()
                // This transitory state must also reset the state machine
                resetState()
            }
            SliderState.DRAG_HANDLE_DRAGGING -> sliderListener.onProgress(latestProgress)
            SliderState.DRAG_HANDLE_REACHED_BOOKEND -> executeOnBookend()
            SliderState.JUMP_TRACK_LOCATION_SELECTED ->
                sliderListener.onProgressJump(latestProgress)
            SliderState.JUMP_BOOKEND_SELECTED -> executeOnBookend()
            else -> {}
        }
    }

    private fun executeOnBookend() {
        if (latestProgress >= config.upperBookendThreshold) sliderListener.onUpperBookend()
        else sliderListener.onLowerBookend()
    }

    override fun resetState() {
        timerJob?.cancel()
        timerJob = null
        super.resetState()
    }

    private fun deltaProgressIsAboveThreshold(
        currentProgress: Float,
        epsilon: Float = 0.00001f,
    ): Boolean {
        val delta = abs(currentProgress - latestProgress)
        return abs(delta - config.jumpThreshold) < epsilon
    }

    private fun bookendReached(currentProgress: Float): Boolean {
        return currentProgress >= config.upperBookendThreshold ||
            currentProgress <= config.lowerBookendThreshold
    }

    @VisibleForTesting
    fun setState(state: SliderState) {
        currentState = state
    }
}
+37 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.annotation.FloatRange

/**
 * Configuration parameters of a seekable slider tracker.
 *
 * @property[waitTimeMillis] Wait period to determine if a touch event acquires the slider handle.
 * @property[jumpThreshold] Threshold on the slider progress to detect if a touch event is qualified
 *   as an imprecise acquisition of the slider handle.
 * @property[lowerBookendThreshold] Threshold to determine the progress on the slider that qualifies
 *   as reaching the lower bookend.
 * @property[upperBookendThreshold] Threshold to determine the progress on the slider that qualifies
 *   as reaching the upper bookend.
 */
data class SeekableSliderTrackerConfig(
    val waitTimeMillis: Long = 100,
    @FloatRange(from = 0.0, to = 1.0) val jumpThreshold: Float = 0.02f,
    @FloatRange(from = 0.0, to = 1.0) val lowerBookendThreshold: Float = 0.05f,
    @FloatRange(from = 0.0, to = 1.0) val upperBookendThreshold: Float = 0.95f,
)
+43 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.annotation.FloatRange

/** Configuration parameters of a [SliderHapticFeedbackProvider] */
data class SliderHapticFeedbackConfig(
    /** Interpolator factor for velocity-based vibration scale interpolations. Must be positive */
    val velocityInterpolatorFactor: Float = 1f,
    /** Interpolator factor for progress-based vibration scale interpolations. Must be positive */
    val progressInterpolatorFactor: Float = 1f,
    /** Minimum vibration scale for vibrations based on slider progress */
    @FloatRange(from = 0.0, to = 1.0) val progressBasedDragMinScale: Float = 0f,
    /** Maximum vibration scale for vibrations based on slider progress */
    @FloatRange(from = 0.0, to = 1.0) val progressBasedDragMaxScale: Float = 0.2f,
    /** Additional vibration scaling due to velocity */
    @FloatRange(from = 0.0, to = 1.0) val additionalVelocityMaxBump: Float = 0.15f,
    /** Additional time delta to wait between drag texture vibrations */
    @FloatRange(from = 0.0) val deltaMillisForDragInterval: Float = 0f,
    /** Number of low ticks in a drag texture composition. This is not expected to change */
    val numberOfLowTicks: Int = 5,
    /** Maximum velocity allowed for vibration scaling. This is not expected to change. */
    val maxVelocityToScale: Float = 2000f, /* In pixels/sec */
    /** Vibration scale at the upper bookend of the slider */
    @FloatRange(from = 0.0, to = 1.0) val upperBookendScale: Float = 1f,
    /** Vibration scale at the lower bookend of the slider */
    @FloatRange(from = 0.0, to = 1.0) val lowerBookendScale: Float = 0.05f,
)
+160 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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 android.os.VibrationAttributes
import android.os.VibrationEffect
import android.view.VelocityTracker
import android.view.animation.AccelerateInterpolator
import androidx.annotation.FloatRange
import com.android.systemui.statusbar.VibratorHelper
import kotlin.math.abs
import kotlin.math.min

/**
 * Listener of slider events that triggers haptic feedback.
 *
 * @property[vibratorHelper] Singleton instance of the [VibratorHelper] to deliver haptics.
 * @property[velocityTracker] Instance of a [VelocityTracker] that tracks slider dragging velocity.
 * @property[config] Configuration parameters for vibration encapsulated as a
 *   [SliderHapticFeedbackConfig].
 * @property[clock] Clock to obtain elapsed real time values.
 */
class SliderHapticFeedbackProvider(
    private val vibratorHelper: VibratorHelper,
    private val velocityTracker: VelocityTracker,
    private val config: SliderHapticFeedbackConfig = SliderHapticFeedbackConfig(),
    private val clock: com.android.systemui.util.time.SystemClock,
) : SliderStateListener {

    private val velocityAccelerateInterpolator =
        AccelerateInterpolator(config.velocityInterpolatorFactor)
    private val positionAccelerateInterpolator =
        AccelerateInterpolator(config.progressInterpolatorFactor)
    private var dragTextureLastTime = clock.elapsedRealtime()
    private val lowTickDurationMs =
        vibratorHelper.getPrimitiveDurations(VibrationEffect.Composition.PRIMITIVE_LOW_TICK)[0]
    private var hasVibratedAtLowerBookend = false
    private var hasVibratedAtUpperBookend = false

    /** Time threshold to wait before making new API call. */
    private val thresholdUntilNextDragCallMillis =
        lowTickDurationMs * config.numberOfLowTicks + config.deltaMillisForDragInterval

    /**
     * Vibrate when the handle reaches either bookend with a certain velocity.
     *
     * @param[absoluteVelocity] Velocity of the handle when it reached the bookend.
     */
    private fun vibrateOnEdgeCollision(absoluteVelocity: Float) {
        val velocityInterpolated =
            velocityAccelerateInterpolator.getInterpolation(
                min(absoluteVelocity / config.maxVelocityToScale, 1f)
            )
        val bookendScaleRange = config.upperBookendScale - config.lowerBookendScale
        val bookendsHitScale = bookendScaleRange * velocityInterpolated + config.lowerBookendScale

        val vibration =
            VibrationEffect.startComposition()
                .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, bookendsHitScale)
                .compose()
        vibratorHelper.vibrate(vibration, VIBRATION_ATTRIBUTES_PIPELINING)
    }

    /**
     * Create a drag texture vibration based on velocity and slider progress.
     *
     * @param[absoluteVelocity] Absolute velocity of the handle.
     * @param[normalizedSliderProgress] Progress of the slider handled normalized to the range from
     *   0F to 1F (inclusive).
     */
    private fun vibrateDragTexture(
        absoluteVelocity: Float,
        @FloatRange(from = 0.0, to = 1.0) normalizedSliderProgress: Float
    ) {
        // Check if its time to vibrate
        val currentTime = clock.elapsedRealtime()
        val elapsedSinceLastDrag = currentTime - dragTextureLastTime
        if (elapsedSinceLastDrag < thresholdUntilNextDragCallMillis) return

        val velocityInterpolated =
            velocityAccelerateInterpolator.getInterpolation(
                min(absoluteVelocity / config.maxVelocityToScale, 1f)
            )

        // Scaling of vibration due to the position of the slider
        val positionScaleRange = config.progressBasedDragMaxScale - config.progressBasedDragMinScale
        val sliderProgressInterpolated =
            positionAccelerateInterpolator.getInterpolation(normalizedSliderProgress)
        val positionBasedScale =
            positionScaleRange * sliderProgressInterpolated + config.progressBasedDragMinScale

        // Scaling bump due to velocity
        val velocityBasedScale = velocityInterpolated * config.additionalVelocityMaxBump

        // Total scale
        val scale = positionBasedScale + velocityBasedScale

        // Trigger the vibration composition
        val composition = VibrationEffect.startComposition()
        repeat(config.numberOfLowTicks) {
            composition.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, scale)
        }
        vibratorHelper.vibrate(composition.compose(), VIBRATION_ATTRIBUTES_PIPELINING)
        dragTextureLastTime = currentTime
    }

    override fun onHandleAcquiredByTouch() {}

    override fun onHandleReleasedFromTouch() {}

    override fun onLowerBookend() {
        if (!hasVibratedAtLowerBookend) {
            velocityTracker.computeCurrentVelocity(UNITS_SECOND, config.maxVelocityToScale)
            vibrateOnEdgeCollision(abs(velocityTracker.xVelocity))
            hasVibratedAtLowerBookend = true
        }
    }

    override fun onUpperBookend() {
        if (!hasVibratedAtUpperBookend) {
            velocityTracker.computeCurrentVelocity(UNITS_SECOND, config.maxVelocityToScale)
            vibrateOnEdgeCollision(abs(velocityTracker.xVelocity))
            hasVibratedAtUpperBookend = true
        }
    }

    override fun onProgress(@FloatRange(from = 0.0, to = 1.0) progress: Float) {
        velocityTracker.computeCurrentVelocity(UNITS_SECOND, config.maxVelocityToScale)
        vibrateDragTexture(abs(velocityTracker.xVelocity), progress)
        hasVibratedAtUpperBookend = false
        hasVibratedAtLowerBookend = false
    }

    override fun onProgressJump(@FloatRange(from = 0.0, to = 1.0) progress: Float) {}

    override fun onSelectAndArrow(@FloatRange(from = 0.0, to = 1.0) progress: Float) {}

    private companion object {
        private val VIBRATION_ATTRIBUTES_PIPELINING =
            VibrationAttributes.Builder()
                .setUsage(VibrationAttributes.USAGE_TOUCH)
                .setFlags(VibrationAttributes.FLAG_PIPELINED_EFFECT)
                .build()
        private const val UNITS_SECOND = 1000
    }
}
+37 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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

/** State of a slider */
enum class SliderState {
    /* The slider is idle */
    IDLE,
    /* Waiting state to disambiguate between handle acquisition and select and jump operations */
    WAIT,
    /* The slider handle was acquired by touch. */
    DRAG_HANDLE_ACQUIRED_BY_TOUCH,
    /* The slider handle was released. */
    DRAG_HANDLE_RELEASED_FROM_TOUCH,
    /* The slider handle is being dragged by touch. */
    DRAG_HANDLE_DRAGGING,
    /* The slider handle reached a bookend. */
    DRAG_HANDLE_REACHED_BOOKEND,
    /* A location in the slider track has been selected. */
    JUMP_TRACK_LOCATION_SELECTED,
    /* The slider handled moved to a bookend after it was selected. */
    JUMP_BOOKEND_SELECTED,
}
Loading