Add seek bar to QS Media Player
Bug: 150724977
Test: manual - play music and look for seek bar in QS media player
Test: maunal - play podcast and check that track position can be changed
Test: manual - play IHeartRadio and check that seek bar is gone
Test: adding SeekBarObserverTest and SeekBarViewModelTest
Change-Id: I98f32b939f2310e9eb492165f1fddfd7dee65a90
diff --git a/packages/SystemUI/res/layout/qs_media_panel.xml b/packages/SystemUI/res/layout/qs_media_panel.xml
index 9ef8c1d..4ffef4d 100644
--- a/packages/SystemUI/res/layout/qs_media_panel.xml
+++ b/packages/SystemUI/res/layout/qs_media_panel.xml
@@ -136,6 +136,47 @@
</LinearLayout>
</LinearLayout>
+ <!-- Seek Bar -->
+ <SeekBar
+ android:id="@+id/media_progress_bar"
+ android:clickable="true"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:maxHeight="3dp"
+ android:paddingTop="24dp"
+ android:paddingBottom="24dp"
+ android:layout_marginBottom="-24dp"
+ android:layout_marginTop="-24dp"
+ android:splitTrack="false"
+ />
+
+ <FrameLayout
+ android:id="@+id/notification_media_progress_time"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ >
+ <!-- width is set to "match_parent" to avoid extra layout calls -->
+ <TextView
+ android:id="@+id/media_elapsed_time"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_alignParentLeft="true"
+ android:fontFamily="@*android:string/config_bodyFontFamily"
+ android:textSize="14sp"
+ android:gravity="left"
+ />
+ <TextView
+ android:id="@+id/media_total_time"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:fontFamily="@*android:string/config_bodyFontFamily"
+ android:layout_alignParentRight="true"
+ android:textSize="14sp"
+ android:gravity="right"
+ />
+ </FrameLayout>
+
<!-- Controls -->
<LinearLayout
android:id="@+id/media_actions"
diff --git a/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt b/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt
new file mode 100644
index 0000000..aa5ebaa
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/SeekBarObserver.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2020 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.content.res.ColorStateList
+import android.text.format.DateUtils
+import android.view.View
+import android.widget.SeekBar
+import android.widget.TextView
+import androidx.annotation.UiThread
+import androidx.lifecycle.Observer
+
+import com.android.systemui.R
+
+/**
+ * Observer for changes from SeekBarViewModel.
+ *
+ * <p>Updates the seek bar views in response to changes to the model.
+ */
+class SeekBarObserver(view: View) : Observer<SeekBarViewModel.Progress> {
+
+ private val seekBarView: SeekBar
+ private val elapsedTimeView: TextView
+ private val totalTimeView: TextView
+
+ init {
+ seekBarView = view.findViewById(R.id.media_progress_bar)
+ elapsedTimeView = view.findViewById(R.id.media_elapsed_time)
+ totalTimeView = view.findViewById(R.id.media_total_time)
+ }
+
+ /** Updates seek bar views when the data model changes. */
+ @UiThread
+ override fun onChanged(data: SeekBarViewModel.Progress) {
+ if (data.enabled && seekBarView.visibility == View.GONE) {
+ seekBarView.visibility = View.VISIBLE
+ elapsedTimeView.visibility = View.VISIBLE
+ totalTimeView.visibility = View.VISIBLE
+ } else if (!data.enabled && seekBarView.visibility == View.VISIBLE) {
+ seekBarView.visibility = View.GONE
+ elapsedTimeView.visibility = View.GONE
+ totalTimeView.visibility = View.GONE
+ return
+ }
+
+ // TODO: update the style of the disabled progress bar
+ seekBarView.setEnabled(data.seekAvailable)
+
+ data.color?.let {
+ var tintList = ColorStateList.valueOf(it)
+ seekBarView.setThumbTintList(tintList)
+ tintList = tintList.withAlpha(192) // 75%
+ seekBarView.setProgressTintList(tintList)
+ tintList = tintList.withAlpha(128) // 50%
+ seekBarView.setProgressBackgroundTintList(tintList)
+ elapsedTimeView.setTextColor(it)
+ totalTimeView.setTextColor(it)
+ }
+
+ data.elapsedTime?.let {
+ seekBarView.setProgress(it)
+ elapsedTimeView.setText(DateUtils.formatElapsedTime(
+ it / DateUtils.SECOND_IN_MILLIS))
+ }
+
+ data.duration?.let {
+ seekBarView.setMax(it)
+ totalTimeView.setText(DateUtils.formatElapsedTime(
+ it / DateUtils.SECOND_IN_MILLIS))
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt
new file mode 100644
index 0000000..cf8f268
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2020 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.media.MediaMetadata
+import android.media.session.MediaController
+import android.media.session.PlaybackState
+import android.view.MotionEvent
+import android.view.View
+import android.widget.SeekBar
+import androidx.annotation.AnyThread
+import androidx.annotation.WorkerThread
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.LiveData
+
+import com.android.systemui.util.concurrency.DelayableExecutor
+
+private const val POSITION_UPDATE_INTERVAL_MILLIS = 100L
+
+/** ViewModel for seek bar in QS media player. */
+class SeekBarViewModel(val bgExecutor: DelayableExecutor) {
+
+ private val _progress = MutableLiveData<Progress>().apply {
+ postValue(Progress(false, false, null, null, null))
+ }
+ val progress: LiveData<Progress>
+ get() = _progress
+ private var controller: MediaController? = null
+ private var playbackState: PlaybackState? = null
+
+ /** Listening state (QS open or closed) is used to control polling of progress. */
+ var listening = true
+ set(value) {
+ if (value) {
+ checkPlaybackPosition()
+ }
+ }
+
+ /**
+ * Handle request to change the current position in the media track.
+ * @param position Place to seek to in the track.
+ */
+ @WorkerThread
+ fun onSeek(position: Long) {
+ controller?.transportControls?.seekTo(position)
+ }
+
+ /**
+ * Updates media information.
+ * @param mediaController controller for media session
+ * @param color foreground color for UI elements
+ */
+ @WorkerThread
+ fun updateController(mediaController: MediaController?, color: Int) {
+ controller = mediaController
+ playbackState = controller?.playbackState
+ val mediaMetadata = controller?.metadata
+ val seekAvailable = ((playbackState?.actions ?: 0L) and PlaybackState.ACTION_SEEK_TO) != 0L
+ val position = playbackState?.position?.toInt()
+ val duration = mediaMetadata?.getLong(MediaMetadata.METADATA_KEY_DURATION)?.toInt()
+ val enabled = if (duration != null && duration <= 0) false else true
+ _progress.postValue(Progress(enabled, seekAvailable, position, duration, color))
+ if (shouldPollPlaybackPosition()) {
+ checkPlaybackPosition()
+ }
+ }
+
+ @AnyThread
+ private fun checkPlaybackPosition(): Runnable = bgExecutor.executeDelayed({
+ val currentPosition = controller?.playbackState?.position?.toInt()
+ if (currentPosition != null && _progress.value!!.elapsedTime != currentPosition) {
+ _progress.postValue(_progress.value!!.copy(elapsedTime = currentPosition))
+ }
+ if (shouldPollPlaybackPosition()) {
+ checkPlaybackPosition()
+ }
+ }, POSITION_UPDATE_INTERVAL_MILLIS)
+
+ @WorkerThread
+ private fun shouldPollPlaybackPosition(): Boolean {
+ val state = playbackState?.state
+ val moving = if (state == null) false else
+ state == PlaybackState.STATE_PLAYING ||
+ state == PlaybackState.STATE_BUFFERING ||
+ state == PlaybackState.STATE_FAST_FORWARDING ||
+ state == PlaybackState.STATE_REWINDING
+ return moving && listening
+ }
+
+ /** Gets a listener to attach to the seek bar to handle seeking. */
+ val seekBarListener: SeekBar.OnSeekBarChangeListener
+ get() {
+ return SeekBarChangeListener(this, bgExecutor)
+ }
+
+ /** Gets a listener to attach to the seek bar to disable touch intercepting. */
+ val seekBarTouchListener: View.OnTouchListener
+ get() {
+ return SeekBarTouchListener()
+ }
+
+ private class SeekBarChangeListener(
+ val viewModel: SeekBarViewModel,
+ val bgExecutor: DelayableExecutor
+ ) : SeekBar.OnSeekBarChangeListener {
+ override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
+ if (fromUser) {
+ bgExecutor.execute {
+ viewModel.onSeek(progress.toLong())
+ }
+ }
+ }
+ override fun onStartTrackingTouch(bar: SeekBar) {
+ }
+ override fun onStopTrackingTouch(bar: SeekBar) {
+ val pos = bar.progress.toLong()
+ bgExecutor.execute {
+ viewModel.onSeek(pos)
+ }
+ }
+ }
+
+ private class SeekBarTouchListener : View.OnTouchListener {
+ override fun onTouch(view: View, event: MotionEvent): Boolean {
+ view.parent.requestDisallowInterceptTouchEvent(true)
+ return view.onTouchEvent(event)
+ }
+ }
+
+ /** State seen by seek bar UI. */
+ data class Progress(
+ val enabled: Boolean,
+ val seekAvailable: Boolean,
+ val elapsedTime: Int?,
+ val duration: Int?,
+ val color: Int?
+ )
+}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSMediaPlayer.java b/packages/SystemUI/src/com/android/systemui/qs/QSMediaPlayer.java
index 8922e14..339a408 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/QSMediaPlayer.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/QSMediaPlayer.java
@@ -16,11 +16,14 @@
package com.android.systemui.qs;
+import static com.android.systemui.util.SysuiLifecycle.viewAttachLifecycle;
+
import android.app.Notification;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
+import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.util.Log;
import android.view.View;
@@ -28,12 +31,16 @@
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
+import android.widget.SeekBar;
import android.widget.TextView;
import com.android.settingslib.media.MediaDevice;
import com.android.systemui.R;
import com.android.systemui.media.MediaControlPanel;
+import com.android.systemui.media.SeekBarObserver;
+import com.android.systemui.media.SeekBarViewModel;
import com.android.systemui.statusbar.NotificationMediaManager;
+import com.android.systemui.util.concurrency.DelayableExecutor;
import java.util.concurrent.Executor;
@@ -54,6 +61,9 @@
};
private final QSPanel mParent;
+ private final DelayableExecutor mBackgroundExecutor;
+ private final SeekBarViewModel mSeekBarViewModel;
+ private final SeekBarObserver mSeekBarObserver;
/**
* Initialize quick shade version of player
@@ -64,10 +74,20 @@
* @param backgroundExecutor
*/
public QSMediaPlayer(Context context, ViewGroup parent, NotificationMediaManager manager,
- Executor foregroundExecutor, Executor backgroundExecutor) {
+ Executor foregroundExecutor, DelayableExecutor backgroundExecutor) {
super(context, parent, manager, R.layout.qs_media_panel, QS_ACTION_IDS, foregroundExecutor,
backgroundExecutor);
mParent = (QSPanel) parent;
+ mBackgroundExecutor = backgroundExecutor;
+ mSeekBarViewModel = new SeekBarViewModel(backgroundExecutor);
+ mSeekBarObserver = new SeekBarObserver(getView());
+ // Can't use the viewAttachLifecycle of media player because remove/add is used to adjust
+ // priority of players. As soon as it is removed, the lifecycle will end and the seek bar
+ // will stop updating. So, use the lifecycle of the parent instead.
+ mSeekBarViewModel.getProgress().observe(viewAttachLifecycle(parent), mSeekBarObserver);
+ SeekBar bar = getView().findViewById(R.id.media_progress_bar);
+ bar.setOnSeekBarChangeListener(mSeekBarViewModel.getSeekBarListener());
+ bar.setOnTouchListener(mSeekBarViewModel.getSeekBarTouchListener());
}
/**
@@ -115,6 +135,11 @@
thisBtn.setVisibility(View.GONE);
}
+ // Seek Bar
+ final MediaController controller = new MediaController(getContext(), token);
+ mBackgroundExecutor.execute(
+ () -> mSeekBarViewModel.updateController(controller, iconColor));
+
// Set up long press menu
View guts = mMediaNotifView.findViewById(R.id.media_guts);
View options = mMediaNotifView.findViewById(R.id.qs_media_controls_options);
@@ -155,4 +180,16 @@
return true; // consumed click
});
}
+
+ /**
+ * Sets the listening state of the player.
+ *
+ * Should be set to true when the QS panel is open. Otherwise, false. This is a signal to avoid
+ * unnecessary work when the QS panel is closed.
+ *
+ * @param listening True when player should be active. Otherwise, false.
+ */
+ public void setListening(boolean listening) {
+ mSeekBarViewModel.setListening(listening);
+ }
}
diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java
index 5ccf8c7..10bb82b 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java
@@ -69,6 +69,7 @@
import com.android.systemui.statusbar.policy.BrightnessMirrorController.BrightnessMirrorListener;
import com.android.systemui.tuner.TunerService;
import com.android.systemui.tuner.TunerService.Tunable;
+import com.android.systemui.util.concurrency.DelayableExecutor;
import java.io.FileDescriptor;
import java.io.PrintWriter;
@@ -103,7 +104,7 @@
private final NotificationMediaManager mNotificationMediaManager;
private final LocalBluetoothManager mLocalBluetoothManager;
private final Executor mForegroundExecutor;
- private final Executor mBackgroundExecutor;
+ private final DelayableExecutor mBackgroundExecutor;
private LocalMediaManager mLocalMediaManager;
private MediaDevice mDevice;
private boolean mUpdateCarousel = false;
@@ -163,7 +164,7 @@
QSLogger qsLogger,
NotificationMediaManager notificationMediaManager,
@Main Executor foregroundExecutor,
- @Background Executor backgroundExecutor,
+ @Background DelayableExecutor backgroundExecutor,
@Nullable LocalBluetoothManager localBluetoothManager
) {
super(context, attrs);
@@ -275,7 +276,7 @@
Log.d(TAG, "creating new player");
player = new QSMediaPlayer(mContext, this, mNotificationMediaManager,
mForegroundExecutor, mBackgroundExecutor);
-
+ player.setListening(mListening);
if (player.isPlaying()) {
mMediaCarousel.addView(player.getView(), 0, lp); // add in front
} else {
@@ -574,6 +575,9 @@
if (mListening) {
refreshAllTiles();
}
+ for (QSMediaPlayer player : mMediaPlayers) {
+ player.setListening(mListening);
+ }
}
private String getTilesSpecs() {
diff --git a/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanel.java b/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanel.java
index be01d75..8fa64d3 100644
--- a/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanel.java
+++ b/packages/SystemUI/src/com/android/systemui/qs/QuickQSPanel.java
@@ -43,6 +43,7 @@
import com.android.systemui.tuner.TunerService;
import com.android.systemui.tuner.TunerService.Tunable;
import com.android.systemui.util.Utils;
+import com.android.systemui.util.concurrency.DelayableExecutor;
import java.util.ArrayList;
import java.util.Collection;
@@ -81,7 +82,7 @@
QSLogger qsLogger,
NotificationMediaManager notificationMediaManager,
@Main Executor foregroundExecutor,
- @Background Executor backgroundExecutor,
+ @Background DelayableExecutor backgroundExecutor,
@Nullable LocalBluetoothManager localBluetoothManager
) {
super(context, attrs, dumpManager, broadcastDispatcher, qsLogger, notificationMediaManager,
diff --git a/packages/SystemUI/src/com/android/systemui/util/SysuiLifecycle.java b/packages/SystemUI/src/com/android/systemui/util/SysuiLifecycle.java
index 711a0df..d731753 100644
--- a/packages/SystemUI/src/com/android/systemui/util/SysuiLifecycle.java
+++ b/packages/SystemUI/src/com/android/systemui/util/SysuiLifecycle.java
@@ -48,6 +48,9 @@
ViewLifecycle(View v) {
v.addOnAttachStateChangeListener(this);
+ if (v.isAttachedToWindow()) {
+ mLifecycle.markState(RESUMED);
+ }
}
@NonNull
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt
new file mode 100644
index 0000000..260f520
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarObserverTest.kt
@@ -0,0 +1,130 @@
+/*
+ * Copyright (C) 2020 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.graphics.Color
+import android.content.res.ColorStateList
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import android.view.View
+import android.widget.SeekBar
+import android.widget.TextView
+import androidx.test.filters.SmallTest
+
+import com.android.systemui.R
+import com.android.systemui.SysuiTestCase
+import com.google.common.truth.Truth.assertThat
+
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.`when` as whenever
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper
+public class SeekBarObserverTest : SysuiTestCase() {
+
+ private lateinit var observer: SeekBarObserver
+ @Mock private lateinit var mockView: View
+ private lateinit var seekBarView: SeekBar
+ private lateinit var elapsedTimeView: TextView
+ private lateinit var totalTimeView: TextView
+
+ @Before
+ fun setUp() {
+ mockView = mock(View::class.java)
+ seekBarView = SeekBar(context)
+ elapsedTimeView = TextView(context)
+ totalTimeView = TextView(context)
+ whenever<SeekBar>(
+ mockView.findViewById(R.id.media_progress_bar)).thenReturn(seekBarView)
+ whenever<TextView>(
+ mockView.findViewById(R.id.media_elapsed_time)).thenReturn(elapsedTimeView)
+ whenever<TextView>(mockView.findViewById(R.id.media_total_time)).thenReturn(totalTimeView)
+ observer = SeekBarObserver(mockView)
+ }
+
+ @Test
+ fun seekBarGone() {
+ // WHEN seek bar is disabled
+ val isEnabled = false
+ val data = SeekBarViewModel.Progress(isEnabled, false, null, null, null)
+ observer.onChanged(data)
+ // THEN seek bar visibility is set to GONE
+ assertThat(seekBarView.getVisibility()).isEqualTo(View.GONE)
+ assertThat(elapsedTimeView.getVisibility()).isEqualTo(View.GONE)
+ assertThat(totalTimeView.getVisibility()).isEqualTo(View.GONE)
+ }
+
+ @Test
+ fun seekBarVisible() {
+ // WHEN seek bar is enabled
+ val isEnabled = true
+ val data = SeekBarViewModel.Progress(isEnabled, true, 3000, 12000, -1)
+ observer.onChanged(data)
+ // THEN seek bar is visible
+ assertThat(seekBarView.getVisibility()).isEqualTo(View.VISIBLE)
+ assertThat(elapsedTimeView.getVisibility()).isEqualTo(View.VISIBLE)
+ assertThat(totalTimeView.getVisibility()).isEqualTo(View.VISIBLE)
+ }
+
+ @Test
+ fun seekBarProgress() {
+ // WHEN seek bar progress is about half
+ val data = SeekBarViewModel.Progress(true, true, 3000, 120000, -1)
+ observer.onChanged(data)
+ // THEN seek bar is visible
+ assertThat(seekBarView.progress).isEqualTo(100)
+ assertThat(seekBarView.max).isEqualTo(120000)
+ assertThat(elapsedTimeView.getText()).isEqualTo("00:03")
+ assertThat(totalTimeView.getText()).isEqualTo("02:00")
+ }
+
+ @Test
+ fun seekBarDisabledWhenSeekNotAvailable() {
+ // WHEN seek is not available
+ val isSeekAvailable = false
+ val data = SeekBarViewModel.Progress(true, isSeekAvailable, 3000, 120000, -1)
+ observer.onChanged(data)
+ // THEN seek bar is not enabled
+ assertThat(seekBarView.isEnabled()).isFalse()
+ }
+
+ @Test
+ fun seekBarEnabledWhenSeekNotAvailable() {
+ // WHEN seek is available
+ val isSeekAvailable = true
+ val data = SeekBarViewModel.Progress(true, isSeekAvailable, 3000, 120000, -1)
+ observer.onChanged(data)
+ // THEN seek bar is not enabled
+ assertThat(seekBarView.isEnabled()).isTrue()
+ }
+
+ @Test
+ fun seekBarColor() {
+ // WHEN data included color
+ val data = SeekBarViewModel.Progress(true, true, 3000, 120000, Color.RED)
+ observer.onChanged(data)
+ // THEN seek bar is colored
+ val red = ColorStateList.valueOf(Color.RED)
+ assertThat(elapsedTimeView.getTextColors()).isEqualTo(red)
+ assertThat(totalTimeView.getTextColors()).isEqualTo(red)
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt
new file mode 100644
index 0000000..f316d04
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/SeekBarViewModelTest.kt
@@ -0,0 +1,375 @@
+/*
+ * Copyright (C) 2020 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.graphics.Color
+import android.media.MediaMetadata
+import android.media.session.MediaController
+import android.media.session.PlaybackState
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import android.widget.SeekBar
+import androidx.arch.core.executor.ArchTaskExecutor
+import androidx.arch.core.executor.TaskExecutor
+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.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when` as whenever
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper
+public class SeekBarViewModelTest : SysuiTestCase() {
+
+ private lateinit var viewModel: SeekBarViewModel
+ private lateinit var fakeExecutor: FakeExecutor
+ private val taskExecutor: TaskExecutor = object : TaskExecutor() {
+ override fun executeOnDiskIO(runnable: Runnable) {
+ runnable.run()
+ }
+ override fun postToMainThread(runnable: Runnable) {
+ runnable.run()
+ }
+ override fun isMainThread(): Boolean {
+ return true
+ }
+ }
+ @Mock private lateinit var mockController: MediaController
+ @Mock private lateinit var mockTransport: MediaController.TransportControls
+
+ @Before
+ fun setUp() {
+ fakeExecutor = FakeExecutor(FakeSystemClock())
+ viewModel = SeekBarViewModel(fakeExecutor)
+ mockController = mock(MediaController::class.java)
+ mockTransport = mock(MediaController.TransportControls::class.java)
+
+ // LiveData to run synchronously
+ ArchTaskExecutor.getInstance().setDelegate(taskExecutor)
+ }
+
+ @After
+ fun tearDown() {
+ ArchTaskExecutor.getInstance().setDelegate(null)
+ }
+
+ @Test
+ fun updateColor() {
+ viewModel.updateController(mockController, Color.RED)
+ assertThat(viewModel.progress.value!!.color).isEqualTo(Color.RED)
+ }
+
+ @Test
+ fun updateDuration() {
+ // GIVEN that the duration is contained within the metadata
+ val duration = 12000L
+ val metadata = MediaMetadata.Builder().run {
+ putLong(MediaMetadata.METADATA_KEY_DURATION, duration)
+ build()
+ }
+ whenever(mockController.getMetadata()).thenReturn(metadata)
+ // WHEN the controller is updated
+ viewModel.updateController(mockController, Color.RED)
+ // THEN the duration is extracted
+ assertThat(viewModel.progress.value!!.duration).isEqualTo(duration)
+ assertThat(viewModel.progress.value!!.enabled).isTrue()
+ }
+
+ @Test
+ fun updateDurationNegative() {
+ // GIVEN that the duration is negative
+ val duration = -1L
+ val metadata = MediaMetadata.Builder().run {
+ putLong(MediaMetadata.METADATA_KEY_DURATION, duration)
+ build()
+ }
+ whenever(mockController.getMetadata()).thenReturn(metadata)
+ // WHEN the controller is updated
+ viewModel.updateController(mockController, Color.RED)
+ // THEN the seek bar is disabled
+ assertThat(viewModel.progress.value!!.enabled).isFalse()
+ }
+
+ @Test
+ fun updateDurationZero() {
+ // GIVEN that the duration is zero
+ val duration = 0L
+ val metadata = MediaMetadata.Builder().run {
+ putLong(MediaMetadata.METADATA_KEY_DURATION, duration)
+ build()
+ }
+ whenever(mockController.getMetadata()).thenReturn(metadata)
+ // WHEN the controller is updated
+ viewModel.updateController(mockController, Color.RED)
+ // THEN the seek bar is disabled
+ assertThat(viewModel.progress.value!!.enabled).isFalse()
+ }
+
+ @Test
+ fun updateElapsedTime() {
+ // GIVEN that the PlaybackState contins the current position
+ val position = 200L
+ val state = PlaybackState.Builder().run {
+ setState(PlaybackState.STATE_PLAYING, position, 1f)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ // WHEN the controller is updated
+ viewModel.updateController(mockController, Color.RED)
+ // THEN elapsed time is captured
+ assertThat(viewModel.progress.value!!.elapsedTime).isEqualTo(200.toInt())
+ }
+
+ @Test
+ fun updateSeekAvailable() {
+ // GIVEN that seek is included in actions
+ val state = PlaybackState.Builder().run {
+ setActions(PlaybackState.ACTION_SEEK_TO)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ // WHEN the controller is updated
+ viewModel.updateController(mockController, Color.RED)
+ // THEN seek is available
+ assertThat(viewModel.progress.value!!.seekAvailable).isTrue()
+ }
+
+ @Test
+ fun updateSeekNotAvailable() {
+ // GIVEN that seek is not included in actions
+ val state = PlaybackState.Builder().run {
+ setActions(PlaybackState.ACTION_PLAY)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ // WHEN the controller is updated
+ viewModel.updateController(mockController, Color.RED)
+ // THEN seek is not available
+ assertThat(viewModel.progress.value!!.seekAvailable).isFalse()
+ }
+
+ @Test
+ fun handleSeek() {
+ whenever(mockController.getTransportControls()).thenReturn(mockTransport)
+ viewModel.updateController(mockController, Color.RED)
+ // WHEN user input is dispatched
+ val pos = 42L
+ viewModel.onSeek(pos)
+ fakeExecutor.runAllReady()
+ // THEN transport controls should be used
+ verify(mockTransport).seekTo(pos)
+ }
+
+ @Test
+ fun handleProgressChangedUser() {
+ whenever(mockController.getTransportControls()).thenReturn(mockTransport)
+ viewModel.updateController(mockController, Color.RED)
+ // WHEN user starts dragging the seek bar
+ val pos = 42
+ viewModel.seekBarListener.onProgressChanged(SeekBar(context), pos, true)
+ fakeExecutor.runAllReady()
+ // THEN transport controls should be used
+ verify(mockTransport).seekTo(pos.toLong())
+ }
+
+ @Test
+ fun handleProgressChangedOther() {
+ whenever(mockController.getTransportControls()).thenReturn(mockTransport)
+ viewModel.updateController(mockController, Color.RED)
+ // WHEN user starts dragging the seek bar
+ val pos = 42
+ viewModel.seekBarListener.onProgressChanged(SeekBar(context), pos, false)
+ fakeExecutor.runAllReady()
+ // THEN transport controls should be used
+ verify(mockTransport, never()).seekTo(pos.toLong())
+ }
+
+ @Test
+ fun handleStartTrackingTouch() {
+ whenever(mockController.getTransportControls()).thenReturn(mockTransport)
+ viewModel.updateController(mockController, Color.RED)
+ // WHEN user starts dragging the seek bar
+ val pos = 42
+ val bar = SeekBar(context).apply {
+ progress = pos
+ }
+ viewModel.seekBarListener.onStartTrackingTouch(bar)
+ fakeExecutor.runAllReady()
+ // THEN transport controls should be used
+ verify(mockTransport, never()).seekTo(pos.toLong())
+ }
+
+ @Test
+ fun handleStopTrackingTouch() {
+ whenever(mockController.getTransportControls()).thenReturn(mockTransport)
+ viewModel.updateController(mockController, Color.RED)
+ // WHEN user ends drag
+ val pos = 42
+ val bar = SeekBar(context).apply {
+ progress = pos
+ }
+ viewModel.seekBarListener.onStopTrackingTouch(bar)
+ fakeExecutor.runAllReady()
+ // THEN transport controls should be used
+ verify(mockTransport).seekTo(pos.toLong())
+ }
+
+ @Test
+ fun queuePollTaskWhenPlaying() {
+ // GIVEN that the track is playing
+ val state = PlaybackState.Builder().run {
+ setState(PlaybackState.STATE_PLAYING, 100L, 1f)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ // WHEN the controller is updated
+ viewModel.updateController(mockController, Color.RED)
+ // THEN a task is queued
+ assertThat(fakeExecutor.numPending()).isEqualTo(1)
+ }
+
+ @Test
+ fun noQueuePollTaskWhenStopped() {
+ // GIVEN that the playback state is stopped
+ val state = PlaybackState.Builder().run {
+ setState(PlaybackState.STATE_STOPPED, 200L, 1f)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ // WHEN updated
+ viewModel.updateController(mockController, Color.RED)
+ // THEN an update task is not queued
+ assertThat(fakeExecutor.numPending()).isEqualTo(0)
+ }
+
+ @Test
+ fun queuePollTaskWhenListening() {
+ // GIVEN listening
+ viewModel.listening = true
+ with(fakeExecutor) {
+ advanceClockToNext()
+ runAllReady()
+ }
+ // AND the playback state is playing
+ val state = PlaybackState.Builder().run {
+ setState(PlaybackState.STATE_PLAYING, 200L, 1f)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ // WHEN updated
+ viewModel.updateController(mockController, Color.RED)
+ // THEN an update task is queued
+ assertThat(fakeExecutor.numPending()).isEqualTo(1)
+ }
+
+ @Test
+ fun noQueuePollTaskWhenNotListening() {
+ // GIVEN not listening
+ viewModel.listening = false
+ with(fakeExecutor) {
+ advanceClockToNext()
+ runAllReady()
+ }
+ // AND the playback state is playing
+ val state = PlaybackState.Builder().run {
+ setState(PlaybackState.STATE_STOPPED, 200L, 1f)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ // WHEN updated
+ viewModel.updateController(mockController, Color.RED)
+ // THEN an update task is not queued
+ assertThat(fakeExecutor.numPending()).isEqualTo(0)
+ }
+
+ @Test
+ fun pollTaskQueuesAnotherPollTaskWhenPlaying() {
+ // GIVEN that the track is playing
+ val state = PlaybackState.Builder().run {
+ setState(PlaybackState.STATE_PLAYING, 100L, 1f)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ viewModel.updateController(mockController, Color.RED)
+ // WHEN the next task runs
+ with(fakeExecutor) {
+ advanceClockToNext()
+ runAllReady()
+ }
+ // THEN another task is queued
+ assertThat(fakeExecutor.numPending()).isEqualTo(1)
+ }
+
+ @Test
+ fun taskUpdatesProgress() {
+ // GIVEN that the PlaybackState contins the current position
+ val position = 200L
+ val state = PlaybackState.Builder().run {
+ setState(PlaybackState.STATE_PLAYING, position, 1f)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ viewModel.updateController(mockController, Color.RED)
+ // AND the playback state advances
+ val nextPosition = 300L
+ val nextState = PlaybackState.Builder().run {
+ setState(PlaybackState.STATE_PLAYING, nextPosition, 1f)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(nextState)
+ // WHEN the task runs
+ with(fakeExecutor) {
+ advanceClockToNext()
+ runAllReady()
+ }
+ // THEN elapsed time is captured
+ assertThat(viewModel.progress.value!!.elapsedTime).isEqualTo(nextPosition.toInt())
+ }
+
+ @Test
+ fun startListeningQueuesPollTask() {
+ // GIVEN not listening
+ viewModel.listening = false
+ with(fakeExecutor) {
+ advanceClockToNext()
+ runAllReady()
+ }
+ // AND the playback state is playing
+ val state = PlaybackState.Builder().run {
+ setState(PlaybackState.STATE_STOPPED, 200L, 1f)
+ build()
+ }
+ whenever(mockController.getPlaybackState()).thenReturn(state)
+ viewModel.updateController(mockController, Color.RED)
+ // WHEN start listening
+ viewModel.listening = true
+ // THEN an update task is queued
+ assertThat(fakeExecutor.numPending()).isEqualTo(1)
+ }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelTest.java b/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelTest.java
index dbbbaac..862ebe13 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/qs/QSPanelTest.java
@@ -44,6 +44,7 @@
import com.android.systemui.qs.logging.QSLogger;
import com.android.systemui.qs.tileimpl.QSTileImpl;
import com.android.systemui.statusbar.NotificationMediaManager;
+import com.android.systemui.util.concurrency.DelayableExecutor;
import org.junit.Before;
import org.junit.Test;
@@ -87,7 +88,7 @@
@Mock
private Executor mForegroundExecutor;
@Mock
- private Executor mBackgroundExecutor;
+ private DelayableExecutor mBackgroundExecutor;
@Mock
private LocalBluetoothManager mLocalBluetoothManager;
diff --git a/packages/SystemUI/tests/src/com/android/systemui/util/SysuiLifecycleTest.java b/packages/SystemUI/tests/src/com/android/systemui/util/SysuiLifecycleTest.java
index ce8085a..486939d 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/util/SysuiLifecycleTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/util/SysuiLifecycleTest.java
@@ -25,6 +25,8 @@
import static com.android.systemui.util.SysuiLifecycle.viewAttachLifecycle;
+import static com.google.common.truth.Truth.assertThat;
+
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -35,12 +37,15 @@
import android.testing.ViewUtils;
import android.view.View;
+import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleEventObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.test.filters.SmallTest;
import com.android.systemui.SysuiTestCase;
+import org.junit.After;
+import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -49,39 +54,122 @@
@SmallTest
public class SysuiLifecycleTest extends SysuiTestCase {
+ private View mView;
+
+ @Before
+ public void setUp() {
+ mView = new View(mContext);
+ }
+
+ @After
+ public void tearDown() {
+ if (mView.isAttachedToWindow()) {
+ ViewUtils.detachView(mView);
+ TestableLooper.get(this).processAllMessages();
+ }
+ }
+
@Test
public void testAttach() {
- View v = new View(mContext);
LifecycleEventObserver observer = mock(LifecycleEventObserver.class);
- LifecycleOwner lifecycle = viewAttachLifecycle(v);
+ LifecycleOwner lifecycle = viewAttachLifecycle(mView);
lifecycle.getLifecycle().addObserver(observer);
- ViewUtils.attachView(v);
+ ViewUtils.attachView(mView);
TestableLooper.get(this).processAllMessages();
verify(observer).onStateChanged(eq(lifecycle), eq(ON_CREATE));
verify(observer).onStateChanged(eq(lifecycle), eq(ON_START));
verify(observer).onStateChanged(eq(lifecycle), eq(ON_RESUME));
-
- ViewUtils.detachView(v);
- TestableLooper.get(this).processAllMessages();
}
@Test
public void testDetach() {
- View v = new View(mContext);
LifecycleEventObserver observer = mock(LifecycleEventObserver.class);
- LifecycleOwner lifecycle = viewAttachLifecycle(v);
+ LifecycleOwner lifecycle = viewAttachLifecycle(mView);
lifecycle.getLifecycle().addObserver(observer);
- ViewUtils.attachView(v);
+ ViewUtils.attachView(mView);
TestableLooper.get(this).processAllMessages();
- ViewUtils.detachView(v);
+ ViewUtils.detachView(mView);
TestableLooper.get(this).processAllMessages();
verify(observer).onStateChanged(eq(lifecycle), eq(ON_PAUSE));
verify(observer).onStateChanged(eq(lifecycle), eq(ON_STOP));
verify(observer).onStateChanged(eq(lifecycle), eq(ON_DESTROY));
}
+
+ @Test
+ public void testStateBeforeAttach() {
+ // WHEN a lifecycle is obtained from a view
+ LifecycleOwner lifecycle = viewAttachLifecycle(mView);
+ // THEN the lifecycle state should be INITIAZED
+ assertThat(lifecycle.getLifecycle().getCurrentState()).isEqualTo(
+ Lifecycle.State.INITIALIZED);
+ }
+
+ @Test
+ public void testStateAfterAttach() {
+ // WHEN a lifecycle is obtained from a view
+ LifecycleOwner lifecycle = viewAttachLifecycle(mView);
+ // AND the view is attached
+ ViewUtils.attachView(mView);
+ TestableLooper.get(this).processAllMessages();
+ // THEN the lifecycle state should be RESUMED
+ assertThat(lifecycle.getLifecycle().getCurrentState()).isEqualTo(Lifecycle.State.RESUMED);
+ }
+
+ @Test
+ public void testStateAfterDetach() {
+ // WHEN a lifecycle is obtained from a view
+ LifecycleOwner lifecycle = viewAttachLifecycle(mView);
+ // AND the view is detached
+ ViewUtils.attachView(mView);
+ TestableLooper.get(this).processAllMessages();
+ ViewUtils.detachView(mView);
+ TestableLooper.get(this).processAllMessages();
+ // THEN the lifecycle state should be DESTROYED
+ assertThat(lifecycle.getLifecycle().getCurrentState()).isEqualTo(Lifecycle.State.DESTROYED);
+ }
+
+ @Test
+ public void testStateAfterReattach() {
+ // WHEN a lifecycle is obtained from a view
+ LifecycleOwner lifecycle = viewAttachLifecycle(mView);
+ // AND the view is re-attached
+ ViewUtils.attachView(mView);
+ TestableLooper.get(this).processAllMessages();
+ ViewUtils.detachView(mView);
+ TestableLooper.get(this).processAllMessages();
+ ViewUtils.attachView(mView);
+ TestableLooper.get(this).processAllMessages();
+ // THEN the lifecycle state should still be DESTROYED, err RESUMED?
+ assertThat(lifecycle.getLifecycle().getCurrentState()).isEqualTo(Lifecycle.State.RESUMED);
+ }
+
+ @Test
+ public void testStateWhenViewAlreadyAttached() {
+ // GIVEN that a view is already attached
+ ViewUtils.attachView(mView);
+ TestableLooper.get(this).processAllMessages();
+ // WHEN a lifecycle is obtained from a view
+ LifecycleOwner lifecycle = viewAttachLifecycle(mView);
+ // THEN the lifecycle state should be RESUMED
+ assertThat(lifecycle.getLifecycle().getCurrentState()).isEqualTo(Lifecycle.State.RESUMED);
+ }
+
+ @Test
+ public void testStateWhenViewAlreadyDetached() {
+ // GIVEN that a view is already detached
+ ViewUtils.attachView(mView);
+ TestableLooper.get(this).processAllMessages();
+ ViewUtils.detachView(mView);
+ TestableLooper.get(this).processAllMessages();
+ // WHEN a lifecycle is obtained from a view
+ LifecycleOwner lifecycle = viewAttachLifecycle(mView);
+ // THEN the lifecycle state should be INITIALIZED
+ assertThat(lifecycle.getLifecycle().getCurrentState()).isEqualTo(
+ Lifecycle.State.INITIALIZED);
+ }
}