1 /* 2 * Copyright (c) 2019, 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 package com.android.car.media.testmediaapp; 17 18 import static com.android.car.media.testmediaapp.prefs.TmaEnumPrefs.TmaBrowseNodeType.LEAF_CHILDREN; 19 import static com.android.car.media.testmediaapp.prefs.TmaEnumPrefs.TmaBrowseNodeType.QUEUE_ONLY; 20 import static com.android.car.media.testmediaapp.prefs.TmaEnumPrefs.TmaLoginEventOrder.PLAYBACK_STATE_UPDATE_FIRST; 21 22 import android.app.PendingIntent; 23 import android.content.ComponentName; 24 import android.content.Context; 25 import android.content.Intent; 26 import android.media.AudioManager; 27 import android.os.Bundle; 28 import android.os.Handler; 29 import android.support.v4.media.MediaBrowserCompat.MediaItem; 30 import android.support.v4.media.session.MediaSessionCompat; 31 import android.support.v4.media.session.PlaybackStateCompat; 32 import android.util.Log; 33 34 import androidx.annotation.NonNull; 35 import androidx.annotation.Nullable; 36 import androidx.media.MediaBrowserServiceCompat; 37 import androidx.media.session.MediaButtonReceiver; 38 39 import com.android.car.media.testmediaapp.loader.TmaLoader; 40 import com.android.car.media.testmediaapp.prefs.TmaEnumPrefs.TmaAccountType; 41 import com.android.car.media.testmediaapp.prefs.TmaEnumPrefs.TmaBrowseNodeType; 42 import com.android.car.media.testmediaapp.prefs.TmaEnumPrefs.TmaReplyDelay; 43 import com.android.car.media.testmediaapp.prefs.TmaPrefs; 44 45 import java.util.ArrayList; 46 import java.util.List; 47 import java.util.Objects; 48 import java.util.regex.Matcher; 49 import java.util.regex.Pattern; 50 51 52 /** 53 * Implementation of {@link MediaBrowserServiceCompat} that delivers {@link MediaItem}s based on 54 * json configuration files stored in the application's assets. Those assets combined with a few 55 * preferences (see: {@link TmaPrefs}), allow to create a variety of use cases (including error 56 * states) to stress test the Car Media application. <p/> 57 * The media items are cached in the {@link TmaLibrary}, and can be virtually played with 58 * {@link TmaPlayer}. 59 */ 60 public class TmaBrowser extends MediaBrowserServiceCompat { 61 private static final String TAG = "TmaBrowser"; 62 63 private static final int MAX_SEARCH_DEPTH = 4; 64 private static final String MEDIA_SESSION_TAG = "TEST_MEDIA_SESSION"; 65 private static final String ROOT_ID = "_ROOT_ID_"; 66 private static final String SEARCH_SUPPORTED = "android.media.browse.SEARCH_SUPPORTED"; 67 /** 68 * Extras key to allow Android Auto to identify the browse service from the media session. 69 */ 70 private static final String BROWSE_SERVICE_FOR_SESSION_KEY = 71 "android.media.session.BROWSE_SERVICE"; 72 73 private TmaPrefs mPrefs; 74 private Handler mHandler; 75 private MediaSessionCompat mSession; 76 private TmaLibrary mLibrary; 77 private TmaPlayer mPlayer; 78 79 private BrowserRoot mRoot; 80 TmaBrowser()81 public TmaBrowser() { 82 super(); 83 } 84 85 @Override onCreate()86 public void onCreate() { 87 super.onCreate(); 88 mPrefs = TmaPrefs.getInstance(this); 89 mHandler = new Handler(); 90 91 ComponentName mbrComponent = MediaButtonReceiver.getMediaButtonReceiverComponent(this); 92 Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); 93 mediaButtonIntent.setComponent(mbrComponent); 94 PendingIntent mbrIntent = PendingIntent.getBroadcast(this, 0, mediaButtonIntent, 95 PendingIntent.FLAG_IMMUTABLE); 96 97 mSession = new MediaSessionCompat(this, MEDIA_SESSION_TAG, mbrComponent, mbrIntent); 98 setSessionToken(mSession.getSessionToken()); 99 100 mLibrary = new TmaLibrary(new TmaLoader(this)); 101 AudioManager audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); 102 mPlayer = new TmaPlayer(this, mLibrary, audioManager, mHandler, mSession); 103 104 mSession.setCallback(mPlayer); 105 mSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS 106 | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); 107 Bundle mediaSessionExtras = new Bundle(); 108 mediaSessionExtras.putString(BROWSE_SERVICE_FOR_SESSION_KEY, TmaBrowser.class.getName()); 109 mSession.setExtras(mediaSessionExtras); 110 111 mPrefs.mAccountType.registerChangeListener(mOnAccountChanged); 112 mPrefs.mRootNodeType.registerChangeListener(mOnRootNodeTypeChanged); 113 mPrefs.mRootReplyDelay.registerChangeListener(mOnReplyDelayChanged); 114 115 Bundle browserRootExtras = new Bundle(); 116 browserRootExtras.putBoolean(SEARCH_SUPPORTED, true); 117 mRoot = new BrowserRoot(ROOT_ID, browserRootExtras); 118 119 updatePlaybackState(mPrefs.mAccountType.getValue()); 120 } 121 122 @Override onDestroy()123 public void onDestroy() { 124 mPrefs.mAccountType.unregisterChangeListener(mOnAccountChanged); 125 mPrefs.mRootNodeType.unregisterChangeListener(mOnRootNodeTypeChanged); 126 mPrefs.mRootReplyDelay.unregisterChangeListener(mOnReplyDelayChanged); 127 mSession.release(); 128 mHandler = null; 129 mPrefs = null; 130 super.onDestroy(); 131 } 132 133 private final TmaPrefs.PrefValueChangedListener<TmaAccountType> mOnAccountChanged = 134 (oldValue, newValue) -> { 135 if (PLAYBACK_STATE_UPDATE_FIRST.equals(mPrefs.mLoginEventOrder.getValue())) { 136 updatePlaybackState(newValue); 137 invalidateRoot(); 138 } else { 139 invalidateRoot(); 140 (new Handler()).postDelayed(() -> { 141 updatePlaybackState(newValue); 142 }, 3000); 143 } 144 }; 145 146 private final TmaPrefs.PrefValueChangedListener<TmaBrowseNodeType> mOnRootNodeTypeChanged = 147 (oldValue, newValue) -> invalidateRoot(); 148 149 private final TmaPrefs.PrefValueChangedListener<TmaReplyDelay> mOnReplyDelayChanged = 150 (oldValue, newValue) -> invalidateRoot(); 151 updatePlaybackState(TmaAccountType accountType)152 private void updatePlaybackState(TmaAccountType accountType) { 153 if (accountType == TmaAccountType.NONE) { 154 mSession.setMetadata(null); 155 mPlayer.onStop(); 156 mPlayer.setPlaybackState( 157 new TmaMediaEvent(TmaMediaEvent.EventState.ERROR, 158 TmaMediaEvent.StateErrorCode.AUTHENTICATION_EXPIRED, 159 getResources().getString(R.string.no_account), 160 getResources().getString(R.string.select_account), 161 TmaMediaEvent.ResolutionIntent.PREFS, 162 TmaMediaEvent.Action.NONE, 0, null, null)); 163 } else { 164 // TODO don't reset error in all cases... 165 PlaybackStateCompat.Builder playbackState = new PlaybackStateCompat.Builder(); 166 playbackState.setState(PlaybackStateCompat.STATE_PAUSED, 0, 0); 167 playbackState.setActions(PlaybackStateCompat.ACTION_PREPARE); 168 mSession.setPlaybackState(playbackState.build()); 169 } 170 } 171 invalidateRoot()172 private void invalidateRoot() { 173 notifyChildrenChanged(ROOT_ID); 174 } 175 176 @Override onGetRoot( @onNull String clientPackageName, int clientUid, Bundle rootHints)177 public BrowserRoot onGetRoot( 178 @NonNull String clientPackageName, int clientUid, Bundle rootHints) { 179 if (rootHints == null) { 180 Log.e(TAG, "Client " + clientPackageName + " didn't set rootHints."); 181 throw new NullPointerException("rootHints is null"); 182 } 183 Log.i(TAG, "onGetroot client: " + clientPackageName + " EXTRA_MEDIA_ART_SIZE_HINT_PIXELS: " 184 + rootHints.getInt(MediaKeys.EXTRA_MEDIA_ART_SIZE_HINT_PIXELS, 0)); 185 return mRoot; 186 } 187 188 @Override onLoadChildren(@onNull String parentId, @NonNull Result<List<MediaItem>> result)189 public void onLoadChildren(@NonNull String parentId, @NonNull Result<List<MediaItem>> result) { 190 getMediaItemsWithDelay(parentId, result, null); 191 192 if (QUEUE_ONLY.equals(mPrefs.mRootNodeType.getValue()) && ROOT_ID.equals(parentId)) { 193 TmaMediaItem queue = mLibrary.getRoot(LEAF_CHILDREN); 194 if (queue != null) { 195 mSession.setQueue(queue.buildQueue()); 196 mPlayer.prepareMediaItem(queue.getPlayableByIndex(0)); 197 } 198 } 199 } 200 201 @Override onSearch(@onNull String query, Bundle extras, @NonNull Result<List<MediaItem>> result)202 public void onSearch(@NonNull String query, Bundle extras, 203 @NonNull Result<List<MediaItem>> result) { 204 getMediaItemsWithDelay(ROOT_ID, result, query); 205 } 206 getRoot()207 private TmaMediaItem getRoot() { 208 return mLibrary.getRoot(mPrefs.mRootNodeType.getValue()); 209 } 210 getMediaItemsWithDelay(@onNull String parentId, @NonNull Result<List<MediaItem>> result, @Nullable String filter)211 private void getMediaItemsWithDelay(@NonNull String parentId, 212 @NonNull Result<List<MediaItem>> result, @Nullable String filter) { 213 // TODO: allow per item override of the delay ? 214 TmaReplyDelay delay = mPrefs.mRootReplyDelay.getValue(); 215 Runnable task = () -> { 216 TmaMediaItem node; 217 if (TmaAccountType.NONE.equals(mPrefs.mAccountType.getValue())) { 218 node = null; 219 } else if (ROOT_ID.equals(parentId)) { 220 node = getRoot(); 221 } else { 222 node = mLibrary.getMediaItemById(parentId); 223 } 224 225 if (node == null) { 226 result.sendResult(null); 227 } else if (filter != null) { 228 List<MediaItem> hits = new ArrayList<>(50); 229 Pattern pat = Pattern.compile(Pattern.quote(filter), Pattern.CASE_INSENSITIVE); 230 addSearchResults(node, pat.matcher(""), hits, MAX_SEARCH_DEPTH); 231 result.sendResult(hits); 232 } else { 233 List<TmaMediaItem> children = node.getChildren(); 234 int childrenCount = children.size(); 235 List<MediaItem> items = new ArrayList<>(childrenCount); 236 if (childrenCount <= 0) { 237 result.sendResult(items); 238 } else { 239 int selfUpdateDelay = node.getSelfUpdateDelay(); 240 int toShow = (selfUpdateDelay > 0) ? 1 + node.mRevealCounter : childrenCount; 241 for (int childIndex = 0 ; childIndex < toShow; childIndex++) { 242 TmaMediaItem child = children.get(childIndex); 243 if (child.mIsHidden) { 244 continue; 245 } 246 items.add(child.toMediaItem()); 247 } 248 result.sendResult(items); 249 250 if (selfUpdateDelay > 0) { 251 mHandler.postDelayed(new UpdateNodeTask(parentId), selfUpdateDelay); 252 node.mRevealCounter = (node.mRevealCounter + 1) % (childrenCount); 253 } 254 } 255 } 256 }; 257 if (delay == TmaReplyDelay.NONE) { 258 task.run(); 259 } else { 260 result.detach(); 261 mHandler.postDelayed(task, delay.mReplyDelayMs); 262 } 263 } 264 addSearchResults(@ullable TmaMediaItem node, Matcher matcher, List<MediaItem> hits, int currentDepth)265 private void addSearchResults(@Nullable TmaMediaItem node, Matcher matcher, 266 List<MediaItem> hits, int currentDepth) { 267 if (node == null || currentDepth <= 0) { 268 return; 269 } 270 271 for (TmaMediaItem child : node.getChildren()) { 272 if (child.mIsHidden) { 273 continue; 274 } 275 MediaItem item = child.toMediaItem(); 276 CharSequence title = item.getDescription().getTitle(); 277 if (title != null) { 278 matcher.reset(title); 279 if (matcher.find()) { 280 hits.add(item); 281 } 282 } 283 284 // Ask the library to load the grand children 285 child = mLibrary.getMediaItemById(child.getMediaId()); 286 addSearchResults(child, matcher, hits, currentDepth - 1); 287 } 288 } 289 toggleItem(@ullable TmaMediaItem item)290 void toggleItem(@Nullable TmaMediaItem item) { 291 if (item == null) { 292 return; 293 } 294 item.mIsHidden = !item.mIsHidden; 295 if (item.getParent() != null) { 296 String parentId = item.getParent().getMediaId(); 297 if (Objects.equals(parentId, getRoot().getMediaId())) { 298 parentId = ROOT_ID; 299 } 300 notifyChildrenChanged(parentId); 301 } 302 } 303 304 private class UpdateNodeTask implements Runnable { 305 306 private final String mNodeId; 307 UpdateNodeTask(@onNull String nodeId)308 UpdateNodeTask(@NonNull String nodeId) { 309 mNodeId = nodeId; 310 } 311 312 @Override run()313 public void run() { 314 notifyChildrenChanged(mNodeId); 315 } 316 } 317 } 318