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.uiautomatorhelpers.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 InstrumentationRegistry.getInstrumentation().getUiAutomation().clearCache(); 142 notification = mDevice.wait(Until.findObject(umoSelector), WAIT_TIME_MILLIS); 143 } 144 assertNotNull("Unable to find UMO.", notification); 145 // The UMO may still not be fully visible, double check it's visibility. 146 notification = ensureUMOFullyVisible(notification); 147 assertNotNull("UMO isn't fully visible.", notification); 148 mDevice.waitForIdle(); 149 HealthTestingUtils.waitForValueToSettle( 150 () -> "UMO isn't settle after timeout.", notification::getVisibleBounds); 151 return notification; 152 } 153 ensureUMOFullyVisible(UiObject2 umo)154 private UiObject2 ensureUMOFullyVisible(UiObject2 umo) { 155 final BySelector footerSelector = By.res(PKG, "qs_footer_actions"); 156 UiObject2 footer = mDevice.wait(Until.findObject(footerSelector), WAIT_TIME_MILLIS); 157 assertNotNull("Can't find QS actions footer.", footer); 158 Rect umoBound = umo.getVisibleBounds(); 159 Rect footerBound = footer.getVisibleBounds(); 160 int distance = umoBound.bottom - footerBound.top; 161 if (distance <= 0) { 162 return umo; 163 } 164 distance += footerBound.height(); 165 UiObject2 scrollable = mDevice.wait(Until.findObject(By.scrollable(true)), WAIT_TIME_MILLIS); 166 scrollable.scroll( 167 Direction.DOWN, (float)distance / scrollable.getVisibleBounds().height(), 100); 168 InstrumentationRegistry.getInstrumentation().getUiAutomation().clearCache(); 169 return mDevice.wait(Until.findObject(By.res(umo.getResourceName())), WAIT_TIME_MILLIS); 170 } 171 172 /** 173 * Find the UMO that belongs to the current MediaInstrumentation (Media Session). 174 * If the UMO can't be found, the function will raise an assertion error. 175 * 176 * @return MediaController 177 */ getMediaNotification()178 public MediaController getMediaNotification() { 179 MediaMetadata source = mMediaSources.stream().findFirst().orElseThrow(); 180 UiObject2 notification = scrollToMediaNotification(source); 181 return new MediaController(this, notification); 182 } 183 184 /** 185 * Find the UMO in current view. This method will only check UMO in current view page different 186 * than {@link #getMediaNotification()} to seek UMO in quick setting view. 187 * 188 * @return MediaController 189 * @throws AssertionError if the UMO can't be found in current view. 190 */ getMediaNotificationInCurrentView()191 public MediaController getMediaNotificationInCurrentView() { 192 MediaMetadata source = mMediaSources.stream().findFirst().orElseThrow(); 193 final BySelector mediaTitleSelector = By.res(PKG, "header_title") 194 .text(source.getString(MediaMetadata.METADATA_KEY_TITLE)); 195 final BySelector umoSelector = By.res(PKG, MEDIA_CONTROLLER_RES_ID) 196 .hasDescendant(mediaTitleSelector); 197 UiObject2 notification = mDevice.wait(Until.findObject(umoSelector), WAIT_TIME_MILLIS); 198 assertNotNull("Unable to find UMO.", notification); 199 mDevice.waitForIdle(); 200 HealthTestingUtils.waitForValueToSettle( 201 () -> "UMO isn't settle after timeout.", notification::getVisibleBounds); 202 return new MediaController(this, notification); 203 } 204 205 /** 206 * Wait for UMO is gone. 207 * 208 * @param timeout Maximum amount of time to wait in milliseconds. 209 * @return The final result returned by the condition, or null if the condition was not met 210 * before the timeout. 211 */ waitUmoGone(long timeout)212 public boolean waitUmoGone(long timeout) { 213 return mDevice.wait(Until.gone(By.res(PKG, MEDIA_CONTROLLER_RES_ID)), timeout); 214 } 215 isMediaNotificationVisible()216 public boolean isMediaNotificationVisible() { 217 return mDevice.hasObject(By.res(PKG, MEDIA_CONTROLLER_RES_ID)); 218 } 219 220 /** Assert that the media notification is visible with a 10 second timeout. */ assertMediaNotificationVisible()221 public void assertMediaNotificationVisible() { 222 assertVisibility( 223 By.res(PKG, MEDIA_CONTROLLER_RES_ID), 224 true, 225 Duration.ofSeconds(10), 226 () -> "UMO should be visible on lockscreen."); 227 } 228 addMediaSessionStateChangedListeners(Consumer<Integer> listener)229 public void addMediaSessionStateChangedListeners(Consumer<Integer> listener) { 230 mMediaSessionStateChangedListeners.add(listener); 231 } 232 clearMediaSessionStateChangedListeners()233 public void clearMediaSessionStateChangedListeners() { 234 mMediaSessionStateChangedListeners.clear(); 235 } 236 onMediaSessionStateChanged(int state)237 private void onMediaSessionStateChanged(int state) { 238 setCurrentMediaState(state); 239 for (Consumer<Integer> listener : mMediaSessionStateChangedListeners) { 240 listener.accept(state); 241 } 242 } 243 onMediaSessionSkipTo(int state)244 private void onMediaSessionSkipTo(int state) { 245 final int sources = mMediaSources.size(); 246 if (sources <= 0) { // no media sources to skip to 247 return; 248 } 249 switch (state) { 250 case PlaybackState.STATE_SKIPPING_TO_NEXT: 251 mCurrentMediaSource = (mCurrentMediaSource + 1) % sources; 252 break; 253 case PlaybackState.STATE_SKIPPING_TO_PREVIOUS: 254 mCurrentMediaSource = (mCurrentMediaSource - 1) % sources; 255 break; 256 default: // the state changing isn't related to skip. 257 return; 258 } 259 mMediaSession.setMetadata(mMediaSources.get(mCurrentMediaSource)); 260 mPlayer.setDataSource(mMediaSources.get(mCurrentMediaSource)); 261 mPlayer.reset(); 262 mPlayer.pause(); 263 setCurrentMediaState(PlaybackState.STATE_PAUSED); 264 createNotification(); 265 } 266 updatePlaybackState()267 private void updatePlaybackState() { 268 if (mUseLegacyVersion) { 269 // TODO(bennolin): add legacy version, be aware of `setState` and `ACTION_SEEK_TO` 270 // are still relevant to legacy version controller. 271 return; 272 } 273 mMediaSession.setPlaybackState(new PlaybackState.Builder() 274 .setActions(getAvailableActions(mCurrentMediaState)) 275 .setState(mCurrentMediaState, mPlayer.getCurrentPosition(), 1.0f) 276 .build()); 277 } 278 279 /** 280 * Sets the Media's state to the given state. 281 * 282 * @param state the {@link PlaybackState}. 283 */ setCurrentMediaState(int state)284 public void setCurrentMediaState(int state) { 285 mCurrentMediaState = state; 286 updatePlaybackState(); 287 } 288 getAvailableActions(int state)289 private Long getAvailableActions(int state) { 290 switch (state) { 291 case PlaybackState.STATE_PLAYING: 292 return PlaybackState.ACTION_PAUSE 293 | PlaybackState.ACTION_SEEK_TO 294 | PlaybackState.ACTION_SKIP_TO_NEXT 295 | PlaybackState.ACTION_SKIP_TO_PREVIOUS; 296 case PlaybackState.STATE_PAUSED: 297 return PlaybackState.ACTION_PLAY 298 | PlaybackState.ACTION_STOP 299 | PlaybackState.ACTION_SKIP_TO_NEXT 300 | PlaybackState.ACTION_SKIP_TO_PREVIOUS; 301 case PlaybackState.STATE_STOPPED: 302 return PlaybackState.ACTION_PLAY 303 | PlaybackState.ACTION_PAUSE 304 | PlaybackState.ACTION_SKIP_TO_NEXT 305 | PlaybackState.ACTION_SKIP_TO_PREVIOUS; 306 default: 307 return PlaybackState.ACTION_PLAY | PlaybackState.ACTION_PLAY_PAUSE 308 | PlaybackState.ACTION_STOP | PlaybackState.ACTION_SEEK_TO; 309 } 310 } 311 312 public static class Builder { 313 314 private final boolean mUseLegacyVersion; 315 private final Context mContext; 316 private final MediaSession mSession; 317 private String mChannelId; 318 private final List<MediaMetadata> mDataSources; 319 Builder(Context context, MediaSession session)320 public Builder(Context context, MediaSession session) { 321 mUseLegacyVersion = false; 322 mContext = context; 323 mChannelId = ""; 324 mSession = session; 325 mDataSources = new ArrayList<>(); 326 } 327 setChannelId(String id)328 public Builder setChannelId(String id) { 329 mChannelId = id; 330 return this; 331 } 332 addDataSource(MediaMetadata source)333 public Builder addDataSource(MediaMetadata source) { 334 mDataSources.add(source); 335 return this; 336 } 337 build()338 public MediaInstrumentation build() { 339 if (mChannelId.isEmpty()) { 340 NotificationManager manager = mContext.getSystemService(NotificationManager.class); 341 mChannelId = MediaInstrumentation.class.getCanonicalName(); 342 NotificationChannel channel = new NotificationChannel( 343 mChannelId, "Default", NotificationManager.IMPORTANCE_DEFAULT); 344 manager.createNotificationChannel(channel); 345 } 346 return new MediaInstrumentation( 347 mContext, mSession, mDataSources, mChannelId, mUseLegacyVersion); 348 } 349 } 350 } 351