diff options
6 files changed, 547 insertions, 0 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackConfig.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackConfig.kt new file mode 100644 index 000000000000..20d99d1e75fb --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackConfig.kt @@ -0,0 +1,43 @@ +/* + * 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, +) diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProvider.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProvider.kt new file mode 100644 index 000000000000..e6de156de0c4 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProvider.kt @@ -0,0 +1,160 @@ +/* + * 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 + } +} diff --git a/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateListener.kt b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateListener.kt new file mode 100644 index 000000000000..9c99c90bb910 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/haptics/slider/SliderStateListener.kt @@ -0,0 +1,64 @@ +/* + * 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 + +/** Listener of events from a slider (such as [android.widget.SeekBar]) */ +interface SliderStateListener { + + /** Notification that the handle is acquired by touch */ + fun onHandleAcquiredByTouch() + + /** Notification that the handle was released from touch */ + fun onHandleReleasedFromTouch() + + /** Notification that the handle reached the lower bookend */ + fun onLowerBookend() + + /** Notification that the handle reached the upper bookend */ + fun onUpperBookend() + + /** + * Notification that the slider reached a certain progress on the slider track. + * + * This method is called in all intermediate steps of a continuous progress change as the slider + * moves through the slider track. + * + * @param[progress] The progress of the slider in the range from 0F to 1F (inclusive). + */ + fun onProgress(@FloatRange(from = 0.0, to = 1.0) progress: Float) + + /** + * Notification that the slider handle jumped to a selected progress on the slider track. + * + * This method is specific to the case when the handle performed a single jump to a position on + * the slider track and reached the corresponding progress. In this case, [onProgress] is not + * called and the new progress reached is represented by the [progress] parameter. + * + * @param[progress] The selected progress on the slider track that the handle jumps to. The + * progress is in the range from 0F to 1F (inclusive). + */ + fun onProgressJump(@FloatRange(from = 0.0, to = 1.0) progress: Float) + + /** + * Notification that the slider handle was moved by a button press. + * + * @param[progress] The progress of the slider in the range from 0F to 1F (inclusive). + */ + fun onSelectAndArrow(@FloatRange(from = 0.0, to = 1.0) progress: Float) +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/VibratorHelper.java b/packages/SystemUI/src/com/android/systemui/statusbar/VibratorHelper.java index 645595c1f7bf..d089252759c7 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/VibratorHelper.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/VibratorHelper.java @@ -118,6 +118,16 @@ public class VibratorHelper { } /** + * @see Vibrator#vibrate(VibrationEffect, VibrationAttributes) + */ + public void vibrate(@NonNull VibrationEffect effect, @NonNull VibrationAttributes attributes) { + if (!hasVibrator()) { + return; + } + mExecutor.execute(() -> mVibrator.vibrate(effect, attributes)); + } + + /** * @see Vibrator#hasVibrator() */ public boolean hasVibrator() { @@ -154,6 +164,17 @@ public class VibratorHelper { } /** + * @see Vibrator#getPrimitiveDurations(int...) + */ + public int[] getPrimitiveDurations(int... primitiveIds) { + if (!hasVibrator()) { + return new int[]{0}; + } else { + return mVibrator.getPrimitiveDurations(primitiveIds); + } + } + + /** * Perform a vibration using a view and the one-way API with flags * @see View#performHapticFeedback(int feedbackConstant, int flags) */ diff --git a/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProviderTest.kt b/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProviderTest.kt new file mode 100644 index 000000000000..0ee348e0d23d --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/haptics/slider/SliderHapticFeedbackProviderTest.kt @@ -0,0 +1,247 @@ +/* + * 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.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.VibratorHelper +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.whenever +import com.android.systemui.util.time.FakeSystemClock +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@SmallTest +@RunWith(AndroidJUnit4::class) +class SliderHapticFeedbackProviderTest : SysuiTestCase() { + + @Mock private lateinit var velocityTracker: VelocityTracker + @Mock private lateinit var vibratorHelper: VibratorHelper + + private val config = SliderHapticFeedbackConfig() + private val clock = FakeSystemClock() + + private val lowTickDuration = 12 // Mocked duration of a low tick + private val dragTextureThresholdMillis = + lowTickDuration * config.numberOfLowTicks + config.deltaMillisForDragInterval + private val progressInterpolator = AccelerateInterpolator(config.progressInterpolatorFactor) + private val velocityInterpolator = AccelerateInterpolator(config.velocityInterpolatorFactor) + private lateinit var sliderHapticFeedbackProvider: SliderHapticFeedbackProvider + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + whenever(vibratorHelper.getPrimitiveDurations(any())) + .thenReturn(intArrayOf(lowTickDuration)) + whenever(velocityTracker.xVelocity).thenReturn(config.maxVelocityToScale) + sliderHapticFeedbackProvider = + SliderHapticFeedbackProvider(vibratorHelper, velocityTracker, config, clock) + } + + @Test + fun playHapticAtLowerBookend_playsClick() { + val vibration = + VibrationEffect.startComposition() + .addPrimitive( + VibrationEffect.Composition.PRIMITIVE_CLICK, + scaleAtBookends(config.maxVelocityToScale) + ) + .compose() + + sliderHapticFeedbackProvider.onLowerBookend() + + verify(vibratorHelper).vibrate(eq(vibration), any(VibrationAttributes::class.java)) + } + + @Test + fun playHapticAtLowerBookend_twoTimes_playsClickOnlyOnce() { + val vibration = + VibrationEffect.startComposition() + .addPrimitive( + VibrationEffect.Composition.PRIMITIVE_CLICK, + scaleAtBookends(config.maxVelocityToScale) + ) + .compose() + + sliderHapticFeedbackProvider.onLowerBookend() + sliderHapticFeedbackProvider.onLowerBookend() + + verify(vibratorHelper).vibrate(eq(vibration), any(VibrationAttributes::class.java)) + } + + @Test + fun playHapticAtUpperBookend_playsClick() { + val vibration = + VibrationEffect.startComposition() + .addPrimitive( + VibrationEffect.Composition.PRIMITIVE_CLICK, + scaleAtBookends(config.maxVelocityToScale) + ) + .compose() + + sliderHapticFeedbackProvider.onUpperBookend() + + verify(vibratorHelper).vibrate(eq(vibration), any(VibrationAttributes::class.java)) + } + + @Test + fun playHapticAtUpperBookend_twoTimes_playsClickOnlyOnce() { + val vibration = + VibrationEffect.startComposition() + .addPrimitive( + VibrationEffect.Composition.PRIMITIVE_CLICK, + scaleAtBookends(config.maxVelocityToScale) + ) + .compose() + + sliderHapticFeedbackProvider.onUpperBookend() + sliderHapticFeedbackProvider.onUpperBookend() + + verify(vibratorHelper, times(1)) + .vibrate(eq(vibration), any(VibrationAttributes::class.java)) + } + + @Test + fun playHapticAtProgress_onQuickSuccession_playsLowTicksOnce() { + // GIVEN max velocity and slider progress + val progress = 1f + val expectedScale = scaleAtProgressChange(config.maxVelocityToScale.toFloat(), progress) + val ticks = VibrationEffect.startComposition() + repeat(config.numberOfLowTicks) { + ticks.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, expectedScale) + } + + // GIVEN system running for 1s + clock.advanceTime(1000) + + // WHEN two calls to play occur immediately + sliderHapticFeedbackProvider.onProgress(progress) + sliderHapticFeedbackProvider.onProgress(progress) + + // THEN the correct composition only plays once + verify(vibratorHelper, times(1)) + .vibrate(eq(ticks.compose()), any(VibrationAttributes::class.java)) + } + + @Test + fun playHapticAtProgress_afterNextDragThreshold_playsLowTicksTwice() { + // GIVEN max velocity and slider progress + val progress = 1f + val expectedScale = scaleAtProgressChange(config.maxVelocityToScale.toFloat(), progress) + val ticks = VibrationEffect.startComposition() + repeat(config.numberOfLowTicks) { + ticks.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, expectedScale) + } + + // GIVEN system running for 1s + clock.advanceTime(1000) + + // WHEN two calls to play occur with the required threshold separation + sliderHapticFeedbackProvider.onProgress(progress) + clock.advanceTime(dragTextureThresholdMillis.toLong()) + sliderHapticFeedbackProvider.onProgress(progress) + + // THEN the correct composition plays two times + verify(vibratorHelper, times(2)) + .vibrate(eq(ticks.compose()), any(VibrationAttributes::class.java)) + } + + @Test + fun playHapticAtLowerBookend_afterPlayingAtProgress_playsTwice() { + // GIVEN max velocity and slider progress + val progress = 1f + val expectedScale = scaleAtProgressChange(config.maxVelocityToScale.toFloat(), progress) + val ticks = VibrationEffect.startComposition() + repeat(config.numberOfLowTicks) { + ticks.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, expectedScale) + } + val bookendVibration = + VibrationEffect.startComposition() + .addPrimitive( + VibrationEffect.Composition.PRIMITIVE_CLICK, + scaleAtBookends(config.maxVelocityToScale) + ) + .compose() + + // GIVEN a vibration at the lower bookend followed by a request to vibrate at progress + sliderHapticFeedbackProvider.onLowerBookend() + sliderHapticFeedbackProvider.onProgress(progress) + + // WHEN a vibration is to trigger again at the lower bookend + sliderHapticFeedbackProvider.onLowerBookend() + + // THEN there are two bookend vibrations + verify(vibratorHelper, times(2)) + .vibrate(eq(bookendVibration), any(VibrationAttributes::class.java)) + } + + @Test + fun playHapticAtUpperBookend_afterPlayingAtProgress_playsTwice() { + // GIVEN max velocity and slider progress + val progress = 1f + val expectedScale = scaleAtProgressChange(config.maxVelocityToScale.toFloat(), progress) + val ticks = VibrationEffect.startComposition() + repeat(config.numberOfLowTicks) { + ticks.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, expectedScale) + } + val bookendVibration = + VibrationEffect.startComposition() + .addPrimitive( + VibrationEffect.Composition.PRIMITIVE_CLICK, + scaleAtBookends(config.maxVelocityToScale) + ) + .compose() + + // GIVEN a vibration at the upper bookend followed by a request to vibrate at progress + sliderHapticFeedbackProvider.onUpperBookend() + sliderHapticFeedbackProvider.onProgress(progress) + + // WHEN a vibration is to trigger again at the upper bookend + sliderHapticFeedbackProvider.onUpperBookend() + + // THEN there are two bookend vibrations + verify(vibratorHelper, times(2)) + .vibrate(eq(bookendVibration), any(VibrationAttributes::class.java)) + } + + private fun scaleAtBookends(velocity: Float): Float { + val range = config.upperBookendScale - config.lowerBookendScale + val interpolatedVelocity = + velocityInterpolator.getInterpolation(velocity / config.maxVelocityToScale) + return interpolatedVelocity * range + config.lowerBookendScale + } + + private fun scaleAtProgressChange(velocity: Float, progress: Float): Float { + val range = config.progressBasedDragMaxScale - config.progressBasedDragMinScale + val interpolatedVelocity = + velocityInterpolator.getInterpolation(velocity / config.maxVelocityToScale) + val interpolatedProgress = progressInterpolator.getInterpolation(progress) + val bump = interpolatedVelocity * config.additionalVelocityMaxBump + return interpolatedProgress * range + config.progressBasedDragMinScale + bump + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/VibratorHelperTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/VibratorHelperTest.kt index aab4bc361d7b..b90582575970 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/VibratorHelperTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/VibratorHelperTest.kt @@ -75,6 +75,18 @@ class VibratorHelperTest : SysuiTestCase() { } @Test + fun testVibrate5() { + vibratorHelper.vibrate( + mock(VibrationEffect::class.java), + mock(VibrationAttributes::class.java) + ) + verifyAsync().vibrate( + any(VibrationEffect::class.java), + any(VibrationAttributes::class.java) + ) + } + + @Test fun testPerformHapticFeedback() { val constant = HapticFeedbackConstants.CONFIRM vibratorHelper.performHapticFeedback(view, constant) |