Loading packages/SystemUI/multivalentTests/src/com/android/systemui/topwindoweffects/TopLevelWindowEffectsTest.kt +28 −25 Original line number Diff line number Diff line Loading @@ -72,16 +72,19 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { VibrationEffect.Composition.PRIMITIVE_LOW_TICK, VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, VibrationEffect.Composition.PRIMITIVE_TICK, VibrationEffect.Composition.PRIMITIVE_CLICK, ) private val invocationHaptics = SqueezeEffectHapticsBuilder.createInvocationHaptics( private val zoomOutHaptics = SqueezeEffectHapticsBuilder.createZoomOutEffect( lowTickDuration = primitiveDurations[0], quickRiseDuration = primitiveDurations[1], tickDuration = primitiveDurations[2], totalEffectDuration = DEFAULT_OUTWARD_EFFECT_DURATION_MS.toInt() + DEFAULT_INWARD_EFFECT_DURATION_MILLIS.toInt(), effectDuration = (TopLevelWindowEffects.HAPTIC_OUTWARD_EFFECT_DURATION_SCALE * DEFAULT_OUTWARD_EFFECT_DURATION_MS) .toInt(), ) private val lppIndicatorEffect = SqueezeEffectHapticsBuilder.createLppIndicatorEffect() private val Kosmos.underTest by Kosmos.Fixture { Loading Loading @@ -134,7 +137,6 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { animatorTestRule.advanceTimeBy(1) assertNotEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue() } @Test Loading @@ -153,7 +155,7 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { animatorTestRule.advanceTimeBy(1) assertEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isFalse() assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse() } @Test Loading @@ -177,7 +179,7 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { animatorTestRule.advanceTimeBy(1) assertEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isFalse() assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse() } @Test Loading @@ -194,15 +196,11 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { // add additional 1ms time to simulate initial delay duration has passed advanceTime((expectedDelay + 1).milliseconds) animatorTestRule.advanceTimeBy(1) val timesCancelledBefore = vibratorHelper.timesCancelled fakeSqueezeEffectRepository.isEffectEnabledAndPowerButtonPressedAsSingleGesture.value = false runCurrent() animatorTestRule.advanceTimeBy(1) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue() assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore + 1) } @Test Loading @@ -228,7 +226,9 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { runCurrent() animatorTestRule.advanceTimeBy(1) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue() assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse() assertThat(vibratorHelper.hasVibratedWithEffects(lppIndicatorEffect.vibration)) .isFalse() assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore + 1) } Loading @@ -254,7 +254,7 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { runCurrent() assertEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isFalse() assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse() } @Test Loading @@ -273,7 +273,7 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { animatorTestRule.advanceTimeBy(1) assertEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isFalse() assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse() } } Loading @@ -295,7 +295,6 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { runCurrent() val timesCancelledBefore = vibratorHelper.timesCancelled assertThat(fakeAppZoomOut.lastTopLevelProgress).isGreaterThan(0f) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue() // Simulate power button long press fakeSqueezeEffectRepository.isPowerButtonLongPressed.value = true Loading @@ -306,8 +305,10 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { false runCurrent() // Triggers cancelSqueeze, but it should not interrupt // Animation should be non-interruptible, so haptics are not cancelled at this point assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore) // On long-press the LPP haptic effect plays cancelling any previous haptic effect var expectedCancellations = timesCancelledBefore + 1 assertThat(vibratorHelper.timesCancelled).isEqualTo(expectedCancellations) assertThat(vibratorHelper.hasVibratedWithEffects(lppIndicatorEffect.vibration)).isTrue() // Animation continues: complete inward animation animatorTestRule.advanceTimeBy(DEFAULT_INWARD_EFFECT_DURATION_MILLIS - 10L) Loading @@ -315,12 +316,14 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { assertThat(fakeAppZoomOut.lastTopLevelProgress).isEqualTo(1f) // Animation continues: complete outward animation (triggered by inward animation's end) // ZoomOut haptics have been played at this point after cancelling any previous // vibration job animatorTestRule.advanceTimeBy(DEFAULT_OUTWARD_EFFECT_DURATION_MS) expectedCancellations++ runCurrent() assertThat(fakeAppZoomOut.lastTopLevelProgress).isEqualTo(0f) // Haptics never cancelled when animation completes assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore) assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isTrue() assertThat(vibratorHelper.timesCancelled).isEqualTo(expectedCancellations) } @Test Loading Loading @@ -376,7 +379,6 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { runCurrent() // Advance past initial delay advanceTime((initialDelay + 1).milliseconds) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue() val timesCancelledBefore = vibratorHelper.timesCancelled // Complete inward animation Loading @@ -389,8 +391,10 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { runCurrent() assertThat(fakeAppZoomOut.lastTopLevelProgress).isEqualTo(0f) // Haptics are not cancelled when animation completes without interruption assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore) // ZoomOut Haptics play and are not cancelled when animation completes without // interruption assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore + 1) assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isTrue() // Release power button (does not affect completed animation) fakeSqueezeEffectRepository.isEffectEnabledAndPowerButtonPressedAsSingleGesture.value = Loading Loading @@ -420,7 +424,6 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { val progressBeforeCancel = fakeAppZoomOut.lastTopLevelProgress assertThat(progressBeforeCancel).isGreaterThan(0f) assertThat(progressBeforeCancel).isLessThan(1f) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue() // Release power button before long press is detected fakeSqueezeEffectRepository.isEffectEnabledAndPowerButtonPressedAsSingleGesture.value = Loading packages/SystemUI/src/com/android/systemui/topwindoweffects/TopLevelWindowEffects.kt +24 −3 Original line number Diff line number Diff line Loading @@ -16,6 +16,7 @@ package com.android.systemui.topwindoweffects import android.os.SystemProperties import androidx.annotation.VisibleForTesting import androidx.core.animation.Animator import androidx.core.animation.AnimatorListenerAdapter Loading Loading @@ -79,7 +80,13 @@ constructor( squeezeEffectInteractor.isEffectEnabledAndPowerButtonPressedAsSingleGesture .collectLatest { enabledAndPressed -> if (enabledAndPressed) { startSqueeze() val hapticsOption = SystemProperties.get( /*key=*/ "persist.lpp_invocation.haptics", /*def=*/ "no_rumble", ) val useHapticRumble = hapticsOption == "with_rumble" startSqueeze(useHapticRumble) } else { cancelSqueeze() } Loading @@ -87,18 +94,25 @@ constructor( } } private suspend fun startSqueeze() { private suspend fun startSqueeze(useHapticRumble: Boolean) { delay(squeezeEffectInteractor.getInvocationEffectInitialDelayMillis()) setRequestTopUi(true) val inwardsAnimationDuration = squeezeEffectInteractor.getInvocationEffectInAnimationDurationMillis() val outwardsAnimationDuration = squeezeEffectInteractor.getInvocationEffectOutAnimationDurationMillis() if (useHapticRumble) { hapticPlayer?.playRumble(inwardsAnimationDuration.toInt()) } animateSqueezeProgressTo( targetProgress = 1f, duration = inwardsAnimationDuration, interpolator = InterpolatorsAndroidX.LEGACY, ) { hapticPlayer?.startZoomOutEffect( durationMillis = (HAPTIC_OUTWARD_EFFECT_DURATION_SCALE * outwardsAnimationDuration).toInt() ) animateSqueezeProgressTo( targetProgress = 0f, duration = outwardsAnimationDuration, Loading @@ -107,10 +121,10 @@ constructor( finishAnimation() } } hapticPlayer?.start(inwardsAnimationDuration.toInt() + outwardsAnimationDuration.toInt()) squeezeEffectInteractor.isPowerButtonLongPressed.collectLatest { isLongPressed -> if (isLongPressed) { isAnimationInterruptible = false hapticPlayer?.playLppIndicator() } } } Loading Loading @@ -173,6 +187,13 @@ constructor( companion object { @VisibleForTesting const val TAG = "TopLevelWindowEffects" /** * A scale applied to the outward animation duration to derive the duration of the haptic * effect. This number is fine tuned to produce a haptic effect that suits the outward * animator interpolator well. */ @VisibleForTesting const val HAPTIC_OUTWARD_EFFECT_DURATION_SCALE = 0.53 } } Loading packages/SystemUI/src/com/android/systemui/topwindoweffects/ui/viewmodel/SqueezeEffectHapticPlayer.kt +30 −11 Original line number Diff line number Diff line Loading @@ -41,41 +41,60 @@ constructor( VibrationEffect.Composition.PRIMITIVE_TICK, ) private fun buildInvocationHaptics(totalDurationMillis: Int) = SqueezeEffectHapticsBuilder.createInvocationHaptics( private fun buildZoomOutHaptics(durationMillis: Int) = SqueezeEffectHapticsBuilder.createZoomOutEffect( lowTickDuration = primitiveDurations[0], quickRiseDuration = primitiveDurations[1], tickDuration = primitiveDurations[2], totalEffectDuration = totalDurationMillis, effectDuration = durationMillis, ) private val lppIndicationEffect = SqueezeEffectHapticsBuilder.createLppIndicatorEffect() private var vibrationJob: Job? = null fun start(totalDurationMillis: Int) { fun startZoomOutEffect(durationMillis: Int) { cancel() val invocationHaptics = buildInvocationHaptics(totalDurationMillis) if (invocationHaptics.initialDelay <= 0) { vibrate(invocationHaptics.vibration) val zoomOutHaptics = buildZoomOutHaptics(durationMillis) if (zoomOutHaptics.initialDelay <= 0) { vibrate(zoomOutHaptics) } else { vibrationJob = applicationScope.launch { delay(invocationHaptics.initialDelay.toLong()) delay(zoomOutHaptics.initialDelay.toLong()) if (isActive) { vibrate(invocationHaptics.vibration) vibrate(zoomOutHaptics) } vibrationJob = null } } } fun playRumble(rumbleDuration: Int) { val effect = SqueezeEffectHapticsBuilder.createRumbleEffect( rumbleDuration = rumbleDuration, lowTickDuration = primitiveDurations[0], ) vibrate(effect) } fun playLppIndicator() { vibratorHelper.cancel() vibrate(lppIndicationEffect) } fun cancel() { vibrationJob?.cancel() vibrationJob = null vibratorHelper.cancel() } private fun vibrate(vibrationEffect: VibrationEffect) = vibratorHelper.vibrate(vibrationEffect, SqueezeEffectHapticsBuilder.VIBRATION_ATTRIBUTES) private fun vibrate(effect: SqueezeEffectHapticsBuilder.SqueezeEffectHaptics?) { effect?.let { vibratorHelper.vibrate(it.vibration, SqueezeEffectHapticsBuilder.VIBRATION_ATTRIBUTES) } } @AssistedFactory interface Factory { Loading packages/SystemUI/src/com/android/systemui/topwindoweffects/ui/viewmodel/SqueezeEffectHapticsBuilder.kt +78 −23 Original line number Diff line number Diff line Loading @@ -18,6 +18,7 @@ package com.android.systemui.topwindoweffects.ui.viewmodel import android.os.VibrationAttributes import android.os.VibrationEffect import android.os.VibrationEffect.Composition import android.util.Log object SqueezeEffectHapticsBuilder { Loading @@ -27,15 +28,27 @@ object SqueezeEffectHapticsBuilder { private const val LOW_TICK_SCALE = 0.09f private const val QUICK_RISE_SCALE = 0.25f private const val TICK_SCALE = 1f private const val CLICK_SCALE = 0.8f val VIBRATION_ATTRIBUTES = VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_HARDWARE_FEEDBACK).build() fun createInvocationHaptics( fun createRumbleEffect(rumbleDuration: Int, lowTickDuration: Int): SqueezeEffectHaptics? { // If the lowTickDuration is zero, the effect is not supported if (lowTickDuration == 0) { Log.d(TAG, "The LOW_TICK, primitive is not supported. No rumble added") return null } val composition = VibrationEffect.startComposition() addRumble(rumbleDuration, lowTickDuration, composition) return SqueezeEffectHaptics(composition.compose()) } fun createZoomOutEffect( lowTickDuration: Int, quickRiseDuration: Int, tickDuration: Int, totalEffectDuration: Int, effectDuration: Int, ): SqueezeEffectHaptics { // If a primitive is not supported, the duration will be 0 val isInvocationEffectSupported = Loading @@ -53,32 +66,74 @@ object SqueezeEffectHapticsBuilder { // We use the full invocation duration as a delay so that we play the // HEAVY_CLICK fallback in sync with the end of the squeeze effect return SqueezeEffectHaptics( initialDelay = totalEffectDuration, initialDelay = effectDuration, vibration = VibrationEffect.get(VibrationEffect.EFFECT_HEAVY_CLICK), ) } val riseEffectDuration = quickRiseDuration + RISE_TO_TICK_DELAY + tickDuration val warmUpTime = totalEffectDuration - riseEffectDuration val nLowTicks = warmUpTime / lowTickDuration if (effectDuration < riseEffectDuration) { Log.d( TAG, """ The rise effect($riseEffectDuration ms) is longer than the total zoom-out effect ($effectDuration ms). Using EFFECT_HEAVY_CLICK as a fallback. """ .trimIndent(), ) return SqueezeEffectHaptics( initialDelay = effectDuration, vibration = VibrationEffect.get(VibrationEffect.EFFECT_HEAVY_CLICK), ) } val composition = VibrationEffect.startComposition().apply { // Warmup low ticks repeat(nLowTicks) { addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, LOW_TICK_SCALE, 0) val composition = VibrationEffect.startComposition() // Rumble towards the end of the zoom-out val zoomOutRumbleDuration = effectDuration - riseEffectDuration addRumble(zoomOutRumbleDuration, lowTickDuration, composition) // Final rise effect addQuickRiseTickEffect(composition) return SqueezeEffectHaptics(composition.compose()) } // Quick rise and tick addPrimitive(VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, QUICK_RISE_SCALE, 0) addPrimitive( VibrationEffect.Composition.PRIMITIVE_TICK, TICK_SCALE, RISE_TO_TICK_DELAY, /** * Add a rumble to a vibration composition. The rumble is a composition of LOW_TICK primitives. * This assumes that the device can play the LOW_TICK primitive. */ private fun addRumble(rumbleDuration: Int, lowTickDuration: Int, composition: Composition) { val nLowTicks = (rumbleDuration / lowTickDuration).coerceAtLeast(minimumValue = 0) repeat(nLowTicks) { composition.addPrimitive( Composition.PRIMITIVE_LOW_TICK, /*scale=*/ LOW_TICK_SCALE, /*delay=*/ 0, ) } } /** * Add a quick rise and a tick to a vibration composition. This assumes that the QUICK_RISE and * TICK primitives are supported. */ private fun addQuickRiseTickEffect(composition: Composition) { composition.addPrimitive(Composition.PRIMITIVE_QUICK_RISE, QUICK_RISE_SCALE, /* delay= */ 0) composition.addPrimitive(Composition.PRIMITIVE_TICK, TICK_SCALE, RISE_TO_TICK_DELAY) } return SqueezeEffectHaptics(initialDelay = 0, vibration = composition.compose()) fun createLppIndicatorEffect(): SqueezeEffectHaptics { val composition = VibrationEffect.startComposition() composition.addPrimitive( Composition.PRIMITIVE_CLICK, /*scale=*/ CLICK_SCALE, /*delay=*/ 0, ) return SqueezeEffectHaptics(composition.compose()) } data class SqueezeEffectHaptics(val initialDelay: Int, val vibration: VibrationEffect) data class SqueezeEffectHaptics(val initialDelay: Int, val vibration: VibrationEffect) { constructor(vibration: VibrationEffect) : this(initialDelay = 0, vibration) } } Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/topwindoweffects/TopLevelWindowEffectsTest.kt +28 −25 Original line number Diff line number Diff line Loading @@ -72,16 +72,19 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { VibrationEffect.Composition.PRIMITIVE_LOW_TICK, VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, VibrationEffect.Composition.PRIMITIVE_TICK, VibrationEffect.Composition.PRIMITIVE_CLICK, ) private val invocationHaptics = SqueezeEffectHapticsBuilder.createInvocationHaptics( private val zoomOutHaptics = SqueezeEffectHapticsBuilder.createZoomOutEffect( lowTickDuration = primitiveDurations[0], quickRiseDuration = primitiveDurations[1], tickDuration = primitiveDurations[2], totalEffectDuration = DEFAULT_OUTWARD_EFFECT_DURATION_MS.toInt() + DEFAULT_INWARD_EFFECT_DURATION_MILLIS.toInt(), effectDuration = (TopLevelWindowEffects.HAPTIC_OUTWARD_EFFECT_DURATION_SCALE * DEFAULT_OUTWARD_EFFECT_DURATION_MS) .toInt(), ) private val lppIndicatorEffect = SqueezeEffectHapticsBuilder.createLppIndicatorEffect() private val Kosmos.underTest by Kosmos.Fixture { Loading Loading @@ -134,7 +137,6 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { animatorTestRule.advanceTimeBy(1) assertNotEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue() } @Test Loading @@ -153,7 +155,7 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { animatorTestRule.advanceTimeBy(1) assertEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isFalse() assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse() } @Test Loading @@ -177,7 +179,7 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { animatorTestRule.advanceTimeBy(1) assertEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isFalse() assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse() } @Test Loading @@ -194,15 +196,11 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { // add additional 1ms time to simulate initial delay duration has passed advanceTime((expectedDelay + 1).milliseconds) animatorTestRule.advanceTimeBy(1) val timesCancelledBefore = vibratorHelper.timesCancelled fakeSqueezeEffectRepository.isEffectEnabledAndPowerButtonPressedAsSingleGesture.value = false runCurrent() animatorTestRule.advanceTimeBy(1) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue() assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore + 1) } @Test Loading @@ -228,7 +226,9 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { runCurrent() animatorTestRule.advanceTimeBy(1) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue() assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse() assertThat(vibratorHelper.hasVibratedWithEffects(lppIndicatorEffect.vibration)) .isFalse() assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore + 1) } Loading @@ -254,7 +254,7 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { runCurrent() assertEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isFalse() assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse() } @Test Loading @@ -273,7 +273,7 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { animatorTestRule.advanceTimeBy(1) assertEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isFalse() assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse() } } Loading @@ -295,7 +295,6 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { runCurrent() val timesCancelledBefore = vibratorHelper.timesCancelled assertThat(fakeAppZoomOut.lastTopLevelProgress).isGreaterThan(0f) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue() // Simulate power button long press fakeSqueezeEffectRepository.isPowerButtonLongPressed.value = true Loading @@ -306,8 +305,10 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { false runCurrent() // Triggers cancelSqueeze, but it should not interrupt // Animation should be non-interruptible, so haptics are not cancelled at this point assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore) // On long-press the LPP haptic effect plays cancelling any previous haptic effect var expectedCancellations = timesCancelledBefore + 1 assertThat(vibratorHelper.timesCancelled).isEqualTo(expectedCancellations) assertThat(vibratorHelper.hasVibratedWithEffects(lppIndicatorEffect.vibration)).isTrue() // Animation continues: complete inward animation animatorTestRule.advanceTimeBy(DEFAULT_INWARD_EFFECT_DURATION_MILLIS - 10L) Loading @@ -315,12 +316,14 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { assertThat(fakeAppZoomOut.lastTopLevelProgress).isEqualTo(1f) // Animation continues: complete outward animation (triggered by inward animation's end) // ZoomOut haptics have been played at this point after cancelling any previous // vibration job animatorTestRule.advanceTimeBy(DEFAULT_OUTWARD_EFFECT_DURATION_MS) expectedCancellations++ runCurrent() assertThat(fakeAppZoomOut.lastTopLevelProgress).isEqualTo(0f) // Haptics never cancelled when animation completes assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore) assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isTrue() assertThat(vibratorHelper.timesCancelled).isEqualTo(expectedCancellations) } @Test Loading Loading @@ -376,7 +379,6 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { runCurrent() // Advance past initial delay advanceTime((initialDelay + 1).milliseconds) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue() val timesCancelledBefore = vibratorHelper.timesCancelled // Complete inward animation Loading @@ -389,8 +391,10 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { runCurrent() assertThat(fakeAppZoomOut.lastTopLevelProgress).isEqualTo(0f) // Haptics are not cancelled when animation completes without interruption assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore) // ZoomOut Haptics play and are not cancelled when animation completes without // interruption assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore + 1) assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isTrue() // Release power button (does not affect completed animation) fakeSqueezeEffectRepository.isEffectEnabledAndPowerButtonPressedAsSingleGesture.value = Loading Loading @@ -420,7 +424,6 @@ class TopLevelWindowEffectsTest : SysuiTestCase() { val progressBeforeCancel = fakeAppZoomOut.lastTopLevelProgress assertThat(progressBeforeCancel).isGreaterThan(0f) assertThat(progressBeforeCancel).isLessThan(1f) assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue() // Release power button before long press is detected fakeSqueezeEffectRepository.isEffectEnabledAndPowerButtonPressedAsSingleGesture.value = Loading
packages/SystemUI/src/com/android/systemui/topwindoweffects/TopLevelWindowEffects.kt +24 −3 Original line number Diff line number Diff line Loading @@ -16,6 +16,7 @@ package com.android.systemui.topwindoweffects import android.os.SystemProperties import androidx.annotation.VisibleForTesting import androidx.core.animation.Animator import androidx.core.animation.AnimatorListenerAdapter Loading Loading @@ -79,7 +80,13 @@ constructor( squeezeEffectInteractor.isEffectEnabledAndPowerButtonPressedAsSingleGesture .collectLatest { enabledAndPressed -> if (enabledAndPressed) { startSqueeze() val hapticsOption = SystemProperties.get( /*key=*/ "persist.lpp_invocation.haptics", /*def=*/ "no_rumble", ) val useHapticRumble = hapticsOption == "with_rumble" startSqueeze(useHapticRumble) } else { cancelSqueeze() } Loading @@ -87,18 +94,25 @@ constructor( } } private suspend fun startSqueeze() { private suspend fun startSqueeze(useHapticRumble: Boolean) { delay(squeezeEffectInteractor.getInvocationEffectInitialDelayMillis()) setRequestTopUi(true) val inwardsAnimationDuration = squeezeEffectInteractor.getInvocationEffectInAnimationDurationMillis() val outwardsAnimationDuration = squeezeEffectInteractor.getInvocationEffectOutAnimationDurationMillis() if (useHapticRumble) { hapticPlayer?.playRumble(inwardsAnimationDuration.toInt()) } animateSqueezeProgressTo( targetProgress = 1f, duration = inwardsAnimationDuration, interpolator = InterpolatorsAndroidX.LEGACY, ) { hapticPlayer?.startZoomOutEffect( durationMillis = (HAPTIC_OUTWARD_EFFECT_DURATION_SCALE * outwardsAnimationDuration).toInt() ) animateSqueezeProgressTo( targetProgress = 0f, duration = outwardsAnimationDuration, Loading @@ -107,10 +121,10 @@ constructor( finishAnimation() } } hapticPlayer?.start(inwardsAnimationDuration.toInt() + outwardsAnimationDuration.toInt()) squeezeEffectInteractor.isPowerButtonLongPressed.collectLatest { isLongPressed -> if (isLongPressed) { isAnimationInterruptible = false hapticPlayer?.playLppIndicator() } } } Loading Loading @@ -173,6 +187,13 @@ constructor( companion object { @VisibleForTesting const val TAG = "TopLevelWindowEffects" /** * A scale applied to the outward animation duration to derive the duration of the haptic * effect. This number is fine tuned to produce a haptic effect that suits the outward * animator interpolator well. */ @VisibleForTesting const val HAPTIC_OUTWARD_EFFECT_DURATION_SCALE = 0.53 } } Loading
packages/SystemUI/src/com/android/systemui/topwindoweffects/ui/viewmodel/SqueezeEffectHapticPlayer.kt +30 −11 Original line number Diff line number Diff line Loading @@ -41,41 +41,60 @@ constructor( VibrationEffect.Composition.PRIMITIVE_TICK, ) private fun buildInvocationHaptics(totalDurationMillis: Int) = SqueezeEffectHapticsBuilder.createInvocationHaptics( private fun buildZoomOutHaptics(durationMillis: Int) = SqueezeEffectHapticsBuilder.createZoomOutEffect( lowTickDuration = primitiveDurations[0], quickRiseDuration = primitiveDurations[1], tickDuration = primitiveDurations[2], totalEffectDuration = totalDurationMillis, effectDuration = durationMillis, ) private val lppIndicationEffect = SqueezeEffectHapticsBuilder.createLppIndicatorEffect() private var vibrationJob: Job? = null fun start(totalDurationMillis: Int) { fun startZoomOutEffect(durationMillis: Int) { cancel() val invocationHaptics = buildInvocationHaptics(totalDurationMillis) if (invocationHaptics.initialDelay <= 0) { vibrate(invocationHaptics.vibration) val zoomOutHaptics = buildZoomOutHaptics(durationMillis) if (zoomOutHaptics.initialDelay <= 0) { vibrate(zoomOutHaptics) } else { vibrationJob = applicationScope.launch { delay(invocationHaptics.initialDelay.toLong()) delay(zoomOutHaptics.initialDelay.toLong()) if (isActive) { vibrate(invocationHaptics.vibration) vibrate(zoomOutHaptics) } vibrationJob = null } } } fun playRumble(rumbleDuration: Int) { val effect = SqueezeEffectHapticsBuilder.createRumbleEffect( rumbleDuration = rumbleDuration, lowTickDuration = primitiveDurations[0], ) vibrate(effect) } fun playLppIndicator() { vibratorHelper.cancel() vibrate(lppIndicationEffect) } fun cancel() { vibrationJob?.cancel() vibrationJob = null vibratorHelper.cancel() } private fun vibrate(vibrationEffect: VibrationEffect) = vibratorHelper.vibrate(vibrationEffect, SqueezeEffectHapticsBuilder.VIBRATION_ATTRIBUTES) private fun vibrate(effect: SqueezeEffectHapticsBuilder.SqueezeEffectHaptics?) { effect?.let { vibratorHelper.vibrate(it.vibration, SqueezeEffectHapticsBuilder.VIBRATION_ATTRIBUTES) } } @AssistedFactory interface Factory { Loading
packages/SystemUI/src/com/android/systemui/topwindoweffects/ui/viewmodel/SqueezeEffectHapticsBuilder.kt +78 −23 Original line number Diff line number Diff line Loading @@ -18,6 +18,7 @@ package com.android.systemui.topwindoweffects.ui.viewmodel import android.os.VibrationAttributes import android.os.VibrationEffect import android.os.VibrationEffect.Composition import android.util.Log object SqueezeEffectHapticsBuilder { Loading @@ -27,15 +28,27 @@ object SqueezeEffectHapticsBuilder { private const val LOW_TICK_SCALE = 0.09f private const val QUICK_RISE_SCALE = 0.25f private const val TICK_SCALE = 1f private const val CLICK_SCALE = 0.8f val VIBRATION_ATTRIBUTES = VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_HARDWARE_FEEDBACK).build() fun createInvocationHaptics( fun createRumbleEffect(rumbleDuration: Int, lowTickDuration: Int): SqueezeEffectHaptics? { // If the lowTickDuration is zero, the effect is not supported if (lowTickDuration == 0) { Log.d(TAG, "The LOW_TICK, primitive is not supported. No rumble added") return null } val composition = VibrationEffect.startComposition() addRumble(rumbleDuration, lowTickDuration, composition) return SqueezeEffectHaptics(composition.compose()) } fun createZoomOutEffect( lowTickDuration: Int, quickRiseDuration: Int, tickDuration: Int, totalEffectDuration: Int, effectDuration: Int, ): SqueezeEffectHaptics { // If a primitive is not supported, the duration will be 0 val isInvocationEffectSupported = Loading @@ -53,32 +66,74 @@ object SqueezeEffectHapticsBuilder { // We use the full invocation duration as a delay so that we play the // HEAVY_CLICK fallback in sync with the end of the squeeze effect return SqueezeEffectHaptics( initialDelay = totalEffectDuration, initialDelay = effectDuration, vibration = VibrationEffect.get(VibrationEffect.EFFECT_HEAVY_CLICK), ) } val riseEffectDuration = quickRiseDuration + RISE_TO_TICK_DELAY + tickDuration val warmUpTime = totalEffectDuration - riseEffectDuration val nLowTicks = warmUpTime / lowTickDuration if (effectDuration < riseEffectDuration) { Log.d( TAG, """ The rise effect($riseEffectDuration ms) is longer than the total zoom-out effect ($effectDuration ms). Using EFFECT_HEAVY_CLICK as a fallback. """ .trimIndent(), ) return SqueezeEffectHaptics( initialDelay = effectDuration, vibration = VibrationEffect.get(VibrationEffect.EFFECT_HEAVY_CLICK), ) } val composition = VibrationEffect.startComposition().apply { // Warmup low ticks repeat(nLowTicks) { addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, LOW_TICK_SCALE, 0) val composition = VibrationEffect.startComposition() // Rumble towards the end of the zoom-out val zoomOutRumbleDuration = effectDuration - riseEffectDuration addRumble(zoomOutRumbleDuration, lowTickDuration, composition) // Final rise effect addQuickRiseTickEffect(composition) return SqueezeEffectHaptics(composition.compose()) } // Quick rise and tick addPrimitive(VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, QUICK_RISE_SCALE, 0) addPrimitive( VibrationEffect.Composition.PRIMITIVE_TICK, TICK_SCALE, RISE_TO_TICK_DELAY, /** * Add a rumble to a vibration composition. The rumble is a composition of LOW_TICK primitives. * This assumes that the device can play the LOW_TICK primitive. */ private fun addRumble(rumbleDuration: Int, lowTickDuration: Int, composition: Composition) { val nLowTicks = (rumbleDuration / lowTickDuration).coerceAtLeast(minimumValue = 0) repeat(nLowTicks) { composition.addPrimitive( Composition.PRIMITIVE_LOW_TICK, /*scale=*/ LOW_TICK_SCALE, /*delay=*/ 0, ) } } /** * Add a quick rise and a tick to a vibration composition. This assumes that the QUICK_RISE and * TICK primitives are supported. */ private fun addQuickRiseTickEffect(composition: Composition) { composition.addPrimitive(Composition.PRIMITIVE_QUICK_RISE, QUICK_RISE_SCALE, /* delay= */ 0) composition.addPrimitive(Composition.PRIMITIVE_TICK, TICK_SCALE, RISE_TO_TICK_DELAY) } return SqueezeEffectHaptics(initialDelay = 0, vibration = composition.compose()) fun createLppIndicatorEffect(): SqueezeEffectHaptics { val composition = VibrationEffect.startComposition() composition.addPrimitive( Composition.PRIMITIVE_CLICK, /*scale=*/ CLICK_SCALE, /*delay=*/ 0, ) return SqueezeEffectHaptics(composition.compose()) } data class SqueezeEffectHaptics(val initialDelay: Int, val vibration: VibrationEffect) data class SqueezeEffectHaptics(val initialDelay: Int, val vibration: VibrationEffect) { constructor(vibration: VibrationEffect) : this(initialDelay = 0, vibration) } }