mRegistrations = new ArrayList<>();
- private Integer mRebindId = null;
-
- // This check prevents rebinding to the action button if the identifier has not changed. A
- // null value is always considered to be changed. This is used to prevent the connecting
- // animation from rebinding (and restarting) if multiple buffer PlaybackStates are pushed by
- // an application in a row.
- public boolean updateRebindId(Integer rebindId) {
- if (mRebindId == null || rebindId == null || !mRebindId.equals(rebindId)) {
- mRebindId = rebindId;
- return true;
- }
- return false;
- }
-
- public void tryRegister(Drawable drawable) {
- if (drawable instanceof Animatable2) {
- Animatable2 anim = (Animatable2) drawable;
- anim.registerAnimationCallback(this);
- mRegistrations.add(anim);
- }
- }
-
- public void unregisterAll() {
- for (Animatable2 anim : mRegistrations) {
- anim.unregisterAnimationCallback(this);
- }
- mRegistrations.clear();
- }
-
- public boolean isAnimationRunning() {
- for (Animatable2 anim : mRegistrations) {
- if (anim.isRunning()) {
- return true;
- }
- }
- return false;
- }
-
- public void tryExecute(Runnable action) {
- if (isAnimationRunning()) {
- mOnAnimationsComplete.add(action);
- } else {
- action.run();
- }
- }
-
- @Override
- public void onAnimationEnd(Drawable drawable) {
- super.onAnimationEnd(drawable);
- if (!isAnimationRunning()) {
- for (Runnable action : mOnAnimationsComplete) {
- action.run();
- }
- mOnAnimationsComplete.clear();
- }
- }
- }
-
@Nullable
private ActivityLaunchAnimator.Controller buildLaunchAnimatorController(
TransitionLayout player) {
@@ -1097,7 +1081,9 @@ public class MediaControlPanel {
});
mController = null;
- mMediaViewController.refreshState();
+ if (mMetadataAnimationHandler == null || !mMetadataAnimationHandler.isRunning()) {
+ mMediaViewController.refreshState();
+ }
}
/**
diff --git a/packages/SystemUI/src/com/android/systemui/media/MetadataAnimationHandler.kt b/packages/SystemUI/src/com/android/systemui/media/MetadataAnimationHandler.kt
new file mode 100644
index 0000000000000..9a1a6d35e3e36
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/MetadataAnimationHandler.kt
@@ -0,0 +1,86 @@
+/*
+ * 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.media
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.AnimatorSet
+import com.android.internal.annotations.VisibleForTesting
+
+/**
+ * MetadataAnimationHandler controls the current state of the MediaControlPanel's transition motion.
+ *
+ * It checks for a changed data object (artist & title from MediaControlPanel) and runs the
+ * animation if necessary. When the motion has fully transitioned the elements out, it runs the
+ * update callback to modify the view data, before the enter animation runs.
+ */
+internal open class MetadataAnimationHandler(
+ private val exitAnimator: Animator,
+ private val enterAnimator: Animator
+) : AnimatorListenerAdapter() {
+
+ private val animator: AnimatorSet
+ private var postExitUpdate: (() -> Unit)? = null
+ private var postEnterUpdate: (() -> Unit)? = null
+ private var targetData: Any? = null
+
+ val isRunning: Boolean
+ get() = animator.isRunning
+
+ fun setNext(targetData: Any, postExit: () -> Unit, postEnter: () -> Unit): Boolean {
+ if (targetData != this.targetData) {
+ this.targetData = targetData
+ postExitUpdate = postExit
+ postEnterUpdate = postEnter
+ if (!animator.isRunning) {
+ animator.start()
+ }
+ return true
+ }
+ return false
+ }
+
+ override fun onAnimationEnd(animator: Animator) {
+ if (animator === exitAnimator) {
+ postExitUpdate?.let { it() }
+ postExitUpdate = null
+ }
+
+ if (animator === enterAnimator) {
+ // Another new update appeared while entering
+ if (postExitUpdate != null) {
+ this.animator.start()
+ } else {
+ postEnterUpdate?.let { it() }
+ postEnterUpdate = null
+ }
+ }
+ }
+
+ init {
+ exitAnimator.addListener(this)
+ enterAnimator.addListener(this)
+ animator = buildAnimatorSet(exitAnimator, enterAnimator)
+ }
+
+ @VisibleForTesting
+ protected open fun buildAnimatorSet(exit: Animator, enter: Animator): AnimatorSet {
+ val result = AnimatorSet()
+ result.playSequentially(exitAnimator, enterAnimator)
+ return result
+ }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt b/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt
index 612a7f92fc339..c9d300bad5e0e 100644
--- a/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt
@@ -16,20 +16,29 @@
package com.android.systemui.media
+import android.animation.Animator
+import android.animation.ObjectAnimator
import android.text.format.DateUtils
import androidx.annotation.UiThread
import androidx.lifecycle.Observer
+import com.android.internal.annotations.VisibleForTesting
import com.android.systemui.R
+import com.android.systemui.animation.Interpolators
/**
* Observer for changes from SeekBarViewModel.
*
* Updates the seek bar views in response to changes to the model.
*/
-class SeekBarObserver(
+open class SeekBarObserver(
private val holder: MediaViewHolder
) : Observer {
+ companion object {
+ @JvmStatic val RESET_ANIMATION_DURATION_MS: Int = 750
+ @JvmStatic val RESET_ANIMATION_THRESHOLD_MS: Int = 250
+ }
+
val seekBarEnabledMaxHeight = holder.seekBar.context.resources
.getDimensionPixelSize(R.dimen.qs_media_enabled_seekbar_height)
val seekBarDisabledHeight = holder.seekBar.context.resources
@@ -38,6 +47,7 @@ class SeekBarObserver(
.getDimensionPixelSize(R.dimen.qs_media_session_enabled_seekbar_vertical_padding)
val seekBarDisabledVerticalPadding = holder.seekBar.context.resources
.getDimensionPixelSize(R.dimen.qs_media_session_disabled_seekbar_vertical_padding)
+ var seekBarResetAnimator: Animator? = null
init {
val seekBarProgressWavelength = holder.seekBar.context.resources
@@ -91,7 +101,17 @@ class SeekBarObserver(
holder.scrubbingTotalTimeView.text = totalTimeString
data.elapsedTime?.let {
- holder.seekBar.setProgress(it)
+ if (!data.scrubbing && !(seekBarResetAnimator?.isRunning ?: false)) {
+ if (it <= RESET_ANIMATION_THRESHOLD_MS &&
+ holder.seekBar.progress > RESET_ANIMATION_THRESHOLD_MS) {
+ // This animation resets for every additional update to zero.
+ val animator = buildResetAnimator(it)
+ animator.start()
+ seekBarResetAnimator = animator
+ } else {
+ holder.seekBar.progress = it
+ }
+ }
val elapsedTimeString = DateUtils.formatElapsedTime(
it / DateUtils.SECOND_IN_MILLIS)
holder.scrubbingElapsedTimeView.text = elapsedTimeString
@@ -104,6 +124,16 @@ class SeekBarObserver(
}
}
+ @VisibleForTesting
+ open fun buildResetAnimator(targetTime: Int): Animator {
+ val animator = ObjectAnimator.ofInt(holder.seekBar, "progress",
+ holder.seekBar.progress, targetTime + RESET_ANIMATION_DURATION_MS)
+ animator.setAutoCancel(true)
+ animator.duration = RESET_ANIMATION_DURATION_MS.toLong()
+ animator.interpolator = Interpolators.EMPHASIZED
+ return animator
+ }
+
@UiThread
fun setVerticalPadding(padding: Int) {
val leftPadding = holder.seekBar.paddingLeft
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/AnimationBindHandlerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/AnimationBindHandlerTest.kt
new file mode 100644
index 0000000000000..e4cab18108220
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/AnimationBindHandlerTest.kt
@@ -0,0 +1,144 @@
+/*
+ * 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.media
+
+import org.mockito.Mockito.`when` as whenever
+import android.graphics.drawable.Animatable2
+import android.graphics.drawable.Drawable
+import android.test.suitebuilder.annotation.SmallTest
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import com.android.systemui.SysuiTestCase
+import junit.framework.Assert.assertTrue
+import junit.framework.Assert.assertFalse
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.times
+import org.mockito.Mockito.never
+import org.mockito.junit.MockitoJUnit
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class AnimationBindHandlerTest : SysuiTestCase() {
+
+ private interface Callback : () -> Unit
+ private abstract class AnimatedDrawable : Drawable(), Animatable2
+ private lateinit var handler: AnimationBindHandler
+
+ @Mock private lateinit var animatable: AnimatedDrawable
+ @Mock private lateinit var animatable2: AnimatedDrawable
+ @Mock private lateinit var callback: Callback
+
+ @JvmField @Rule val mockito = MockitoJUnit.rule()
+
+ @Before
+ fun setUp() {
+ handler = AnimationBindHandler()
+ }
+
+ @After
+ fun tearDown() {}
+
+ @Test
+ fun registerNoAnimations_executeCallbackImmediately() {
+ handler.tryExecute(callback)
+ verify(callback).invoke()
+ }
+
+ @Test
+ fun registerStoppedAnimations_executeCallbackImmediately() {
+ whenever(animatable.isRunning).thenReturn(false)
+ whenever(animatable2.isRunning).thenReturn(false)
+
+ handler.tryExecute(callback)
+ verify(callback).invoke()
+ }
+
+ @Test
+ fun registerRunningAnimations_executeCallbackDelayed() {
+ whenever(animatable.isRunning).thenReturn(true)
+ whenever(animatable2.isRunning).thenReturn(true)
+
+ handler.tryRegister(animatable)
+ handler.tryRegister(animatable2)
+ handler.tryExecute(callback)
+
+ verify(callback, never()).invoke()
+
+ whenever(animatable.isRunning).thenReturn(false)
+ handler.onAnimationEnd(animatable)
+ verify(callback, never()).invoke()
+
+ whenever(animatable2.isRunning).thenReturn(false)
+ handler.onAnimationEnd(animatable2)
+ verify(callback, times(1)).invoke()
+ }
+
+ @Test
+ fun repeatedEndCallback_executeSingleCallback() {
+ whenever(animatable.isRunning).thenReturn(true)
+
+ handler.tryRegister(animatable)
+ handler.tryExecute(callback)
+
+ verify(callback, never()).invoke()
+
+ whenever(animatable.isRunning).thenReturn(false)
+ handler.onAnimationEnd(animatable)
+ handler.onAnimationEnd(animatable)
+ handler.onAnimationEnd(animatable)
+ verify(callback, times(1)).invoke()
+ }
+
+ @Test
+ fun registerUnregister_executeImmediately() {
+ whenever(animatable.isRunning).thenReturn(true)
+
+ handler.tryRegister(animatable)
+ handler.unregisterAll()
+ handler.tryExecute(callback)
+
+ verify(callback).invoke()
+ }
+
+ @Test
+ fun updateRebindId_returnsAsExpected() {
+ // Previous or current call is null, returns true
+ assertTrue(handler.updateRebindId(null))
+ assertTrue(handler.updateRebindId(null))
+ assertTrue(handler.updateRebindId(null))
+ assertTrue(handler.updateRebindId(10))
+ assertTrue(handler.updateRebindId(null))
+ assertTrue(handler.updateRebindId(20))
+
+ // Different integer from prevoius, returns true
+ assertTrue(handler.updateRebindId(10))
+ assertTrue(handler.updateRebindId(20))
+
+ // Matches previous call, returns false
+ assertFalse(handler.updateRebindId(20))
+ assertFalse(handler.updateRebindId(20))
+ assertTrue(handler.updateRebindId(10))
+ assertFalse(handler.updateRebindId(10))
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/ColorSchemeTransitionTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/ColorSchemeTransitionTest.kt
new file mode 100644
index 0000000000000..86527d9558fda
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/ColorSchemeTransitionTest.kt
@@ -0,0 +1,149 @@
+/*
+ * 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.media
+
+import org.mockito.Mockito.`when` as whenever
+import android.animation.ValueAnimator
+import android.graphics.Color
+import android.test.suitebuilder.annotation.SmallTest
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.monet.ColorScheme
+import junit.framework.Assert.assertEquals
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.junit.MockitoJUnit
+
+private const val DEFAULT_COLOR = Color.RED
+private const val TARGET_COLOR = Color.BLUE
+private const val BG_COLOR = Color.GREEN
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class ColorSchemeTransitionTest : SysuiTestCase() {
+
+ private interface ExtractCB : (ColorScheme) -> Int
+ private interface ApplyCB : (Int) -> Unit
+ private lateinit var colorTransition: ColorTransition
+ private lateinit var colorSchemeTransition: ColorSchemeTransition
+
+ @Mock private lateinit var mockTransition: ColorTransition
+ @Mock private lateinit var valueAnimator: ValueAnimator
+ @Mock private lateinit var colorScheme: ColorScheme
+ @Mock private lateinit var extractColor: ExtractCB
+ @Mock private lateinit var applyColor: ApplyCB
+
+ private lateinit var transitionFactory: ColorTransitionFactory
+ @Mock private lateinit var mediaViewHolder: MediaViewHolder
+
+ @JvmField @Rule val mockitoRule = MockitoJUnit.rule()
+
+ @Before
+ fun setUp() {
+ transitionFactory = { default, extractColor, applyColor -> mockTransition }
+ whenever(extractColor.invoke(colorScheme)).thenReturn(TARGET_COLOR)
+
+ colorSchemeTransition = ColorSchemeTransition(context,
+ BG_COLOR, mediaViewHolder, transitionFactory)
+
+ colorTransition = object : ColorTransition(DEFAULT_COLOR, extractColor, applyColor) {
+ override fun buildAnimator(): ValueAnimator {
+ return valueAnimator
+ }
+ }
+ }
+
+ @After
+ fun tearDown() {}
+
+ @Test
+ fun testColorTransition_nullColorScheme_keepsDefault() {
+ colorTransition.updateColorScheme(null)
+ verify(applyColor, times(1)).invoke(DEFAULT_COLOR)
+ verify(valueAnimator, never()).start()
+ assertEquals(DEFAULT_COLOR, colorTransition.sourceColor)
+ assertEquals(DEFAULT_COLOR, colorTransition.targetColor)
+ }
+
+ @Test
+ fun testColorTransition_newColor_startsAnimation() {
+ colorTransition.updateColorScheme(colorScheme)
+ verify(applyColor, times(1)).invoke(DEFAULT_COLOR)
+ verify(valueAnimator, times(1)).start()
+ assertEquals(DEFAULT_COLOR, colorTransition.sourceColor)
+ assertEquals(TARGET_COLOR, colorTransition.targetColor)
+ }
+
+ @Test
+ fun testColorTransition_sameColor_noAnimation() {
+ whenever(extractColor.invoke(colorScheme)).thenReturn(DEFAULT_COLOR)
+ colorTransition.updateColorScheme(colorScheme)
+ verify(valueAnimator, never()).start()
+ assertEquals(DEFAULT_COLOR, colorTransition.sourceColor)
+ assertEquals(DEFAULT_COLOR, colorTransition.targetColor)
+ }
+
+ @Test
+ fun testColorTransition_colorAnimation_startValues() {
+ val expectedColor = DEFAULT_COLOR
+ whenever(valueAnimator.animatedFraction).thenReturn(0f)
+ colorTransition.updateColorScheme(colorScheme)
+ colorTransition.onAnimationUpdate(valueAnimator)
+
+ assertEquals(expectedColor, colorTransition.currentColor)
+ assertEquals(expectedColor, colorTransition.sourceColor)
+ verify(applyColor, times(2)).invoke(expectedColor) // applied once in constructor
+ }
+
+ @Test
+ fun testColorTransition_colorAnimation_endValues() {
+ val expectedColor = TARGET_COLOR
+ whenever(valueAnimator.animatedFraction).thenReturn(1f)
+ colorTransition.updateColorScheme(colorScheme)
+ colorTransition.onAnimationUpdate(valueAnimator)
+
+ assertEquals(expectedColor, colorTransition.currentColor)
+ assertEquals(expectedColor, colorTransition.targetColor)
+ verify(applyColor).invoke(expectedColor)
+ }
+
+ @Test
+ fun testColorTransition_colorAnimation_interpolatedMidpoint() {
+ val expectedColor = Color.rgb(186, 0, 186)
+ whenever(valueAnimator.animatedFraction).thenReturn(0.5f)
+ colorTransition.updateColorScheme(colorScheme)
+ colorTransition.onAnimationUpdate(valueAnimator)
+
+ assertEquals(expectedColor, colorTransition.currentColor)
+ verify(applyColor).invoke(expectedColor)
+ }
+
+ @Test
+ fun testColorSchemeTransition_update() {
+ colorSchemeTransition.updateColorScheme(colorScheme)
+ verify(mockTransition, times(6)).updateColorScheme(colorScheme)
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt
index a58a28e3920b2..a39ae6c231bda 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt
@@ -16,6 +16,8 @@
package com.android.systemui.media
+import android.animation.Animator
+import android.animation.AnimatorSet
import android.app.PendingIntent
import android.app.smartspace.SmartspaceAction
import android.content.Context
@@ -39,6 +41,7 @@ import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.View
import android.view.ViewGroup
+import android.view.animation.Interpolator
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
@@ -58,6 +61,7 @@ import com.android.systemui.plugins.FalsingManager
import com.android.systemui.util.animation.TransitionLayout
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.mockito.KotlinArgumentCaptor
+import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.withArgCaptor
import com.android.systemui.util.time.FakeSystemClock
@@ -140,6 +144,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
private lateinit var actionsTopBarrier: Barrier
@Mock private lateinit var longPressText: TextView
@Mock private lateinit var handler: Handler
+ @Mock private lateinit var mockAnimator: AnimatorSet
private lateinit var settings: ImageButton
private lateinit var cancel: View
private lateinit var cancelText: TextView
@@ -181,7 +186,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
whenever(packageManager.getApplicationLabel(any())).thenReturn(PACKAGE)
context.setMockPackageManager(packageManager)
- player = MediaControlPanel(
+ player = object : MediaControlPanel(
context,
bgExecutor,
mainExecutor,
@@ -194,8 +199,15 @@ public class MediaControlPanelTest : SysuiTestCase() {
mediaCarouselController,
falsingManager,
clock,
- logger
- )
+ logger) {
+ override fun loadAnimator(
+ animId: Int,
+ otionInterpolator: Interpolator,
+ vararg targets: View
+ ): AnimatorSet {
+ return mockAnimator
+ }
+ }
initMediaViewHolderMocks()
@@ -470,7 +482,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
val icon = context.getDrawable(android.R.drawable.ic_media_play)
val semanticActions = MediaButton(
prevOrCustom = MediaAction(icon, {}, "prev", null),
- nextOrCustom = MediaAction(icon, {}, "next", null),
+ nextOrCustom = MediaAction(icon, {}, "next", null)
)
val state = mediaData.copy(semanticActions = semanticActions)
@@ -504,7 +516,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
val icon = context.getDrawable(android.R.drawable.ic_media_play)
val semanticActions = MediaButton(
prevOrCustom = null,
- nextOrCustom = MediaAction(icon, {}, "next", null),
+ nextOrCustom = MediaAction(icon, {}, "next", null)
)
val state = mediaData.copy(semanticActions = semanticActions)
player.attachPlayer(viewHolder)
@@ -524,7 +536,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
val icon = context.getDrawable(android.R.drawable.ic_media_play)
val semanticActions = MediaButton(
prevOrCustom = MediaAction(icon, {}, "prev", null),
- nextOrCustom = null,
+ nextOrCustom = null
)
val state = mediaData.copy(semanticActions = semanticActions)
player.attachPlayer(viewHolder)
@@ -544,7 +556,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
val icon = context.getDrawable(android.R.drawable.ic_media_play)
val semanticActions = MediaButton(
prevOrCustom = MediaAction(icon, {}, "prev", null),
- nextOrCustom = MediaAction(icon, {}, "next", null),
+ nextOrCustom = MediaAction(icon, {}, "next", null)
)
val state = mediaData.copy(semanticActions = semanticActions)
player.attachPlayer(viewHolder)
@@ -566,7 +578,7 @@ public class MediaControlPanelTest : SysuiTestCase() {
val icon = context.getDrawable(android.R.drawable.ic_media_play)
val semanticActions = MediaButton(
prevOrCustom = MediaAction(icon, {}, "prev", null),
- nextOrCustom = MediaAction(icon, {}, "next", null),
+ nextOrCustom = MediaAction(icon, {}, "next", null)
)
val state = mediaData.copy(semanticActions = semanticActions)
@@ -709,8 +721,53 @@ public class MediaControlPanelTest : SysuiTestCase() {
fun bindText() {
player.attachPlayer(viewHolder)
player.bindPlayer(mediaData, PACKAGE)
+
+ // Capture animation handler
+ val captor = argumentCaptor()
+ verify(mockAnimator, times(2)).addListener(captor.capture())
+ val handler = captor.value
+
+ // Validate text views unchanged but animation started
+ assertThat(titleText.getText()).isEqualTo("")
+ assertThat(artistText.getText()).isEqualTo("")
+ verify(mockAnimator, times(1)).start()
+
+ // Binding only after animator runs
+ handler.onAnimationEnd(mockAnimator)
assertThat(titleText.getText()).isEqualTo(TITLE)
assertThat(artistText.getText()).isEqualTo(ARTIST)
+
+ // Rebinding should not trigger animation
+ player.bindPlayer(mediaData, PACKAGE)
+ verify(mockAnimator, times(1)).start()
+ }
+
+ @Test
+ fun bindTextInterrupted() {
+ val data0 = mediaData.copy(artist = "ARTIST_0")
+ val data1 = mediaData.copy(artist = "ARTIST_1")
+ val data2 = mediaData.copy(artist = "ARTIST_2")
+
+ player.attachPlayer(viewHolder)
+ player.bindPlayer(data0, PACKAGE)
+
+ // Capture animation handler
+ val captor = argumentCaptor()
+ verify(mockAnimator, times(2)).addListener(captor.capture())
+ val handler = captor.value
+
+ handler.onAnimationEnd(mockAnimator)
+ assertThat(artistText.getText()).isEqualTo("ARTIST_0")
+
+ // Bind trigges new animation
+ player.bindPlayer(data1, PACKAGE)
+ verify(mockAnimator, times(2)).start()
+ whenever(mockAnimator.isRunning()).thenReturn(true)
+
+ // Rebind before animation end binds corrct data
+ player.bindPlayer(data2, PACKAGE)
+ handler.onAnimationEnd(mockAnimator)
+ assertThat(artistText.getText()).isEqualTo("ARTIST_2")
}
@Test
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MetadataAnimationHandlerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MetadataAnimationHandlerTest.kt
new file mode 100644
index 0000000000000..52cb902a4f384
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MetadataAnimationHandlerTest.kt
@@ -0,0 +1,177 @@
+/*
+ * 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.media
+
+import org.mockito.Mockito.`when` as whenever
+import android.animation.Animator
+import android.animation.AnimatorSet
+import android.test.suitebuilder.annotation.SmallTest
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import com.android.systemui.SysuiTestCase
+import junit.framework.Assert.fail
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.times
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.junit.MockitoJUnit
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+class MetadataAnimationHandlerTest : SysuiTestCase() {
+
+ private interface Callback : () -> Unit
+ private lateinit var handler: MetadataAnimationHandler
+
+ @Mock private lateinit var animatorSet: AnimatorSet
+ @Mock private lateinit var enterAnimator: Animator
+ @Mock private lateinit var exitAnimator: Animator
+ @Mock private lateinit var postExitCB: Callback
+ @Mock private lateinit var postEnterCB: Callback
+
+ @JvmField @Rule val mockito = MockitoJUnit.rule()
+
+ @Before
+ fun setUp() {
+ handler = object : MetadataAnimationHandler(exitAnimator, enterAnimator) {
+ override fun buildAnimatorSet(exit: Animator, enter: Animator): AnimatorSet {
+ return animatorSet
+ }
+ }
+ }
+
+ @After
+ fun tearDown() {}
+
+ @Test
+ fun firstBind_startsAnimationSet() {
+ val cb = { fail("Unexpected callback") }
+ handler.setNext("data-1", cb, cb)
+
+ verify(animatorSet).start()
+ }
+
+ @Test
+ fun executeAnimationEnd_runsCallacks() {
+ handler.setNext("data-1", postExitCB, postEnterCB)
+ verify(animatorSet, times(1)).start()
+ verify(postExitCB, never()).invoke()
+
+ handler.onAnimationEnd(exitAnimator)
+ verify(animatorSet, times(1)).start()
+ verify(postExitCB, times(1)).invoke()
+ verify(postEnterCB, never()).invoke()
+
+ handler.onAnimationEnd(enterAnimator)
+ verify(animatorSet, times(1)).start()
+ verify(postExitCB, times(1)).invoke()
+ verify(postEnterCB, times(1)).invoke()
+ }
+
+ @Test
+ fun rebindSameData_executesFirstCallback() {
+ val postExitCB2 = mock(Callback::class.java)
+
+ handler.setNext("data-1", postExitCB, postEnterCB)
+ handler.setNext("data-1", postExitCB2, postEnterCB)
+ handler.onAnimationEnd(exitAnimator)
+
+ verify(postExitCB, times(1)).invoke()
+ verify(postExitCB2, never()).invoke()
+ verify(postEnterCB, never()).invoke()
+ }
+
+ @Test
+ fun rebindDifferentData_executesSecondCallback() {
+ val postExitCB2 = mock(Callback::class.java)
+
+ handler.setNext("data-1", postExitCB, postEnterCB)
+ handler.setNext("data-2", postExitCB2, postEnterCB)
+ handler.onAnimationEnd(exitAnimator)
+
+ verify(postExitCB, never()).invoke()
+ verify(postExitCB2, times(1)).invoke()
+ verify(postEnterCB, never()).invoke()
+ }
+
+ @Test
+ fun rebindBeforeEnterComplete_animationRestarts() {
+ val postExitCB2 = mock(Callback::class.java)
+ val postEnterCB2 = mock(Callback::class.java)
+
+ handler.setNext("data-1", postExitCB, postEnterCB)
+ verify(animatorSet, times(1)).start()
+ verify(postExitCB, never()).invoke()
+ verify(postExitCB2, never()).invoke()
+ verify(postEnterCB, never()).invoke()
+ verify(postEnterCB2, never()).invoke()
+
+ whenever(animatorSet.isRunning()).thenReturn(true)
+ handler.onAnimationEnd(exitAnimator)
+ verify(animatorSet, times(1)).start()
+ verify(postExitCB, times(1)).invoke()
+ verify(postExitCB2, never()).invoke()
+ verify(postEnterCB, never()).invoke()
+ verify(postEnterCB2, never()).invoke()
+
+ handler.setNext("data-2", postExitCB2, postEnterCB2)
+ handler.onAnimationEnd(enterAnimator)
+ verify(animatorSet, times(2)).start()
+ verify(postExitCB, times(1)).invoke()
+ verify(postExitCB2, never()).invoke()
+ verify(postEnterCB, never()).invoke()
+ verify(postEnterCB2, never()).invoke()
+
+ handler.onAnimationEnd(exitAnimator)
+ verify(animatorSet, times(2)).start()
+ verify(postExitCB, times(1)).invoke()
+ verify(postExitCB2, times(1)).invoke()
+ verify(postEnterCB, never()).invoke()
+ verify(postEnterCB2, never()).invoke()
+
+ handler.onAnimationEnd(enterAnimator)
+ verify(animatorSet, times(2)).start()
+ verify(postExitCB, times(1)).invoke()
+ verify(postExitCB2, times(1)).invoke()
+ verify(postEnterCB, never()).invoke()
+ verify(postEnterCB2, times(1)).invoke()
+ }
+
+ @Test
+ fun exitAnimationEndMultipleCalls_singleCallbackExecution() {
+ handler.setNext("data-1", postExitCB, postEnterCB)
+ handler.onAnimationEnd(exitAnimator)
+ handler.onAnimationEnd(exitAnimator)
+ handler.onAnimationEnd(exitAnimator)
+
+ verify(postExitCB, times(1)).invoke()
+ }
+
+ @Test
+ fun enterAnimatorEndsWithoutCallback_noAnimatiorStart() {
+ handler.onAnimationEnd(enterAnimator)
+
+ verify(animatorSet, never()).start()
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt
index c48d84698b3b9..49be669bb4a51 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt
@@ -16,6 +16,8 @@
package com.android.systemui.media
+import android.animation.Animator
+import android.animation.ObjectAnimator
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.View
@@ -43,6 +45,7 @@ class SeekBarObserverTest : SysuiTestCase() {
private val enabledHeight = 2
private lateinit var observer: SeekBarObserver
+ @Mock private lateinit var mockSeekbarAnimator: ObjectAnimator
@Mock private lateinit var mockHolder: MediaViewHolder
@Mock private lateinit var mockSquigglyProgress: SquigglyProgress
private lateinit var seekBarView: SeekBar
@@ -66,7 +69,11 @@ class SeekBarObserverTest : SysuiTestCase() {
whenever(mockHolder.scrubbingElapsedTimeView).thenReturn(scrubbingElapsedTimeView)
whenever(mockHolder.scrubbingTotalTimeView).thenReturn(scrubbingTotalTimeView)
- observer = SeekBarObserver(mockHolder)
+ observer = object : SeekBarObserver(mockHolder) {
+ override fun buildResetAnimator(targetTime: Int): Animator {
+ return mockSeekbarAnimator
+ }
+ }
}
@Test
@@ -189,4 +196,20 @@ class SeekBarObserverTest : SysuiTestCase() {
assertThat(scrubbingElapsedTimeView.text).isEqualTo("")
assertThat(scrubbingTotalTimeView.text).isEqualTo("")
}
+
+ @Test
+ fun seekBarJumpAnimation() {
+ val data0 = SeekBarViewModel.Progress(true, true, true, false, 4000, 120000)
+ val data1 = SeekBarViewModel.Progress(true, true, true, false, 10, 120000)
+
+ // Set initial position of progress bar
+ observer.onChanged(data0)
+ assertThat(seekBarView.progress).isEqualTo(4000)
+ assertThat(seekBarView.max).isEqualTo(120000)
+
+ // Change to second data & confirm no change to position (due to animation delay)
+ observer.onChanged(data1)
+ assertThat(seekBarView.progress).isEqualTo(4000)
+ verify(mockSeekbarAnimator).start()
+ }
}