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);
+    }
 }