diff options
author | 2020-04-01 17:32:44 -0400 | |
---|---|---|
committer | 2020-04-02 17:36:18 -0400 | |
commit | 7dffd3774d397286a5eab993b47d0c6856e67586 (patch) | |
tree | bfa9ed29a17d81e97efa4f12790680602ebc2bdd | |
parent | ef7b53424b6794d7167a151d11d0c58540b7c0e3 (diff) |
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
11 files changed, 934 insertions, 16 deletions
diff --git a/packages/SystemUI/res/layout/qs_media_panel.xml b/packages/SystemUI/res/layout/qs_media_panel.xml index 9ef8c1dbbb95..4ffef4d99f67 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 000000000000..aa5ebaa22f2d --- /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 000000000000..cf8f26841cf9 --- /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 8922e146cc50..339a408d501a 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.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); + } } diff --git a/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java b/packages/SystemUI/src/com/android/systemui/qs/QSPanel.java index 5ccf8c7e9212..10bb82b9ad8e 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; 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; @@ -163,7 +164,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); @@ -275,7 +276,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 { @@ -574,6 +575,9 @@ public class QSPanel extends LinearLayout implements Tunable, Callback, Brightne 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 be01d75552de..8fa64d3aaf0c 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.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, diff --git a/packages/SystemUI/src/com/android/systemui/util/SysuiLifecycle.java b/packages/SystemUI/src/com/android/systemui/util/SysuiLifecycle.java index 711a0dfe1931..d73175310802 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 @@ public class SysuiLifecycle { 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 000000000000..260f52070a70 --- /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 000000000000..f316d0480fac --- /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 dbbbaac66554..862ebe13bd93 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.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; 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 ce8085aa4862..486939d1f08e 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 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); + } } |