diff --git a/packages/SystemUI/res/layout/media_session_view.xml b/packages/SystemUI/res/layout/media_session_view.xml index c526d9cc8dd38..9b8b611558fec 100644 --- a/packages/SystemUI/res/layout/media_session_view.xml +++ b/packages/SystemUI/res/layout/media_session_view.xml @@ -44,6 +44,15 @@ android:background="@drawable/qs_media_outline_album_bg" /> + + + + + + + + (R.id.album_art) + val multiRippleView = itemView.requireViewById(R.id.touch_ripple_view) val appIcon = itemView.requireViewById(R.id.icon) val titleText = itemView.requireViewById(R.id.header_title) val artistText = itemView.requireViewById(R.id.header_artist) diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/ColorSchemeTransition.kt b/packages/SystemUI/src/com/android/systemui/media/controls/ui/ColorSchemeTransition.kt index 61ef2f1838e7d..918417fcd9a90 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/ui/ColorSchemeTransition.kt +++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/ColorSchemeTransition.kt @@ -29,6 +29,7 @@ import com.android.internal.annotations.VisibleForTesting import com.android.settingslib.Utils import com.android.systemui.media.controls.models.player.MediaViewHolder import com.android.systemui.monet.ColorScheme +import com.android.systemui.ripple.MultiRippleController /** * A [ColorTransition] is an object that updates the colors of views each time [updateColorScheme] @@ -100,12 +101,14 @@ class ColorSchemeTransition internal constructor( private val context: Context, private val mediaViewHolder: MediaViewHolder, + private val multiRippleController: MultiRippleController, animatingColorTransitionFactory: AnimatingColorTransitionFactory ) { constructor( context: Context, - mediaViewHolder: MediaViewHolder - ) : this(context, mediaViewHolder, ::AnimatingColorTransition) + mediaViewHolder: MediaViewHolder, + multiRippleController: MultiRippleController, + ) : this(context, mediaViewHolder, multiRippleController, ::AnimatingColorTransition) val bgColor = context.getColor(com.android.systemui.R.color.material_dynamic_secondary95) val surfaceColor = @@ -125,6 +128,7 @@ internal constructor( val accentColorList = ColorStateList.valueOf(accentPrimary) mediaViewHolder.actionPlayPause.backgroundTintList = accentColorList mediaViewHolder.gutsViewHolder.setAccentPrimaryColor(accentPrimary) + multiRippleController.updateColor(accentPrimary) } val accentSecondary = diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaControlPanel.java index 18ecadb28cf39..5b14cf34827a6 100644 --- a/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaControlPanel.java +++ b/packages/SystemUI/src/com/android/systemui/media/controls/ui/MediaControlPanel.java @@ -76,6 +76,8 @@ import com.android.systemui.bluetooth.BroadcastDialogController; import com.android.systemui.broadcast.BroadcastSender; import com.android.systemui.dagger.qualifiers.Background; import com.android.systemui.dagger.qualifiers.Main; +import com.android.systemui.flags.FeatureFlags; +import com.android.systemui.flags.Flags; import com.android.systemui.media.controls.models.GutsViewHolder; import com.android.systemui.media.controls.models.player.MediaAction; import com.android.systemui.media.controls.models.player.MediaButton; @@ -95,6 +97,10 @@ import com.android.systemui.monet.ColorScheme; import com.android.systemui.monet.Style; import com.android.systemui.plugins.ActivityStarter; import com.android.systemui.plugins.FalsingManager; +import com.android.systemui.ripple.MultiRippleController; +import com.android.systemui.ripple.RippleAnimation; +import com.android.systemui.ripple.RippleAnimationConfig; +import com.android.systemui.ripple.RippleShader; import com.android.systemui.shared.system.SysUiStatsLog; import com.android.systemui.statusbar.NotificationLockscreenUserManager; import com.android.systemui.statusbar.policy.KeyguardStateController; @@ -209,6 +215,8 @@ public class MediaControlPanel { private boolean mIsCurrentBroadcastedApp = false; private boolean mShowBroadcastDialogButton = false; private String mSwitchBroadcastApp; + private MultiRippleController mMultiRippleController; + private FeatureFlags mFeatureFlags; /** * Initialize a new control panel @@ -236,7 +244,9 @@ public class MediaControlPanel { KeyguardStateController keyguardStateController, ActivityIntentHelper activityIntentHelper, NotificationLockscreenUserManager lockscreenUserManager, - BroadcastDialogController broadcastDialogController) { + BroadcastDialogController broadcastDialogController, + FeatureFlags featureFlags + ) { mContext = context; mBackgroundExecutor = backgroundExecutor; mMainExecutor = mainExecutor; @@ -262,6 +272,8 @@ public class MediaControlPanel { logSmartspaceCardReported(SMARTSPACE_CARD_CLICK_EVENT); return Unit.INSTANCE; }); + + mFeatureFlags = featureFlags; } /** @@ -381,7 +393,9 @@ public class MediaControlPanel { AnimatorSet exit = loadAnimator(R.anim.media_metadata_exit, Interpolators.EMPHASIZED_ACCELERATE, titleText, artistText); - mColorSchemeTransition = new ColorSchemeTransition(mContext, mMediaViewHolder); + mMultiRippleController = new MultiRippleController(vh.getMultiRippleView()); + mColorSchemeTransition = new ColorSchemeTransition( + mContext, mMediaViewHolder, mMultiRippleController); mMetadataAnimationHandler = new MetadataAnimationHandler(exit, enter); } @@ -982,6 +996,9 @@ public class MediaControlPanel { mLogger.logTapAction(button.getId(), mUid, mPackageName, mInstanceId); logSmartspaceCardReported(SMARTSPACE_CARD_CLICK_EVENT); action.run(); + if (mFeatureFlags.isEnabled(Flags.UMO_SURFACE_RIPPLE)) { + mMultiRippleController.play(createTouchRippleAnimation(button)); + } if (icon instanceof Animatable) { ((Animatable) icon).start(); @@ -997,6 +1014,26 @@ public class MediaControlPanel { } } + private RippleAnimation createTouchRippleAnimation(ImageButton button) { + float maxSize = mMediaViewHolder.getMultiRippleView().getWidth() * 2; + return new RippleAnimation( + new RippleAnimationConfig( + RippleShader.RippleShape.CIRCLE, + /* duration= */ 1500L, + /* centerX= */ button.getX() + button.getWidth() * 0.5f, + /* centerY= */ button.getY() + button.getHeight() * 0.5f, + /* maxWidth= */ maxSize, + /* maxHeight= */ maxSize, + /* pixelDensity= */ getContext().getResources().getDisplayMetrics().density, + mColorSchemeTransition.getAccentPrimary().getTargetColor(), + /* opacity= */ 100, + /* shouldFillRipple= */ false, + /* sparkleStrength= */ 0f, + /* shouldDistort= */ false + ) + ); + } + private void clearButton(final ImageButton button) { button.setImageDrawable(null); button.setContentDescription(null); diff --git a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttChipControllerReceiver.kt b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttChipControllerReceiver.kt index 7dd9fb4b9cd51..662d059aedcd2 100644 --- a/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttChipControllerReceiver.kt +++ b/packages/SystemUI/src/com/android/systemui/media/taptotransfer/receiver/MediaTttChipControllerReceiver.kt @@ -189,7 +189,7 @@ class MediaTttChipControllerReceiver @Inject constructor( } private fun startRipple(rippleView: ReceiverChipRippleView) { - if (rippleView.rippleInProgress) { + if (rippleView.rippleInProgress()) { // Skip if ripple is still playing return } diff --git a/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleController.kt b/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleController.kt new file mode 100644 index 0000000000000..48df15c78ea5a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleController.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2022 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.ripple + +import androidx.annotation.VisibleForTesting + +/** Controller that handles playing [RippleAnimation]. */ +class MultiRippleController(private val multipleRippleView: MultiRippleView) { + + companion object { + /** Max number of ripple animations at a time. */ + @VisibleForTesting const val MAX_RIPPLE_NUMBER = 10 + } + + /** Updates all the ripple colors during the animation. */ + fun updateColor(color: Int) { + multipleRippleView.ripples.forEach { anim -> anim.updateColor(color) } + } + + fun play(rippleAnimation: RippleAnimation) { + if (multipleRippleView.ripples.size >= MAX_RIPPLE_NUMBER) { + return + } + + multipleRippleView.ripples.add(rippleAnimation) + + // Remove ripple once the animation is done + rippleAnimation.play { multipleRippleView.ripples.remove(rippleAnimation) } + + // Trigger drawing + multipleRippleView.invalidate() + } +} diff --git a/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleView.kt b/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleView.kt new file mode 100644 index 0000000000000..c7f0b7e0056eb --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/ripple/MultiRippleView.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2022 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.ripple + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.util.AttributeSet +import android.util.Log +import android.view.View + +/** + * A view that allows multiple ripples to play. + * + * Use [MultiRippleController] to play ripple animations. + */ +class MultiRippleView(context: Context?, attrs: AttributeSet?) : View(context, attrs) { + + internal val ripples = ArrayList() + private val ripplePaint = Paint() + private var isWarningLogged = false + + companion object { + const val TAG = "MultiRippleView" + } + + override fun onDraw(canvas: Canvas?) { + if (canvas == null || !canvas.isHardwareAccelerated) { + // Drawing with the ripple shader requires hardware acceleration, so skip + // if it's unsupported. + if (!isWarningLogged) { + // Only log once to not spam. + Log.w( + TAG, + "Can't draw ripple shader. $canvas does not support hardware acceleration." + ) + isWarningLogged = true + } + return + } + + var shouldInvalidate = false + + ripples.forEach { anim -> + ripplePaint.shader = anim.rippleShader + canvas.drawPaint(ripplePaint) + + shouldInvalidate = shouldInvalidate || anim.isPlaying() + } + + if (shouldInvalidate) invalidate() + } +} diff --git a/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimation.kt b/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimation.kt new file mode 100644 index 0000000000000..aca9e254e4c3f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimation.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2022 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.ripple + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import androidx.core.graphics.ColorUtils + +/** A single ripple animation. */ +class RippleAnimation(private val config: RippleAnimationConfig) { + internal val rippleShader: RippleShader = RippleShader(config.rippleShape) + private val animator: ValueAnimator = ValueAnimator.ofFloat(0f, 1f) + + init { + applyConfigToShader() + } + + /** Updates the ripple color during the animation. */ + fun updateColor(color: Int) { + config.apply { config.color = color } + applyConfigToShader() + } + + @JvmOverloads + fun play(onAnimationEnd: Runnable? = null) { + if (isPlaying()) { + return // Ignore if ripple effect is already playing + } + + animator.duration = config.duration + animator.addUpdateListener { updateListener -> + val now = updateListener.currentPlayTime + val progress = updateListener.animatedValue as Float + rippleShader.progress = progress + rippleShader.distortionStrength = if (config.shouldDistort) 1 - progress else 0f + rippleShader.time = now.toFloat() + } + animator.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator?) { + onAnimationEnd?.run() + } + } + ) + animator.start() + } + + /** Indicates whether the animation is playing. */ + fun isPlaying(): Boolean = animator.isRunning + + private fun applyConfigToShader() { + rippleShader.setCenter(config.centerX, config.centerY) + rippleShader.setMaxSize(config.maxWidth, config.maxHeight) + rippleShader.rippleFill = config.shouldFillRipple + rippleShader.pixelDensity = config.pixelDensity + rippleShader.color = ColorUtils.setAlphaComponent(config.color, config.opacity) + rippleShader.sparkleStrength = config.sparkleStrength + } +} diff --git a/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimationConfig.kt b/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimationConfig.kt new file mode 100644 index 0000000000000..88122544c7cd5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/ripple/RippleAnimationConfig.kt @@ -0,0 +1,32 @@ +package com.android.systemui.ripple + +import android.graphics.Color + +/** + * A struct that holds the ripple animation configurations. + * + *

This configuration is designed to play a SINGLE animation. Do not reuse or modify the + * configuration parameters to play different animations, unless the value has to change within the + * single animation (e.g. Change color or opacity during the animation). Note that this data class + * is pulled out to make the [RippleAnimation] constructor succinct. + */ +data class RippleAnimationConfig( + val rippleShape: RippleShader.RippleShape = RippleShader.RippleShape.CIRCLE, + val duration: Long = 0L, + val centerX: Float = 0f, + val centerY: Float = 0f, + val maxWidth: Float = 0f, + val maxHeight: Float = 0f, + val pixelDensity: Float = 1f, + var color: Int = Color.WHITE, + val opacity: Int = RIPPLE_DEFAULT_ALPHA, + val shouldFillRipple: Boolean = false, + val sparkleStrength: Float = RIPPLE_SPARKLE_STRENGTH, + val shouldDistort: Boolean = true +) { + companion object { + const val RIPPLE_SPARKLE_STRENGTH: Float = 0.3f + const val RIPPLE_DEFAULT_COLOR: Int = 0xffffffff.toInt() + const val RIPPLE_DEFAULT_ALPHA: Int = 45 // full opacity is 255. + } +} diff --git a/packages/SystemUI/src/com/android/systemui/ripple/RippleView.kt b/packages/SystemUI/src/com/android/systemui/ripple/RippleView.kt index 1e51ffa292b7b..a6d79303962f6 100644 --- a/packages/SystemUI/src/com/android/systemui/ripple/RippleView.kt +++ b/packages/SystemUI/src/com/android/systemui/ripple/RippleView.kt @@ -28,10 +28,6 @@ import android.view.View import androidx.core.graphics.ColorUtils import com.android.systemui.ripple.RippleShader.RippleShape -private const val RIPPLE_SPARKLE_STRENGTH: Float = 0.3f -private const val RIPPLE_DEFAULT_COLOR: Int = 0xffffffff.toInt() -const val RIPPLE_DEFAULT_ALPHA: Int = 45 - /** * A generic expanding ripple effect. * @@ -45,8 +41,8 @@ open class RippleView(context: Context?, attrs: AttributeSet?) : View(context, a private set private val ripplePaint = Paint() + private val animator = ValueAnimator.ofFloat(0f, 1f) - var rippleInProgress: Boolean = false var duration: Long = 1750 private var maxWidth: Float = 0.0f @@ -80,9 +76,9 @@ open class RippleView(context: Context?, attrs: AttributeSet?) : View(context, a this.rippleShape = rippleShape rippleShader = RippleShader(rippleShape) - rippleShader.color = RIPPLE_DEFAULT_COLOR + rippleShader.color = RippleAnimationConfig.RIPPLE_DEFAULT_COLOR rippleShader.progress = 0f - rippleShader.sparkleStrength = RIPPLE_SPARKLE_STRENGTH + rippleShader.sparkleStrength = RippleAnimationConfig.RIPPLE_SPARKLE_STRENGTH rippleShader.pixelDensity = resources.displayMetrics.density ripplePaint.shader = rippleShader @@ -90,10 +86,9 @@ open class RippleView(context: Context?, attrs: AttributeSet?) : View(context, a @JvmOverloads fun startRipple(onAnimationEnd: Runnable? = null) { - if (rippleInProgress) { + if (animator.isRunning) { return // Ignore if ripple effect is already playing } - val animator = ValueAnimator.ofFloat(0f, 1f) animator.duration = duration animator.addUpdateListener { updateListener -> val now = updateListener.currentPlayTime @@ -105,19 +100,17 @@ open class RippleView(context: Context?, attrs: AttributeSet?) : View(context, a } animator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator?) { - rippleInProgress = false onAnimationEnd?.run() } }) animator.start() - rippleInProgress = true } /** Set the color to be used for the ripple. * * The alpha value of the color will be applied to the ripple. The alpha range is [0-100]. */ - fun setColor(color: Int, alpha: Int = RIPPLE_DEFAULT_ALPHA) { + fun setColor(color: Int, alpha: Int = RippleAnimationConfig.RIPPLE_DEFAULT_ALPHA) { rippleShader.color = ColorUtils.setAlphaComponent(color, alpha) } @@ -137,6 +130,9 @@ open class RippleView(context: Context?, attrs: AttributeSet?) : View(context, a rippleShader.sparkleStrength = strength } + /** Indicates whether the ripple animation is playing. */ + fun rippleInProgress(): Boolean = animator.isRunning + override fun onDraw(canvas: Canvas?) { if (canvas == null || !canvas.isHardwareAccelerated) { // Drawing with the ripple shader requires hardware acceleration, so skip diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/ColorSchemeTransitionTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/ColorSchemeTransitionTest.kt index 5bb74e5a31f1f..a8f413848009e 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/ColorSchemeTransitionTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/ColorSchemeTransitionTest.kt @@ -25,6 +25,7 @@ import com.android.systemui.SysuiTestCase import com.android.systemui.media.controls.models.GutsViewHolder import com.android.systemui.media.controls.models.player.MediaViewHolder import com.android.systemui.monet.ColorScheme +import com.android.systemui.ripple.MultiRippleController import junit.framework.Assert.assertEquals import org.junit.After import org.junit.Before @@ -60,6 +61,7 @@ class ColorSchemeTransitionTest : SysuiTestCase() { private lateinit var animatingColorTransitionFactory: AnimatingColorTransitionFactory @Mock private lateinit var mediaViewHolder: MediaViewHolder @Mock private lateinit var gutsViewHolder: GutsViewHolder + @Mock private lateinit var multiRippleController: MultiRippleController @JvmField @Rule val mockitoRule = MockitoJUnit.rule() @@ -70,7 +72,12 @@ class ColorSchemeTransitionTest : SysuiTestCase() { whenever(extractColor.invoke(colorScheme)).thenReturn(TARGET_COLOR) colorSchemeTransition = - ColorSchemeTransition(context, mediaViewHolder, animatingColorTransitionFactory) + ColorSchemeTransition( + context, + mediaViewHolder, + multiRippleController, + animatingColorTransitionFactory + ) colorTransition = object : AnimatingColorTransition(DEFAULT_COLOR, extractColor, applyColor) { diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/MediaControlPanelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/MediaControlPanelTest.kt index 584305334b6fd..81901569bde87 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/MediaControlPanelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/controls/ui/MediaControlPanelTest.kt @@ -59,6 +59,8 @@ import com.android.systemui.R import com.android.systemui.SysuiTestCase import com.android.systemui.bluetooth.BroadcastDialogController import com.android.systemui.broadcast.BroadcastSender +import com.android.systemui.flags.FakeFeatureFlags +import com.android.systemui.flags.Flags import com.android.systemui.media.controls.MediaTestUtils import com.android.systemui.media.controls.models.GutsViewHolder import com.android.systemui.media.controls.models.player.MediaAction @@ -76,6 +78,7 @@ import com.android.systemui.media.controls.util.MediaUiEventLogger import com.android.systemui.media.dialog.MediaOutputDialogFactory import com.android.systemui.plugins.ActivityStarter import com.android.systemui.plugins.FalsingManager +import com.android.systemui.ripple.MultiRippleView import com.android.systemui.statusbar.NotificationLockscreenUserManager import com.android.systemui.statusbar.policy.KeyguardStateController import com.android.systemui.util.animation.TransitionLayout @@ -174,6 +177,7 @@ public class MediaControlPanelTest : SysuiTestCase() { private lateinit var cancelText: TextView private lateinit var dismiss: FrameLayout private lateinit var dismissText: TextView + private lateinit var multiRippleView: MultiRippleView private lateinit var session: MediaSession private lateinit var device: MediaDeviceData @@ -205,6 +209,8 @@ public class MediaControlPanelTest : SysuiTestCase() { private lateinit var recSubtitle2: TextView private lateinit var recSubtitle3: TextView private var shouldShowBroadcastButton: Boolean = false + private val fakeFeatureFlag = + FakeFeatureFlags().apply { this.set(Flags.UMO_SURFACE_RIPPLE, false) } @JvmField @Rule val mockito = MockitoJUnit.rule() @@ -244,7 +250,8 @@ public class MediaControlPanelTest : SysuiTestCase() { keyguardStateController, activityIntentHelper, lockscreenUserManager, - broadcastDialogController + broadcastDialogController, + fakeFeatureFlag ) { override fun loadAnimator( animId: Int, @@ -374,6 +381,8 @@ public class MediaControlPanelTest : SysuiTestCase() { ) } + multiRippleView = MultiRippleView(context, null) + whenever(viewHolder.player).thenReturn(view) whenever(viewHolder.appIcon).thenReturn(appIcon) whenever(viewHolder.albumView).thenReturn(albumView) @@ -414,6 +423,8 @@ public class MediaControlPanelTest : SysuiTestCase() { whenever(viewHolder.getAction(R.id.action4)).thenReturn(action4) whenever(viewHolder.actionsTopBarrier).thenReturn(actionsTopBarrier) + + whenever(viewHolder.multiRippleView).thenReturn(multiRippleView) } /** Initialize elements for the recommendation view holder */ @@ -1973,6 +1984,50 @@ public class MediaControlPanelTest : SysuiTestCase() { assertThat(expandedSet.getVisibility(recSubtitle3.id)).isEqualTo(ConstraintSet.GONE) } + @Test + fun onButtonClick_touchRippleFlagEnabled_playsTouchRipple() { + fakeFeatureFlag.set(Flags.UMO_SURFACE_RIPPLE, true) + val semanticActions = + MediaButton( + playOrPause = + MediaAction( + icon = null, + action = {}, + contentDescription = "play", + background = null + ) + ) + val data = mediaData.copy(semanticActions = semanticActions) + player.attachPlayer(viewHolder) + player.bindPlayer(data, KEY) + + viewHolder.actionPlayPause.callOnClick() + + assertThat(viewHolder.multiRippleView.ripples.size).isEqualTo(1) + } + + @Test + fun onButtonClick_touchRippleFlagDisabled_doesNotPlayTouchRipple() { + fakeFeatureFlag.set(Flags.UMO_SURFACE_RIPPLE, false) + val semanticActions = + MediaButton( + playOrPause = + MediaAction( + icon = null, + action = {}, + contentDescription = "play", + background = null + ) + ) + val data = mediaData.copy(semanticActions = semanticActions) + player.attachPlayer(viewHolder) + player.bindPlayer(data, KEY) + + viewHolder.actionPlayPause.callOnClick() + + assertThat(viewHolder.multiRippleView.ripples.size).isEqualTo(0) + } + private fun getScrubbingChangeListener(): SeekBarViewModel.ScrubbingChangeListener = withArgCaptor { verify(seekBarViewModel).setScrubbingChangeListener(capture()) diff --git a/packages/SystemUI/tests/src/com/android/systemui/ripple/MultiRippleControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/ripple/MultiRippleControllerTest.kt new file mode 100644 index 0000000000000..05512e5bf1ceb --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/ripple/MultiRippleControllerTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2022 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.ripple + +import android.graphics.Color +import android.testing.AndroidTestingRunner +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.ripple.MultiRippleController.Companion.MAX_RIPPLE_NUMBER +import com.android.systemui.util.concurrency.FakeExecutor +import com.android.systemui.util.time.FakeSystemClock +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class MultiRippleControllerTest : SysuiTestCase() { + private lateinit var multiRippleController: MultiRippleController + private lateinit var multiRippleView: MultiRippleView + private lateinit var rippleAnimationConfig: RippleAnimationConfig + private val fakeSystemClock = FakeSystemClock() + + // FakeExecutor is needed to run animator. + private val fakeExecutor = FakeExecutor(fakeSystemClock) + + @Before + fun setup() { + rippleAnimationConfig = RippleAnimationConfig(duration = 1000L) + multiRippleView = MultiRippleView(context, null) + multiRippleController = MultiRippleController(multiRippleView) + } + + @Test + fun updateColor_updatesColor() { + val initialColor = Color.WHITE + val expectedColor = Color.RED + + fakeExecutor.execute { + val rippleAnimation = + RippleAnimation(rippleAnimationConfig.apply { this.color = initialColor }) + + with(multiRippleController) { + play(rippleAnimation) + updateColor(expectedColor) + } + + assertThat(rippleAnimationConfig.color).isEqualTo(expectedColor) + } + } + + @Test + fun play_playsRipple() { + fakeExecutor.execute { + val rippleAnimation = RippleAnimation(rippleAnimationConfig) + + multiRippleController.play(rippleAnimation) + + assertThat(multiRippleView.ripples.size).isEqualTo(1) + assertThat(multiRippleView.ripples[0]).isEqualTo(rippleAnimation) + } + } + + @Test + fun play_doesNotExceedMaxRipple() { + fakeExecutor.execute { + for (i in 0..MAX_RIPPLE_NUMBER + 10) { + multiRippleController.play(RippleAnimation(rippleAnimationConfig)) + } + + assertThat(multiRippleView.ripples.size).isEqualTo(MAX_RIPPLE_NUMBER) + } + } + + @Test + fun play_onEnd_removesAnimation() { + fakeExecutor.execute { + val rippleAnimation = RippleAnimation(rippleAnimationConfig) + multiRippleController.play(rippleAnimation) + + assertThat(multiRippleView.ripples.size).isEqualTo(1) + assertThat(multiRippleView.ripples[0]).isEqualTo(rippleAnimation) + + fakeSystemClock.advanceTime(rippleAnimationConfig.duration) + + assertThat(multiRippleView.ripples.size).isEqualTo(0) + } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/ripple/RippleAnimationTest.kt b/packages/SystemUI/tests/src/com/android/systemui/ripple/RippleAnimationTest.kt new file mode 100644 index 0000000000000..7662282a04f44 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/ripple/RippleAnimationTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2022 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.ripple + +import android.graphics.Color +import android.testing.AndroidTestingRunner +import androidx.core.graphics.ColorUtils +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.util.concurrency.FakeExecutor +import com.android.systemui.util.time.FakeSystemClock +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class RippleAnimationTest : SysuiTestCase() { + + private val fakeSystemClock = FakeSystemClock() + private val fakeExecutor = FakeExecutor(fakeSystemClock) + + @Test + fun init_shaderHasCorrectConfig() { + val config = + RippleAnimationConfig( + duration = 3000L, + pixelDensity = 2f, + color = Color.RED, + opacity = 30, + shouldFillRipple = true, + sparkleStrength = 0.3f + ) + val rippleAnimation = RippleAnimation(config) + + with(rippleAnimation.rippleShader) { + assertThat(rippleFill).isEqualTo(config.shouldFillRipple) + assertThat(pixelDensity).isEqualTo(config.pixelDensity) + assertThat(color).isEqualTo(ColorUtils.setAlphaComponent(config.color, config.opacity)) + assertThat(sparkleStrength).isEqualTo(config.sparkleStrength) + } + } + + @Test + fun updateColor_updatesColorCorrectly() { + val initialColor = Color.WHITE + val expectedColor = Color.RED + val config = RippleAnimationConfig(color = initialColor) + val rippleAnimation = RippleAnimation(config) + + fakeExecutor.execute { + with(rippleAnimation) { + play() + updateColor(expectedColor) + } + + assertThat(config.color).isEqualTo(expectedColor) + } + } + + @Test + fun play_updatesIsPlaying() { + val config = RippleAnimationConfig(duration = 1000L) + val rippleAnimation = RippleAnimation(config) + + fakeExecutor.execute { + rippleAnimation.play() + + assertThat(rippleAnimation.isPlaying()).isTrue() + + // move time to finish the animation + fakeSystemClock.advanceTime(config.duration) + + assertThat(rippleAnimation.isPlaying()).isFalse() + } + } + + @Test + fun play_onEnd_triggersOnAnimationEnd() { + val config = RippleAnimationConfig(duration = 1000L) + val rippleAnimation = RippleAnimation(config) + var animationEnd = false + + fakeExecutor.execute { + rippleAnimation.play(onAnimationEnd = { animationEnd = true }) + + fakeSystemClock.advanceTime(config.duration) + + assertThat(animationEnd).isTrue() + } + } +}