• 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 
isMediaNotificationVisible()200     public boolean isMediaNotificationVisible() {
201         return mDevice.hasObject(By.res(PKG, MEDIA_CONTROLLER_RES_ID));
202     }
203 
addMediaSessionStateChangedListeners(Consumer<Integer> listener)204     public void addMediaSessionStateChangedListeners(Consumer<Integer> listener) {
205         mMediaSessionStateChangedListeners.add(listener);
206     }
207 
clearMediaSessionStateChangedListeners()208     public void clearMediaSessionStateChangedListeners() {
209         mMediaSessionStateChangedListeners.clear();
210     }
211 
onMediaSessionStateChanged(int state)212     private void onMediaSessionStateChanged(int state) {
213         setCurrentMediaState(state);
214         for (Consumer<Integer> listener : mMediaSessionStateChangedListeners) {
215             listener.accept(state);
216         }
217     }
218 
onMediaSessionSkipTo(int state)219     private void onMediaSessionSkipTo(int state) {
220         final int sources = mMediaSources.size();
221         if (sources <= 0) { // no media sources to skip to
222             return;
223         }
224         switch (state) {
225             case PlaybackState.STATE_SKIPPING_TO_NEXT:
226                 mCurrentMediaSource = (mCurrentMediaSource + 1) % sources;
227                 break;
228             case PlaybackState.STATE_SKIPPING_TO_PREVIOUS:
229                 mCurrentMediaSource = (mCurrentMediaSource - 1) % sources;
230                 break;
231             default: // the state changing isn't related to skip.
232                 return;
233         }
234         mMediaSession.setMetadata(mMediaSources.get(mCurrentMediaSource));
235         mPlayer.setDataSource(mMediaSources.get(mCurrentMediaSource));
236         mPlayer.reset();
237         mPlayer.start();
238         setCurrentMediaState(PlaybackState.STATE_PLAYING);
239         createNotification();
240     }
241 
updatePlaybackState()242     private void updatePlaybackState() {
243         if (mUseLegacyVersion) {
244             // TODO(bennolin): add legacy version, be aware of `setState`  and  `ACTION_SEEK_TO`
245             //  are still relevant to legacy version controller.
246             return;
247         }
248         mMediaSession.setPlaybackState(new PlaybackState.Builder()
249                 .setActions(getAvailableActions(mCurrentMediaState))
250                 .setState(mCurrentMediaState, mPlayer.getCurrentPosition(), 1.0f)
251                 .build());
252     }
253 
setCurrentMediaState(int state)254     private void setCurrentMediaState(int state) {
255         mCurrentMediaState = state;
256         updatePlaybackState();
257     }
258 
getAvailableActions(int state)259     private Long getAvailableActions(int state) {
260         switch (state) {
261             case PlaybackState.STATE_PLAYING:
262                 return PlaybackState.ACTION_PAUSE
263                         | PlaybackState.ACTION_SEEK_TO
264                         | PlaybackState.ACTION_SKIP_TO_NEXT
265                         | PlaybackState.ACTION_SKIP_TO_PREVIOUS;
266             case PlaybackState.STATE_PAUSED:
267                 return PlaybackState.ACTION_PLAY
268                         | PlaybackState.ACTION_STOP
269                         | PlaybackState.ACTION_SKIP_TO_NEXT
270                         | PlaybackState.ACTION_SKIP_TO_PREVIOUS;
271             case PlaybackState.STATE_STOPPED:
272                 return PlaybackState.ACTION_PLAY
273                         | PlaybackState.ACTION_PAUSE
274                         | PlaybackState.ACTION_SKIP_TO_NEXT
275                         | PlaybackState.ACTION_SKIP_TO_PREVIOUS;
276             default:
277                 return PlaybackState.ACTION_PLAY | PlaybackState.ACTION_PLAY_PAUSE
278                         | PlaybackState.ACTION_STOP | PlaybackState.ACTION_SEEK_TO;
279         }
280     }
281 
282     public static class Builder {
283 
284         private final boolean mUseLegacyVersion;
285         private final Context mContext;
286         private final MediaSession mSession;
287         private String mChannelId;
288         private final List<MediaMetadata> mDataSources;
289 
Builder(Context context, MediaSession session)290         public Builder(Context context, MediaSession session) {
291             mUseLegacyVersion = false;
292             mContext = context;
293             mChannelId = "";
294             mSession = session;
295             mDataSources = new ArrayList<>();
296         }
297 
setChannelId(String id)298         public Builder setChannelId(String id) {
299             mChannelId = id;
300             return this;
301         }
302 
addDataSource(MediaMetadata source)303         public Builder addDataSource(MediaMetadata source) {
304             mDataSources.add(source);
305             return this;
306         }
307 
build()308         public MediaInstrumentation build() {
309             if (mChannelId.isEmpty()) {
310                 NotificationManager manager = mContext.getSystemService(NotificationManager.class);
311                 mChannelId = MediaInstrumentation.class.getCanonicalName();
312                 NotificationChannel channel = new NotificationChannel(
313                         mChannelId, "Default", NotificationManager.IMPORTANCE_DEFAULT);
314                 manager.createNotificationChannel(channel);
315             }
316             return new MediaInstrumentation(
317                     mContext, mSession, mDataSources, mChannelId, mUseLegacyVersion);
318         }
319     }
320 }
321