• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.platform.helpers.media;
18 
19 import static org.junit.Assert.assertNotNull;
20 
21 import android.app.Notification;
22 import android.app.NotificationChannel;
23 import android.app.NotificationManager;
24 import android.content.Context;
25 import android.graphics.Rect;
26 import android.media.MediaMetadata;
27 import android.media.session.MediaSession;
28 import android.media.session.PlaybackState;
29 import android.os.Handler;
30 import android.os.Looper;
31 import android.platform.test.util.HealthTestingUtils;
32 
33 import androidx.test.platform.app.InstrumentationRegistry;
34 import androidx.test.uiautomator.By;
35 import androidx.test.uiautomator.BySelector;
36 import androidx.test.uiautomator.Direction;
37 import androidx.test.uiautomator.UiDevice;
38 import androidx.test.uiautomator.UiObject2;
39 import androidx.test.uiautomator.Until;
40 
41 import java.util.ArrayList;
42 import java.util.List;
43 import java.util.function.Consumer;
44 
45 /** Media instrumentation for testing. */
46 public final class MediaInstrumentation {
47 
48     private static final int WAIT_TIME_MILLIS = 5000;
49     private static final String PKG = "com.android.systemui";
50     private static final String MEDIA_CONTROLLER_RES_ID = "qs_media_controls";
51     private static int notificationID = 0;
52 
53     private final UiDevice mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
54 
55     private final String mChannelId;
56     private final NotificationManager mManager;
57     private final MediaSession mMediaSession;
58     private final Handler mHandler;
59     private final MediaSessionCallback mCallback;
60     private final Context mContext;
61     // TODO(bennolin): support legacy version media controller. Please refer
62     //  go/media-t-app-changes for more details.
63     private final boolean mUseLegacyVersion;
64     private final List<Consumer<Integer>> mMediaSessionStateChangedListeners;
65     private final int mNotificationId;
66     private final MockMediaPlayer mPlayer;
67     private int mCurrentMediaState;
68 
69     // the idx of mMediaSources which represents current media source.
70     private int mCurrentMediaSource;
71     private final List<MediaMetadata> mMediaSources;
72 
MediaInstrumentation( Context context, MediaSession mediaSession, List<MediaMetadata> mediaSources, String channelId, boolean useLegacyVersion )73     private MediaInstrumentation(
74             Context context, MediaSession mediaSession,
75             List<MediaMetadata> mediaSources,
76             String channelId, boolean useLegacyVersion
77     ) {
78         mHandler = new Handler(Looper.getMainLooper());
79         mContext = context;
80         mMediaSession = mediaSession;
81         mChannelId = channelId;
82         mUseLegacyVersion = useLegacyVersion;
83         mManager = context.getSystemService(NotificationManager.class);
84         mCurrentMediaState = PlaybackState.STATE_NONE;
85         mPlayer = new MockMediaPlayer();
86         mCallback = new MediaSessionCallback(mPlayer);
87         mMediaSources = mediaSources;
88         mCurrentMediaSource = 0;
89         mNotificationId = ++notificationID;
90         mMediaSessionStateChangedListeners = new ArrayList<>();
91         initialize();
92     }
93 
initialize()94     private void initialize() {
95         mHandler.post(() -> mMediaSession.setCallback(mCallback));
96         mCallback.addOnMediaStateChangedListener(this::onMediaSessionStateChanged);
97         mCallback.addOnMediaStateChangedListener(this::onMediaSessionSkipTo);
98         MediaMetadata source = mMediaSources.stream().findFirst().orElse(null);
99         mMediaSession.setMetadata(source);
100         mMediaSession.setActive(true);
101         mPlayer.setDataSource(source);
102         mPlayer.setOnCompletionListener(() -> setCurrentMediaState(PlaybackState.STATE_STOPPED));
103         setCurrentMediaState(
104                 source == null ? PlaybackState.STATE_NONE : PlaybackState.STATE_STOPPED);
105     }
106 
buildNotification()107     Notification.Builder buildNotification() {
108         return new Notification.Builder(mContext, mChannelId)
109                 .setContentTitle("MediaInstrumentation")
110                 .setContentText("media")
111                 .setSmallIcon(android.R.drawable.stat_sys_headset)
112                 .setStyle(new Notification.MediaStyle()
113                         .setMediaSession(mMediaSession.getSessionToken()));
114     }
115 
createNotification()116     public void createNotification() {
117         mManager.notify(mNotificationId, buildNotification().build());
118     }
119 
120     /** Cancel the Media notification */
cancelNotification()121     public void cancelNotification() {
122         mManager.cancel(mNotificationId);
123     }
124 
scrollToMediaNotification(MediaMetadata meta)125     UiObject2 scrollToMediaNotification(MediaMetadata meta) {
126         final BySelector qsScrollViewSelector = By.res(PKG, "expanded_qs_scroll_view");
127         final BySelector mediaTitleSelector = By.res(PKG, "header_title")
128                 .text(meta.getString(MediaMetadata.METADATA_KEY_TITLE));
129         final BySelector umoSelector = By.res(PKG, MEDIA_CONTROLLER_RES_ID)
130                 .hasDescendant(mediaTitleSelector);
131         UiObject2 notification = mDevice.wait(Until.findObject(umoSelector), WAIT_TIME_MILLIS);
132         if (notification == null) {
133             // Try to scroll down the QS container to make UMO visible.
134             UiObject2 qsScrollView = mDevice.wait(Until.findObject(qsScrollViewSelector),
135                     WAIT_TIME_MILLIS);
136             assertNotNull("Unable to scroll the QS container.", qsScrollView);
137             qsScrollView.scroll(Direction.DOWN, 1.0f, 100);
138             notification = mDevice.wait(Until.findObject(umoSelector), WAIT_TIME_MILLIS);
139         }
140         assertNotNull("Unable to find UMO.", notification);
141         // The UMO may still not be fully visible, double check it's visibility.
142         notification = ensureUMOFullyVisible(notification);
143         assertNotNull("UMO isn't fully visible.", notification);
144         mDevice.waitForIdle();
145         HealthTestingUtils.waitForValueToSettle(
146                 () -> "UMO isn't settle after timeout.", notification::getVisibleBounds);
147         return notification;
148     }
149 
ensureUMOFullyVisible(UiObject2 umo)150     private UiObject2 ensureUMOFullyVisible(UiObject2 umo) {
151         final BySelector footerSelector = By.res(PKG, "qs_footer_actions");
152         UiObject2 footer = mDevice.wait(Until.findObject(footerSelector), WAIT_TIME_MILLIS);
153         assertNotNull("Can't find QS actions footer.", footer);
154         Rect umoBound = umo.getVisibleBounds();
155         Rect footerBound = footer.getVisibleBounds();
156         int distance = umoBound.bottom - footerBound.top;
157         if (distance <= 0) {
158             return umo;
159         }
160         distance += footerBound.height();
161         UiObject2 scrollable = mDevice.wait(Until.findObject(By.scrollable(true)), WAIT_TIME_MILLIS);
162         scrollable.scroll(
163                 Direction.DOWN, (float)distance / scrollable.getVisibleBounds().height(), 100);
164         return mDevice.wait(Until.findObject(By.res(umo.getResourceName())), WAIT_TIME_MILLIS);
165     }
166 
167     /**
168      * Find the UMO that belongs to the current MediaInstrumentation (Media Session).
169      * If the UMO can't be found, the function will raise an assertion error.
170      *
171      * @return MediaController
172      */
getMediaNotification()173     public MediaController getMediaNotification() {
174         MediaMetadata source = mMediaSources.stream().findFirst().orElseThrow();
175         UiObject2 notification = scrollToMediaNotification(source);
176         return new MediaController(this, notification);
177     }
178 
179     /**
180      * Find the UMO in current view. This method will only check UMO in current view page different
181      * than {@link #getMediaNotification()} to seek UMO in quick setting view.
182      *
183      * @return MediaController
184      * @throws AssertionError if the UMO can't be found in current view.
185      */
getMediaNotificationInCurrentView()186     public MediaController getMediaNotificationInCurrentView() {
187         MediaMetadata source = mMediaSources.stream().findFirst().orElseThrow();
188         final BySelector mediaTitleSelector = By.res(PKG, "header_title")
189                 .text(source.getString(MediaMetadata.METADATA_KEY_TITLE));
190         final BySelector umoSelector = By.res(PKG, MEDIA_CONTROLLER_RES_ID)
191                 .hasDescendant(mediaTitleSelector);
192         UiObject2 notification = mDevice.wait(Until.findObject(umoSelector), WAIT_TIME_MILLIS);
193         assertNotNull("Unable to find UMO.", notification);
194         mDevice.waitForIdle();
195         HealthTestingUtils.waitForValueToSettle(
196                 () -> "UMO isn't settle after timeout.", notification::getVisibleBounds);
197         return new MediaController(this, notification);
198     }
199 
200     /**
201      * Wait for UMO is gone.
202      *
203      * @param timeout Maximum amount of time to wait in milliseconds.
204      * @return The final result returned by the condition, or null if the condition was not met
205      *     before the timeout.
206      */
waitUmoGone(long timeout)207     public boolean waitUmoGone(long timeout) {
208         return mDevice.wait(Until.gone(By.res(PKG, MEDIA_CONTROLLER_RES_ID)), timeout);
209     }
210 
isMediaNotificationVisible()211     public boolean isMediaNotificationVisible() {
212         return mDevice.hasObject(By.res(PKG, MEDIA_CONTROLLER_RES_ID));
213     }
214 
addMediaSessionStateChangedListeners(Consumer<Integer> listener)215     public void addMediaSessionStateChangedListeners(Consumer<Integer> listener) {
216         mMediaSessionStateChangedListeners.add(listener);
217     }
218 
clearMediaSessionStateChangedListeners()219     public void clearMediaSessionStateChangedListeners() {
220         mMediaSessionStateChangedListeners.clear();
221     }
222 
onMediaSessionStateChanged(int state)223     private void onMediaSessionStateChanged(int state) {
224         setCurrentMediaState(state);
225         for (Consumer<Integer> listener : mMediaSessionStateChangedListeners) {
226             listener.accept(state);
227         }
228     }
229 
onMediaSessionSkipTo(int state)230     private void onMediaSessionSkipTo(int state) {
231         final int sources = mMediaSources.size();
232         if (sources <= 0) { // no media sources to skip to
233             return;
234         }
235         switch (state) {
236             case PlaybackState.STATE_SKIPPING_TO_NEXT:
237                 mCurrentMediaSource = (mCurrentMediaSource + 1) % sources;
238                 break;
239             case PlaybackState.STATE_SKIPPING_TO_PREVIOUS:
240                 mCurrentMediaSource = (mCurrentMediaSource - 1) % sources;
241                 break;
242             default: // the state changing isn't related to skip.
243                 return;
244         }
245         mMediaSession.setMetadata(mMediaSources.get(mCurrentMediaSource));
246         mPlayer.setDataSource(mMediaSources.get(mCurrentMediaSource));
247         mPlayer.reset();
248         mPlayer.start();
249         setCurrentMediaState(PlaybackState.STATE_PLAYING);
250         createNotification();
251     }
252 
updatePlaybackState()253     private void updatePlaybackState() {
254         if (mUseLegacyVersion) {
255             // TODO(bennolin): add legacy version, be aware of `setState`  and  `ACTION_SEEK_TO`
256             //  are still relevant to legacy version controller.
257             return;
258         }
259         mMediaSession.setPlaybackState(new PlaybackState.Builder()
260                 .setActions(getAvailableActions(mCurrentMediaState))
261                 .setState(mCurrentMediaState, mPlayer.getCurrentPosition(), 1.0f)
262                 .build());
263     }
264 
265     /**
266      * Sets the Media's state to the given state.
267      *
268      * @param state the {@link PlaybackState}.
269      */
setCurrentMediaState(int state)270     public void setCurrentMediaState(int state) {
271         mCurrentMediaState = state;
272         updatePlaybackState();
273     }
274 
getAvailableActions(int state)275     private Long getAvailableActions(int state) {
276         switch (state) {
277             case PlaybackState.STATE_PLAYING:
278                 return PlaybackState.ACTION_PAUSE
279                         | PlaybackState.ACTION_SEEK_TO
280                         | PlaybackState.ACTION_SKIP_TO_NEXT
281                         | PlaybackState.ACTION_SKIP_TO_PREVIOUS;
282             case PlaybackState.STATE_PAUSED:
283                 return PlaybackState.ACTION_PLAY
284                         | PlaybackState.ACTION_STOP
285                         | PlaybackState.ACTION_SKIP_TO_NEXT
286                         | PlaybackState.ACTION_SKIP_TO_PREVIOUS;
287             case PlaybackState.STATE_STOPPED:
288                 return PlaybackState.ACTION_PLAY
289                         | PlaybackState.ACTION_PAUSE
290                         | PlaybackState.ACTION_SKIP_TO_NEXT
291                         | PlaybackState.ACTION_SKIP_TO_PREVIOUS;
292             default:
293                 return PlaybackState.ACTION_PLAY | PlaybackState.ACTION_PLAY_PAUSE
294                         | PlaybackState.ACTION_STOP | PlaybackState.ACTION_SEEK_TO;
295         }
296     }
297 
298     public static class Builder {
299 
300         private final boolean mUseLegacyVersion;
301         private final Context mContext;
302         private final MediaSession mSession;
303         private String mChannelId;
304         private final List<MediaMetadata> mDataSources;
305 
Builder(Context context, MediaSession session)306         public Builder(Context context, MediaSession session) {
307             mUseLegacyVersion = false;
308             mContext = context;
309             mChannelId = "";
310             mSession = session;
311             mDataSources = new ArrayList<>();
312         }
313 
setChannelId(String id)314         public Builder setChannelId(String id) {
315             mChannelId = id;
316             return this;
317         }
318 
addDataSource(MediaMetadata source)319         public Builder addDataSource(MediaMetadata source) {
320             mDataSources.add(source);
321             return this;
322         }
323 
build()324         public MediaInstrumentation build() {
325             if (mChannelId.isEmpty()) {
326                 NotificationManager manager = mContext.getSystemService(NotificationManager.class);
327                 mChannelId = MediaInstrumentation.class.getCanonicalName();
328                 NotificationChannel channel = new NotificationChannel(
329                         mChannelId, "Default", NotificationManager.IMPORTANCE_DEFAULT);
330                 manager.createNotificationChannel(channel);
331             }
332             return new MediaInstrumentation(
333                     mContext, mSession, mDataSources, mChannelId, mUseLegacyVersion);
334         }
335     }
336 }
337