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