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