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 android.widget.SeekBar 22 import androidx.annotation.VisibleForTesting 23 import com.android.systemui.statusbar.VibratorHelper 24 import com.android.systemui.util.time.SystemClock 25 import kotlinx.coroutines.CoroutineScope 26 import kotlinx.coroutines.Job 27 import kotlinx.coroutines.delay 28 import kotlinx.coroutines.launch 29 30 /** 31 * A plugin added to a manager of a [android.widget.SeekBar] 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 SeekbarHapticPlugin 39 @JvmOverloads 40 constructor( 41 vibratorHelper: VibratorHelper, 42 systemClock: SystemClock, 43 sliderHapticFeedbackConfig: SliderHapticFeedbackConfig = SliderHapticFeedbackConfig(), 44 private val sliderTrackerConfig: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(), 45 ) { 46 47 private val velocityTracker = VelocityTracker.obtain() 48 49 private val sliderEventProducer = SliderStateProducer() 50 51 private val sliderHapticFeedbackProvider = 52 SliderHapticFeedbackProvider( 53 vibratorHelper, 54 velocityTracker, 55 sliderHapticFeedbackConfig, 56 systemClock, 57 ) 58 59 private var sliderTracker: SliderStateTracker? = null 60 61 private var pluginScope: CoroutineScope? = null 62 63 val isTracking: Boolean 64 get() = sliderTracker?.isTracking == true 65 66 val trackerState: SliderState? 67 get() = sliderTracker?.currentState 68 69 /** 70 * A waiting [Job] for a timer that estimates the key-up event when a key-down event is 71 * received. 72 * 73 * This is useful for the cases where the slider is being operated by an external key, but the 74 * release of the key is not easily accessible (e.g., the volume keys) 75 */ 76 private var keyUpJob: Job? = null 77 78 @VisibleForTesting 79 val isKeyUpTimerWaiting: Boolean 80 get() = keyUpJob != null && keyUpJob?.isActive == true 81 82 /** 83 * Specify the scope for the plugin's operations and start the slider tracker in this scope. 84 * This also involves the key-up timer job. 85 */ startInScopenull86 fun startInScope(scope: CoroutineScope) { 87 if (sliderTracker != null) stop() 88 sliderTracker = 89 SliderStateTracker( 90 sliderHapticFeedbackProvider, 91 sliderEventProducer, 92 scope, 93 sliderTrackerConfig, 94 ) 95 pluginScope = scope 96 sliderTracker?.startTracking() 97 } 98 99 /** 100 * Stop the plugin 101 * 102 * This stops the tracking of slider states, events and triggers of haptic feedback. 103 */ stopnull104 fun stop() = sliderTracker?.stopTracking() 105 106 /** React to a touch event */ 107 fun onTouchEvent(event: MotionEvent?) { 108 when (event?.actionMasked) { 109 MotionEvent.ACTION_UP, 110 MotionEvent.ACTION_CANCEL -> velocityTracker.clear() 111 MotionEvent.ACTION_DOWN, 112 MotionEvent.ACTION_MOVE -> velocityTracker.addMovement(event) 113 } 114 } 115 116 /** onStartTrackingTouch event from the slider's [android.widget.SeekBar] */ onStartTrackingTouchnull117 fun onStartTrackingTouch(seekBar: SeekBar) { 118 if (isTracking) { 119 sliderEventProducer.onStartTracking(true) 120 } 121 } 122 123 /** onProgressChanged event from the slider's [android.widget.SeekBar] */ onProgressChangednull124 fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { 125 if (isTracking) { 126 if (sliderTracker?.currentState == SliderState.IDLE && !fromUser) { 127 // This case translates to the slider starting to track program changes 128 sliderEventProducer.resetWithProgress(normalizeProgress(seekBar, progress)) 129 sliderEventProducer.onStartTracking(false) 130 } else { 131 sliderEventProducer.onProgressChanged( 132 fromUser, 133 normalizeProgress(seekBar, progress), 134 ) 135 } 136 } 137 } 138 139 /** 140 * Normalize the integer progress of a SeekBar to the range from 0F to 1F. 141 * 142 * @param[seekBar] The SeekBar that reports a progress. 143 * @param[progress] The integer progress of the SeekBar within its min and max values. 144 * @return The progress in the range from 0F to 1F. 145 */ normalizeProgressnull146 private fun normalizeProgress(seekBar: SeekBar, progress: Int): Float { 147 if (seekBar.max == seekBar.min) { 148 return 1.0f 149 } 150 val range = seekBar.max - seekBar.min 151 return (progress - seekBar.min) / range.toFloat() 152 } 153 154 /** onStopTrackingTouch event from the slider's [android.widget.SeekBar] */ onStopTrackingTouchnull155 fun onStopTrackingTouch(seekBar: SeekBar) { 156 if (isTracking) { 157 sliderEventProducer.onStopTracking(true) 158 } 159 } 160 161 /** Programmatic changes have stopped */ onStoppedTrackingProgramnull162 private fun onStoppedTrackingProgram() { 163 if (isTracking) { 164 sliderEventProducer.onStopTracking(false) 165 } 166 } 167 168 /** 169 * An external key was pressed (e.g., a volume key). 170 * 171 * This event is used to estimate the key-up event based on a running a timer as a waiting 172 * coroutine in the [pluginScope]. A key-up event in a slider corresponds to an onArrowUp event. 173 * Therefore, [onStoppedTrackingProgram] must be called after the timeout. 174 */ onKeyDownnull175 fun onKeyDown() { 176 if (!isTracking) return 177 178 if (isKeyUpTimerWaiting) { 179 // Cancel the ongoing wait 180 keyUpJob?.cancel() 181 } 182 keyUpJob = 183 pluginScope?.launch { 184 delay(KEY_UP_TIMEOUT) 185 onStoppedTrackingProgram() 186 } 187 } 188 189 companion object { 190 const val KEY_UP_TIMEOUT = 60L 191 } 192 } 193