1 /* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.systemui.haptics.slider 18 19 import android.view.MotionEvent 20 import android.view.VelocityTracker 21 import androidx.annotation.VisibleForTesting 22 import com.android.app.tracing.coroutines.launchTraced as launch 23 import com.android.systemui.statusbar.VibratorHelper 24 import com.android.systemui.util.time.SystemClock 25 import com.google.android.msdl.domain.MSDLPlayer 26 import kotlinx.coroutines.CoroutineScope 27 import kotlinx.coroutines.Job 28 import kotlinx.coroutines.delay 29 30 /** 31 * A plugin added to a manager of a [HapticSlider] that adds dynamic haptic feedback. 32 * 33 * A [SliderStateProducer] is used as the producer of slider events, a 34 * [SliderHapticFeedbackProvider] is used as the listener of slider states to play haptic feedback 35 * depending on the state, and a [SliderStateTracker] is used as the state machine handler that 36 * tracks and manipulates the slider state. 37 */ 38 class HapticSliderPlugin 39 @JvmOverloads 40 constructor( 41 vibratorHelper: VibratorHelper, 42 msdlPlayer: MSDLPlayer, 43 systemClock: SystemClock, 44 private val slider: HapticSlider, 45 sliderHapticFeedbackConfig: SliderHapticFeedbackConfig = SliderHapticFeedbackConfig(), 46 private val sliderTrackerConfig: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(), 47 ) { 48 49 private val velocityTracker = VelocityTracker.obtain() 50 <lambda>null51 private val dragVelocityProvider = SliderDragVelocityProvider { 52 velocityTracker.computeCurrentVelocity( 53 UNITS_SECOND, 54 sliderHapticFeedbackConfig.maxVelocityToScale, 55 ) 56 if (velocityTracker.isAxisSupported(sliderHapticFeedbackConfig.velocityAxis)) { 57 velocityTracker.getAxisVelocity(sliderHapticFeedbackConfig.velocityAxis) 58 } else { 59 0f 60 } 61 } 62 63 private val sliderEventProducer = SliderStateProducer() 64 65 private val sliderHapticFeedbackProvider = 66 SliderHapticFeedbackProvider( 67 vibratorHelper, 68 msdlPlayer, 69 dragVelocityProvider, 70 sliderHapticFeedbackConfig, 71 systemClock, 72 ) 73 74 private var sliderTracker: SliderStateTracker? = null 75 76 private var pluginScope: CoroutineScope? = null 77 78 val isTracking: Boolean 79 get() = sliderTracker?.isTracking == true 80 81 val trackerState: SliderState? 82 get() = sliderTracker?.currentState 83 84 /** 85 * A waiting [Job] for a timer that estimates the key-up event when a key-down event is 86 * received. 87 * 88 * This is useful for the cases where the slider is being operated by an external key, but the 89 * release of the key is not easily accessible (e.g., the volume keys) 90 */ 91 private var keyUpJob: Job? = null 92 93 @VisibleForTesting 94 val isKeyUpTimerWaiting: Boolean 95 get() = keyUpJob != null && keyUpJob?.isActive == true 96 97 /** 98 * Specify the scope for the plugin's operations and start the slider tracker in this scope. 99 * This also involves the key-up timer job. 100 */ startInScopenull101 fun startInScope(scope: CoroutineScope) { 102 if (sliderTracker != null) stop() 103 sliderTracker = 104 SliderStateTracker( 105 sliderHapticFeedbackProvider, 106 sliderEventProducer, 107 scope, 108 sliderTrackerConfig, 109 ) 110 pluginScope = scope 111 sliderTracker?.startTracking() 112 } 113 114 /** 115 * Stop the plugin 116 * 117 * This stops the tracking of slider states, events and triggers of haptic feedback. 118 */ stopnull119 fun stop() = sliderTracker?.stopTracking() 120 121 /** React to a touch event */ 122 fun onTouchEvent(event: MotionEvent?) { 123 when (event?.actionMasked) { 124 MotionEvent.ACTION_UP, 125 MotionEvent.ACTION_CANCEL -> velocityTracker.clear() 126 MotionEvent.ACTION_DOWN, 127 MotionEvent.ACTION_MOVE -> velocityTracker.addMovement(event) 128 } 129 } 130 131 /** onStartTrackingTouch event from the slider. */ onStartTrackingTouchnull132 fun onStartTrackingTouch() { 133 if (isTracking) { 134 sliderEventProducer.onStartTracking(true) 135 } 136 } 137 138 /** onProgressChanged event from the slider's. */ onProgressChangednull139 fun onProgressChanged(progress: Int, fromUser: Boolean) { 140 if (isTracking) { 141 if (sliderTracker?.currentState == SliderState.IDLE && !fromUser) { 142 // This case translates to the slider starting to track program changes 143 sliderEventProducer.resetWithProgress(normalizeProgress(slider, progress)) 144 sliderEventProducer.onStartTracking(false) 145 } else { 146 sliderEventProducer.onProgressChanged(fromUser, normalizeProgress(slider, progress)) 147 } 148 } 149 } 150 151 /** 152 * Normalize the integer progress of a HapticSlider to the range from 0F to 1F. 153 * 154 * @param[slider] The HapticSlider that reports a progress. 155 * @param[progress] The integer progress of the HapticSlider within its min and max values. 156 * @return The progress in the range from 0F to 1F. 157 */ normalizeProgressnull158 private fun normalizeProgress(slider: HapticSlider, progress: Int): Float { 159 if (slider.max == slider.min) { 160 return 1.0f 161 } 162 return (progress - slider.min) / (slider.max - slider.min) 163 } 164 165 /** onStopTrackingTouch event from the slider. */ onStopTrackingTouchnull166 fun onStopTrackingTouch() { 167 if (isTracking) { 168 sliderEventProducer.onStopTracking(true) 169 } 170 } 171 172 /** Programmatic changes have stopped */ onStoppedTrackingProgramnull173 private fun onStoppedTrackingProgram() { 174 if (isTracking) { 175 sliderEventProducer.onStopTracking(false) 176 } 177 } 178 179 /** 180 * An external key was pressed (e.g., a volume key). 181 * 182 * This event is used to estimate the key-up event based on a running a timer as a waiting 183 * coroutine in the [pluginScope]. A key-up event in a slider corresponds to an onArrowUp event. 184 * Therefore, [onStoppedTrackingProgram] must be called after the timeout. 185 */ onKeyDownnull186 fun onKeyDown() { 187 if (!isTracking) return 188 189 if (isKeyUpTimerWaiting) { 190 // Cancel the ongoing wait 191 keyUpJob?.cancel() 192 } 193 keyUpJob = 194 pluginScope?.launch { 195 delay(KEY_UP_TIMEOUT) 196 onStoppedTrackingProgram() 197 } 198 } 199 200 companion object { 201 const val KEY_UP_TIMEOUT = 60L 202 private const val UNITS_SECOND = 1000 203 } 204 } 205