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