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