Merge "Add seek bar to QS Media Player" into rvc-dev am: b131e1b746

Change-Id: Ibdc8215a7b47927a9aae747bc5ee59e4c708e0bc
This commit is contained in:
Robert Snoeberger
2020-04-04 01:25:45 +00:00
committed by Automerger Merge Worker
11 changed files with 934 additions and 16 deletions

View File

@@ -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"

View File

@@ -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))
}
}
}

View File

@@ -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?
)
}

View File

@@ -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.view.ViewGroup;
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 @@ public class QSMediaPlayer extends MediaControlPanel {
};
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 @@ public class QSMediaPlayer extends MediaControlPanel {
* @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 @@ public class QSMediaPlayer extends MediaControlPanel {
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 @@ public class QSMediaPlayer extends MediaControlPanel {
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);
}
}

View File

@@ -69,6 +69,7 @@ import com.android.systemui.statusbar.policy.BrightnessMirrorController;
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 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne
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;
@@ -166,7 +167,7 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne
QSLogger qsLogger,
NotificationMediaManager notificationMediaManager,
@Main Executor foregroundExecutor,
@Background Executor backgroundExecutor,
@Background DelayableExecutor backgroundExecutor,
@Nullable LocalBluetoothManager localBluetoothManager
) {
super(context, attrs);
@@ -278,7 +279,7 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne
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 {
@@ -584,6 +585,9 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne
if (mListening) {
refreshAllTiles();
}
for (QSMediaPlayer player : mMediaPlayers) {
player.setListening(mListening);
}
}
private String getTilesSpecs() {

View File

@@ -43,6 +43,7 @@ import com.android.systemui.statusbar.NotificationMediaManager;
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 @@ public class QuickQSPanel extends QSPanel {
QSLogger qsLogger,
NotificationMediaManager notificationMediaManager,
@Main Executor foregroundExecutor,
@Background Executor backgroundExecutor,
@Background DelayableExecutor backgroundExecutor,
@Nullable LocalBluetoothManager localBluetoothManager
) {
super(context, attrs, dumpManager, broadcastDispatcher, qsLogger, notificationMediaManager,

View File

@@ -48,6 +48,9 @@ public class SysuiLifecycle {
ViewLifecycle(View v) {
v.addOnAttachStateChangeListener(this);
if (v.isAttachedToWindow()) {
mLifecycle.markState(RESUMED);
}
}
@NonNull

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -44,6 +44,7 @@ import com.android.systemui.qs.customize.QSCustomizer;
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 @@ public class QSPanelTest extends SysuiTestCase {
@Mock
private Executor mForegroundExecutor;
@Mock
private Executor mBackgroundExecutor;
private DelayableExecutor mBackgroundExecutor;
@Mock
private LocalBluetoothManager mLocalBluetoothManager;

View File

@@ -25,6 +25,8 @@ import static androidx.lifecycle.Lifecycle.Event.ON_STOP;
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.TestableLooper.RunWithLooper;
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 @@ import org.junit.runner.RunWith;
@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);
}
}