Automatic sources dropoff on 2020-06-10 18:32:38.095721 The change is generated with prebuilt drop tool. Change-Id: I24cbf6ba6db262a1ae1445db1427a08fee35b3b4
diff --git a/android/media/MediaRouter.java b/android/media/MediaRouter.java new file mode 100644 index 0000000..12fc3a6 --- /dev/null +++ b/android/media/MediaRouter.java
@@ -0,0 +1,3095 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.media; + +import android.Manifest; +import android.annotation.DrawableRes; +import android.annotation.IntDef; +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.annotation.SystemService; +import android.app.ActivityThread; +import android.compat.annotation.UnsupportedAppUsage; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; +import android.hardware.display.DisplayManager; +import android.hardware.display.WifiDisplay; +import android.hardware.display.WifiDisplayStatus; +import android.media.session.MediaSession; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Process; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.os.UserHandle; +import android.text.TextUtils; +import android.util.Log; +import android.view.Display; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * MediaRouter allows applications to control the routing of media channels + * and streams from the current device to external speakers and destination devices. + * + * <p>A MediaRouter is retrieved through {@link Context#getSystemService(String) + * Context.getSystemService()} of a {@link Context#MEDIA_ROUTER_SERVICE + * Context.MEDIA_ROUTER_SERVICE}. + * + * <p>The media router API is not thread-safe; all interactions with it must be + * done from the main thread of the process.</p> + * + * <p> + * We recommend using {@link android.media.MediaRouter2} APIs for new applications. + * </p> + */ +//TODO: Link androidx.media2.MediaRouter when we are ready. +@SystemService(Context.MEDIA_ROUTER_SERVICE) +public class MediaRouter { + private static final String TAG = "MediaRouter"; + private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + + static class Static implements DisplayManager.DisplayListener { + final String mPackageName; + final Resources mResources; + final IAudioService mAudioService; + final DisplayManager mDisplayService; + final IMediaRouterService mMediaRouterService; + final Handler mHandler; + final CopyOnWriteArrayList<CallbackInfo> mCallbacks = + new CopyOnWriteArrayList<CallbackInfo>(); + + final ArrayList<RouteInfo> mRoutes = new ArrayList<RouteInfo>(); + final ArrayList<RouteCategory> mCategories = new ArrayList<RouteCategory>(); + + final RouteCategory mSystemCategory; + + final AudioRoutesInfo mCurAudioRoutesInfo = new AudioRoutesInfo(); + + RouteInfo mDefaultAudioVideo; + RouteInfo mBluetoothA2dpRoute; + + RouteInfo mSelectedRoute; + + final boolean mCanConfigureWifiDisplays; + boolean mActivelyScanningWifiDisplays; + String mPreviousActiveWifiDisplayAddress; + + int mDiscoveryRequestRouteTypes; + boolean mDiscoverRequestActiveScan; + + int mCurrentUserId = -1; + IMediaRouterClient mClient; + MediaRouterClientState mClientState; + + final IAudioRoutesObserver.Stub mAudioRoutesObserver = new IAudioRoutesObserver.Stub() { + @Override + public void dispatchAudioRoutesChanged(final AudioRoutesInfo newRoutes) { + mHandler.post(new Runnable() { + @Override public void run() { + updateAudioRoutes(newRoutes); + } + }); + } + }; + + Static(Context appContext) { + mPackageName = appContext.getPackageName(); + mResources = appContext.getResources(); + mHandler = new Handler(appContext.getMainLooper()); + + IBinder b = ServiceManager.getService(Context.AUDIO_SERVICE); + mAudioService = IAudioService.Stub.asInterface(b); + + mDisplayService = (DisplayManager) appContext.getSystemService(Context.DISPLAY_SERVICE); + + mMediaRouterService = IMediaRouterService.Stub.asInterface( + ServiceManager.getService(Context.MEDIA_ROUTER_SERVICE)); + + mSystemCategory = new RouteCategory( + com.android.internal.R.string.default_audio_route_category_name, + ROUTE_TYPE_LIVE_AUDIO | ROUTE_TYPE_LIVE_VIDEO, false); + mSystemCategory.mIsSystem = true; + + // Only the system can configure wifi displays. The display manager + // enforces this with a permission check. Set a flag here so that we + // know whether this process is actually allowed to scan and connect. + mCanConfigureWifiDisplays = appContext.checkPermission( + Manifest.permission.CONFIGURE_WIFI_DISPLAY, + Process.myPid(), Process.myUid()) == PackageManager.PERMISSION_GRANTED; + } + + // Called after sStatic is initialized + void startMonitoringRoutes(Context appContext) { + mDefaultAudioVideo = new RouteInfo(mSystemCategory); + mDefaultAudioVideo.mNameResId = com.android.internal.R.string.default_audio_route_name; + mDefaultAudioVideo.mSupportedTypes = ROUTE_TYPE_LIVE_AUDIO | ROUTE_TYPE_LIVE_VIDEO; + mDefaultAudioVideo.updatePresentationDisplay(); + if (((AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE)) + .isVolumeFixed()) { + mDefaultAudioVideo.mVolumeHandling = RouteInfo.PLAYBACK_VOLUME_FIXED; + } + + addRouteStatic(mDefaultAudioVideo); + + // This will select the active wifi display route if there is one. + updateWifiDisplayStatus(mDisplayService.getWifiDisplayStatus()); + + appContext.registerReceiver(new WifiDisplayStatusChangedReceiver(), + new IntentFilter(DisplayManager.ACTION_WIFI_DISPLAY_STATUS_CHANGED)); + appContext.registerReceiver(new VolumeChangeReceiver(), + new IntentFilter(AudioManager.VOLUME_CHANGED_ACTION)); + + mDisplayService.registerDisplayListener(this, mHandler); + + AudioRoutesInfo newAudioRoutes = null; + try { + newAudioRoutes = mAudioService.startWatchingRoutes(mAudioRoutesObserver); + } catch (RemoteException e) { + } + if (newAudioRoutes != null) { + // This will select the active BT route if there is one and the current + // selected route is the default system route, or if there is no selected + // route yet. + updateAudioRoutes(newAudioRoutes); + } + + // Bind to the media router service. + rebindAsUser(UserHandle.myUserId()); + + // Select the default route if the above didn't sync us up + // appropriately with relevant system state. + if (mSelectedRoute == null) { + selectDefaultRouteStatic(); + } + } + + void updateAudioRoutes(AudioRoutesInfo newRoutes) { + boolean audioRoutesChanged = false; + boolean forceUseDefaultRoute = false; + + if (newRoutes.mainType != mCurAudioRoutesInfo.mainType) { + mCurAudioRoutesInfo.mainType = newRoutes.mainType; + int name; + if ((newRoutes.mainType & AudioRoutesInfo.MAIN_HEADPHONES) != 0 + || (newRoutes.mainType & AudioRoutesInfo.MAIN_HEADSET) != 0) { + name = com.android.internal.R.string.default_audio_route_name_headphones; + } else if ((newRoutes.mainType & AudioRoutesInfo.MAIN_DOCK_SPEAKERS) != 0) { + name = com.android.internal.R.string.default_audio_route_name_dock_speakers; + } else if ((newRoutes.mainType&AudioRoutesInfo.MAIN_HDMI) != 0) { + name = com.android.internal.R.string.default_audio_route_name_hdmi; + } else if ((newRoutes.mainType&AudioRoutesInfo.MAIN_USB) != 0) { + name = com.android.internal.R.string.default_audio_route_name_usb; + } else { + name = com.android.internal.R.string.default_audio_route_name; + } + mDefaultAudioVideo.mNameResId = name; + dispatchRouteChanged(mDefaultAudioVideo); + + if ((newRoutes.mainType & (AudioRoutesInfo.MAIN_HEADSET + | AudioRoutesInfo.MAIN_HEADPHONES | AudioRoutesInfo.MAIN_USB)) != 0) { + forceUseDefaultRoute = true; + } + audioRoutesChanged = true; + } + + if (!TextUtils.equals(newRoutes.bluetoothName, mCurAudioRoutesInfo.bluetoothName)) { + forceUseDefaultRoute = false; + mCurAudioRoutesInfo.bluetoothName = newRoutes.bluetoothName; + if (mCurAudioRoutesInfo.bluetoothName != null) { + if (mBluetoothA2dpRoute == null) { + // BT connected + final RouteInfo info = new RouteInfo(mSystemCategory); + info.mName = mCurAudioRoutesInfo.bluetoothName; + info.mDescription = mResources.getText( + com.android.internal.R.string.bluetooth_a2dp_audio_route_name); + info.mSupportedTypes = ROUTE_TYPE_LIVE_AUDIO; + info.mDeviceType = RouteInfo.DEVICE_TYPE_BLUETOOTH; + mBluetoothA2dpRoute = info; + addRouteStatic(mBluetoothA2dpRoute); + } else { + mBluetoothA2dpRoute.mName = mCurAudioRoutesInfo.bluetoothName; + dispatchRouteChanged(mBluetoothA2dpRoute); + } + } else if (mBluetoothA2dpRoute != null) { + // BT disconnected + removeRouteStatic(mBluetoothA2dpRoute); + mBluetoothA2dpRoute = null; + } + audioRoutesChanged = true; + } + + if (audioRoutesChanged) { + Log.v(TAG, "Audio routes updated: " + newRoutes + ", a2dp=" + isBluetoothA2dpOn()); + if (mSelectedRoute == null || mSelectedRoute == mDefaultAudioVideo + || mSelectedRoute == mBluetoothA2dpRoute) { + if (forceUseDefaultRoute || mBluetoothA2dpRoute == null) { + selectRouteStatic(ROUTE_TYPE_LIVE_AUDIO, mDefaultAudioVideo, false); + } else { + selectRouteStatic(ROUTE_TYPE_LIVE_AUDIO, mBluetoothA2dpRoute, false); + } + } + } + } + + boolean isBluetoothA2dpOn() { + try { + return mBluetoothA2dpRoute != null && mAudioService.isBluetoothA2dpOn(); + } catch (RemoteException e) { + Log.e(TAG, "Error querying Bluetooth A2DP state", e); + return false; + } + } + + void updateDiscoveryRequest() { + // What are we looking for today? + int routeTypes = 0; + int passiveRouteTypes = 0; + boolean activeScan = false; + boolean activeScanWifiDisplay = false; + final int count = mCallbacks.size(); + for (int i = 0; i < count; i++) { + CallbackInfo cbi = mCallbacks.get(i); + if ((cbi.flags & (CALLBACK_FLAG_PERFORM_ACTIVE_SCAN + | CALLBACK_FLAG_REQUEST_DISCOVERY)) != 0) { + // Discovery explicitly requested. + routeTypes |= cbi.type; + } else if ((cbi.flags & CALLBACK_FLAG_PASSIVE_DISCOVERY) != 0) { + // Discovery only passively requested. + passiveRouteTypes |= cbi.type; + } else { + // Legacy case since applications don't specify the discovery flag. + // Unfortunately we just have to assume they always need discovery + // whenever they have a callback registered. + routeTypes |= cbi.type; + } + if ((cbi.flags & CALLBACK_FLAG_PERFORM_ACTIVE_SCAN) != 0) { + activeScan = true; + if ((cbi.type & ROUTE_TYPE_REMOTE_DISPLAY) != 0) { + activeScanWifiDisplay = true; + } + } + } + if (routeTypes != 0 || activeScan) { + // If someone else requests discovery then enable the passive listeners. + // This is used by the MediaRouteButton and MediaRouteActionProvider since + // they don't receive lifecycle callbacks from the Activity. + routeTypes |= passiveRouteTypes; + } + + // Update wifi display scanning. + // TODO: All of this should be managed by the media router service. + if (mCanConfigureWifiDisplays) { + if (mSelectedRoute != null + && mSelectedRoute.matchesTypes(ROUTE_TYPE_REMOTE_DISPLAY)) { + // Don't scan while already connected to a remote display since + // it may interfere with the ongoing transmission. + activeScanWifiDisplay = false; + } + if (activeScanWifiDisplay) { + if (!mActivelyScanningWifiDisplays) { + mActivelyScanningWifiDisplays = true; + mDisplayService.startWifiDisplayScan(); + } + } else { + if (mActivelyScanningWifiDisplays) { + mActivelyScanningWifiDisplays = false; + mDisplayService.stopWifiDisplayScan(); + } + } + } + + // Tell the media router service all about it. + if (routeTypes != mDiscoveryRequestRouteTypes + || activeScan != mDiscoverRequestActiveScan) { + mDiscoveryRequestRouteTypes = routeTypes; + mDiscoverRequestActiveScan = activeScan; + publishClientDiscoveryRequest(); + } + } + + @Override + public void onDisplayAdded(int displayId) { + updatePresentationDisplays(displayId); + } + + @Override + public void onDisplayChanged(int displayId) { + updatePresentationDisplays(displayId); + } + + @Override + public void onDisplayRemoved(int displayId) { + updatePresentationDisplays(displayId); + } + + public void setRouterGroupId(String groupId) { + if (mClient != null) { + try { + mMediaRouterService.registerClientGroupId(mClient, groupId); + } catch (RemoteException ex) { + Log.e(TAG, "Unable to register group ID of the client.", ex); + } + } + } + + public Display[] getAllPresentationDisplays() { + return mDisplayService.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION); + } + + private void updatePresentationDisplays(int changedDisplayId) { + final int count = mRoutes.size(); + for (int i = 0; i < count; i++) { + final RouteInfo route = mRoutes.get(i); + if (route.updatePresentationDisplay() || (route.mPresentationDisplay != null + && route.mPresentationDisplay.getDisplayId() == changedDisplayId)) { + dispatchRoutePresentationDisplayChanged(route); + } + } + } + + void updateSelectedRouteForId(String routeId) { + RouteInfo selectedRoute = isBluetoothA2dpOn() + ? mBluetoothA2dpRoute : mDefaultAudioVideo; + final int count = mRoutes.size(); + for (int i = 0; i < count; i++) { + final RouteInfo route = mRoutes.get(i); + if (TextUtils.equals(route.mGlobalRouteId, routeId)) { + selectedRoute = route; + } + } + if (selectedRoute != mSelectedRoute) { + selectRouteStatic(selectedRoute.mSupportedTypes, selectedRoute, false); + } + } + + void setSelectedRoute(RouteInfo info, boolean explicit) { + // Must be non-reentrant. + mSelectedRoute = info; + publishClientSelectedRoute(explicit); + } + + void rebindAsUser(int userId) { + if (mCurrentUserId != userId || userId < 0 || mClient == null) { + if (mClient != null) { + try { + mMediaRouterService.unregisterClient(mClient); + } catch (RemoteException ex) { + Log.e(TAG, "Unable to unregister media router client.", ex); + } + mClient = null; + } + + mCurrentUserId = userId; + + try { + Client client = new Client(); + mMediaRouterService.registerClientAsUser(client, mPackageName, userId); + mClient = client; + } catch (RemoteException ex) { + Log.e(TAG, "Unable to register media router client.", ex); + } + + publishClientDiscoveryRequest(); + publishClientSelectedRoute(false); + updateClientState(); + } + } + + void publishClientDiscoveryRequest() { + if (mClient != null) { + try { + mMediaRouterService.setDiscoveryRequest(mClient, + mDiscoveryRequestRouteTypes, mDiscoverRequestActiveScan); + } catch (RemoteException ex) { + Log.e(TAG, "Unable to publish media router client discovery request.", ex); + } + } + } + + void publishClientSelectedRoute(boolean explicit) { + if (mClient != null) { + try { + mMediaRouterService.setSelectedRoute(mClient, + mSelectedRoute != null ? mSelectedRoute.mGlobalRouteId : null, + explicit); + } catch (RemoteException ex) { + Log.e(TAG, "Unable to publish media router client selected route.", ex); + } + } + } + + void updateClientState() { + // Update the client state. + mClientState = null; + if (mClient != null) { + try { + mClientState = mMediaRouterService.getState(mClient); + } catch (RemoteException ex) { + Log.e(TAG, "Unable to retrieve media router client state.", ex); + } + } + final ArrayList<MediaRouterClientState.RouteInfo> globalRoutes = + mClientState != null ? mClientState.routes : null; + + // Add or update routes. + final int globalRouteCount = globalRoutes != null ? globalRoutes.size() : 0; + for (int i = 0; i < globalRouteCount; i++) { + final MediaRouterClientState.RouteInfo globalRoute = globalRoutes.get(i); + RouteInfo route = findGlobalRoute(globalRoute.id); + if (route == null) { + route = makeGlobalRoute(globalRoute); + addRouteStatic(route); + } else { + updateGlobalRoute(route, globalRoute); + } + } + + // Remove defunct routes. + outer: for (int i = mRoutes.size(); i-- > 0; ) { + final RouteInfo route = mRoutes.get(i); + final String globalRouteId = route.mGlobalRouteId; + if (globalRouteId != null) { + for (int j = 0; j < globalRouteCount; j++) { + MediaRouterClientState.RouteInfo globalRoute = globalRoutes.get(j); + if (globalRouteId.equals(globalRoute.id)) { + continue outer; // found + } + } + // not found + removeRouteStatic(route); + } + } + } + + void requestSetVolume(RouteInfo route, int volume) { + if (route.mGlobalRouteId != null && mClient != null) { + try { + mMediaRouterService.requestSetVolume(mClient, + route.mGlobalRouteId, volume); + } catch (RemoteException ex) { + Log.w(TAG, "Unable to request volume change.", ex); + } + } + } + + void requestUpdateVolume(RouteInfo route, int direction) { + if (route.mGlobalRouteId != null && mClient != null) { + try { + mMediaRouterService.requestUpdateVolume(mClient, + route.mGlobalRouteId, direction); + } catch (RemoteException ex) { + Log.w(TAG, "Unable to request volume change.", ex); + } + } + } + + RouteInfo makeGlobalRoute(MediaRouterClientState.RouteInfo globalRoute) { + RouteInfo route = new RouteInfo(mSystemCategory); + route.mGlobalRouteId = globalRoute.id; + route.mName = globalRoute.name; + route.mDescription = globalRoute.description; + route.mSupportedTypes = globalRoute.supportedTypes; + route.mDeviceType = globalRoute.deviceType; + route.mEnabled = globalRoute.enabled; + route.setRealStatusCode(globalRoute.statusCode); + route.mPlaybackType = globalRoute.playbackType; + route.mPlaybackStream = globalRoute.playbackStream; + route.mVolume = globalRoute.volume; + route.mVolumeMax = globalRoute.volumeMax; + route.mVolumeHandling = globalRoute.volumeHandling; + route.mPresentationDisplayId = globalRoute.presentationDisplayId; + route.updatePresentationDisplay(); + return route; + } + + void updateGlobalRoute(RouteInfo route, MediaRouterClientState.RouteInfo globalRoute) { + boolean changed = false; + boolean volumeChanged = false; + boolean presentationDisplayChanged = false; + + if (!Objects.equals(route.mName, globalRoute.name)) { + route.mName = globalRoute.name; + changed = true; + } + if (!Objects.equals(route.mDescription, globalRoute.description)) { + route.mDescription = globalRoute.description; + changed = true; + } + final int oldSupportedTypes = route.mSupportedTypes; + if (oldSupportedTypes != globalRoute.supportedTypes) { + route.mSupportedTypes = globalRoute.supportedTypes; + changed = true; + } + if (route.mEnabled != globalRoute.enabled) { + route.mEnabled = globalRoute.enabled; + changed = true; + } + if (route.mRealStatusCode != globalRoute.statusCode) { + route.setRealStatusCode(globalRoute.statusCode); + changed = true; + } + if (route.mPlaybackType != globalRoute.playbackType) { + route.mPlaybackType = globalRoute.playbackType; + changed = true; + } + if (route.mPlaybackStream != globalRoute.playbackStream) { + route.mPlaybackStream = globalRoute.playbackStream; + changed = true; + } + if (route.mVolume != globalRoute.volume) { + route.mVolume = globalRoute.volume; + changed = true; + volumeChanged = true; + } + if (route.mVolumeMax != globalRoute.volumeMax) { + route.mVolumeMax = globalRoute.volumeMax; + changed = true; + volumeChanged = true; + } + if (route.mVolumeHandling != globalRoute.volumeHandling) { + route.mVolumeHandling = globalRoute.volumeHandling; + changed = true; + volumeChanged = true; + } + if (route.mPresentationDisplayId != globalRoute.presentationDisplayId) { + route.mPresentationDisplayId = globalRoute.presentationDisplayId; + route.updatePresentationDisplay(); + changed = true; + presentationDisplayChanged = true; + } + + if (changed) { + dispatchRouteChanged(route, oldSupportedTypes); + } + if (volumeChanged) { + dispatchRouteVolumeChanged(route); + } + if (presentationDisplayChanged) { + dispatchRoutePresentationDisplayChanged(route); + } + } + + RouteInfo findGlobalRoute(String globalRouteId) { + final int count = mRoutes.size(); + for (int i = 0; i < count; i++) { + final RouteInfo route = mRoutes.get(i); + if (globalRouteId.equals(route.mGlobalRouteId)) { + return route; + } + } + return null; + } + + boolean isPlaybackActive() { + if (mClient != null) { + try { + return mMediaRouterService.isPlaybackActive(mClient); + } catch (RemoteException ex) { + Log.e(TAG, "Unable to retrieve playback active state.", ex); + } + } + return false; + } + + final class Client extends IMediaRouterClient.Stub { + @Override + public void onStateChanged() { + mHandler.post(new Runnable() { + @Override + public void run() { + if (Client.this == mClient) { + updateClientState(); + } + } + }); + } + + @Override + public void onRestoreRoute() { + mHandler.post(new Runnable() { + @Override + public void run() { + // Skip restoring route if the selected route is not a system audio route, + // MediaRouter is initializing, or mClient was changed. + if (Client.this != mClient || mSelectedRoute == null + || (mSelectedRoute != mDefaultAudioVideo + && mSelectedRoute != mBluetoothA2dpRoute)) { + return; + } + if (DEBUG) { + Log.d(TAG, "onRestoreRoute() : route=" + mSelectedRoute); + } + mSelectedRoute.select(); + } + }); + } + + @Override + public void onSelectedRouteChanged(String routeId) { + mHandler.post(() -> { + if (Client.this == mClient) { + updateSelectedRouteForId(routeId); + } + }); + } + } + } + + static Static sStatic; + + /** + * Route type flag for live audio. + * + * <p>A device that supports live audio routing will allow the media audio stream + * to be routed to supported destinations. This can include internal speakers or + * audio jacks on the device itself, A2DP devices, and more.</p> + * + * <p>Once initiated this routing is transparent to the application. All audio + * played on the media stream will be routed to the selected destination.</p> + */ + public static final int ROUTE_TYPE_LIVE_AUDIO = 1 << 0; + + /** + * Route type flag for live video. + * + * <p>A device that supports live video routing will allow a mirrored version + * of the device's primary display or a customized + * {@link android.app.Presentation Presentation} to be routed to supported destinations.</p> + * + * <p>Once initiated, display mirroring is transparent to the application. + * While remote routing is active the application may use a + * {@link android.app.Presentation Presentation} to replace the mirrored view + * on the external display with different content.</p> + * + * @see RouteInfo#getPresentationDisplay() + * @see android.app.Presentation + */ + public static final int ROUTE_TYPE_LIVE_VIDEO = 1 << 1; + + /** + * Temporary interop constant to identify remote displays. + * @hide To be removed when media router API is updated. + */ + public static final int ROUTE_TYPE_REMOTE_DISPLAY = 1 << 2; + + /** + * Route type flag for application-specific usage. + * + * <p>Unlike other media route types, user routes are managed by the application. + * The MediaRouter will manage and dispatch events for user routes, but the application + * is expected to interpret the meaning of these events and perform the requested + * routing tasks.</p> + */ + public static final int ROUTE_TYPE_USER = 1 << 23; + + static final int ROUTE_TYPE_ANY = ROUTE_TYPE_LIVE_AUDIO | ROUTE_TYPE_LIVE_VIDEO + | ROUTE_TYPE_REMOTE_DISPLAY | ROUTE_TYPE_USER; + + /** + * Flag for {@link #addCallback}: Actively scan for routes while this callback + * is registered. + * <p> + * When this flag is specified, the media router will actively scan for new + * routes. Certain routes, such as wifi display routes, may not be discoverable + * except when actively scanning. This flag is typically used when the route picker + * dialog has been opened by the user to ensure that the route information is + * up to date. + * </p><p> + * Active scanning may consume a significant amount of power and may have intrusive + * effects on wireless connectivity. Therefore it is important that active scanning + * only be requested when it is actually needed to satisfy a user request to + * discover and select a new route. + * </p> + */ + public static final int CALLBACK_FLAG_PERFORM_ACTIVE_SCAN = 1 << 0; + + /** + * Flag for {@link #addCallback}: Do not filter route events. + * <p> + * When this flag is specified, the callback will be invoked for event that affect any + * route even if they do not match the callback's filter. + * </p> + */ + public static final int CALLBACK_FLAG_UNFILTERED_EVENTS = 1 << 1; + + /** + * Explicitly requests discovery. + * + * @hide Future API ported from support library. Revisit this later. + */ + public static final int CALLBACK_FLAG_REQUEST_DISCOVERY = 1 << 2; + + /** + * Requests that discovery be performed but only if there is some other active + * callback already registered. + * + * @hide Compatibility workaround for the fact that applications do not currently + * request discovery explicitly (except when using the support library API). + */ + public static final int CALLBACK_FLAG_PASSIVE_DISCOVERY = 1 << 3; + + /** + * Flag for {@link #isRouteAvailable}: Ignore the default route. + * <p> + * This flag is used to determine whether a matching non-default route is available. + * This constraint may be used to decide whether to offer the route chooser dialog + * to the user. There is no point offering the chooser if there are no + * non-default choices. + * </p> + * + * @hide Future API ported from support library. Revisit this later. + */ + public static final int AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE = 1 << 0; + + /** + * The route group id used for sharing the selected mirroring device. + * System UI and Settings use this to synchronize their mirroring status. + * @hide + */ + public static final String MIRRORING_GROUP_ID = "android.media.mirroring_group"; + + // Maps application contexts + static final HashMap<Context, MediaRouter> sRouters = new HashMap<Context, MediaRouter>(); + + static String typesToString(int types) { + final StringBuilder result = new StringBuilder(); + if ((types & ROUTE_TYPE_LIVE_AUDIO) != 0) { + result.append("ROUTE_TYPE_LIVE_AUDIO "); + } + if ((types & ROUTE_TYPE_LIVE_VIDEO) != 0) { + result.append("ROUTE_TYPE_LIVE_VIDEO "); + } + if ((types & ROUTE_TYPE_REMOTE_DISPLAY) != 0) { + result.append("ROUTE_TYPE_REMOTE_DISPLAY "); + } + if ((types & ROUTE_TYPE_USER) != 0) { + result.append("ROUTE_TYPE_USER "); + } + return result.toString(); + } + + /** @hide */ + public MediaRouter(Context context) { + synchronized (Static.class) { + if (sStatic == null) { + final Context appContext = context.getApplicationContext(); + sStatic = new Static(appContext); + sStatic.startMonitoringRoutes(appContext); + } + } + } + + /** + * Gets the default route for playing media content on the system. + * <p> + * The system always provides a default route. + * </p> + * + * @return The default route, which is guaranteed to never be null. + */ + public RouteInfo getDefaultRoute() { + return sStatic.mDefaultAudioVideo; + } + + /** + * Returns a Bluetooth route if available, otherwise the default route. + * @hide + */ + public RouteInfo getFallbackRoute() { + return (sStatic.mBluetoothA2dpRoute != null) + ? sStatic.mBluetoothA2dpRoute : sStatic.mDefaultAudioVideo; + } + + /** + * @hide for use by framework routing UI + */ + public RouteCategory getSystemCategory() { + return sStatic.mSystemCategory; + } + + /** @hide */ + @UnsupportedAppUsage + public RouteInfo getSelectedRoute() { + return getSelectedRoute(ROUTE_TYPE_ANY); + } + + /** + * Return the currently selected route for any of the given types + * + * @param type route types + * @return the selected route + */ + public RouteInfo getSelectedRoute(int type) { + if (sStatic.mSelectedRoute != null && + (sStatic.mSelectedRoute.mSupportedTypes & type) != 0) { + // If the selected route supports any of the types supplied, it's still considered + // 'selected' for that type. + return sStatic.mSelectedRoute; + } else if (type == ROUTE_TYPE_USER) { + // The caller specifically asked for a user route and the currently selected route + // doesn't qualify. + return null; + } + // If the above didn't match and we're not specifically asking for a user route, + // consider the default selected. + return sStatic.mDefaultAudioVideo; + } + + /** + * Returns true if there is a route that matches the specified types. + * <p> + * This method returns true if there are any available routes that match the types + * regardless of whether they are enabled or disabled. If the + * {@link #AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE} flag is specified, then + * the method will only consider non-default routes. + * </p> + * + * @param types The types to match. + * @param flags Flags to control the determination of whether a route may be available. + * May be zero or {@link #AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE}. + * @return True if a matching route may be available. + * + * @hide Future API ported from support library. Revisit this later. + */ + public boolean isRouteAvailable(int types, int flags) { + final int count = sStatic.mRoutes.size(); + for (int i = 0; i < count; i++) { + RouteInfo route = sStatic.mRoutes.get(i); + if (route.matchesTypes(types)) { + if ((flags & AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE) == 0 + || route != sStatic.mDefaultAudioVideo) { + return true; + } + } + } + + // It doesn't look like we can find a matching route right now. + return false; + } + + /** + * Sets the group ID of the router. + * Media routers with the same ID acts as if they were a single media router. + * For example, if a media router selects a route, the selected route of routers + * with the same group ID will be changed automatically. + * + * Two routers in a group are supposed to use the same route types. + * + * System UI and Settings use this to synchronize their mirroring status. + * Do not set the router group id unless it's necessary. + * + * {@link android.Manifest.permission#CONFIGURE_WIFI_DISPLAY} permission is required to + * call this method. + * @hide + */ + public void setRouterGroupId(@Nullable String groupId) { + sStatic.setRouterGroupId(groupId); + } + + /** + * Add a callback to listen to events about specific kinds of media routes. + * If the specified callback is already registered, its registration will be updated for any + * additional route types specified. + * <p> + * This is a convenience method that has the same effect as calling + * {@link #addCallback(int, Callback, int)} without flags. + * </p> + * + * @param types Types of routes this callback is interested in + * @param cb Callback to add + */ + public void addCallback(int types, Callback cb) { + addCallback(types, cb, 0); + } + + /** + * Add a callback to listen to events about specific kinds of media routes. + * If the specified callback is already registered, its registration will be updated for any + * additional route types specified. + * <p> + * By default, the callback will only be invoked for events that affect routes + * that match the specified selector. The filtering may be disabled by specifying + * the {@link #CALLBACK_FLAG_UNFILTERED_EVENTS} flag. + * </p> + * + * @param types Types of routes this callback is interested in + * @param cb Callback to add + * @param flags Flags to control the behavior of the callback. + * May be zero or a combination of {@link #CALLBACK_FLAG_PERFORM_ACTIVE_SCAN} and + * {@link #CALLBACK_FLAG_UNFILTERED_EVENTS}. + */ + public void addCallback(int types, Callback cb, int flags) { + CallbackInfo info; + int index = findCallbackInfo(cb); + if (index >= 0) { + info = sStatic.mCallbacks.get(index); + info.type |= types; + info.flags |= flags; + } else { + info = new CallbackInfo(cb, types, flags, this); + sStatic.mCallbacks.add(info); + } + sStatic.updateDiscoveryRequest(); + } + + /** + * Remove the specified callback. It will no longer receive events about media routing. + * + * @param cb Callback to remove + */ + public void removeCallback(Callback cb) { + int index = findCallbackInfo(cb); + if (index >= 0) { + sStatic.mCallbacks.remove(index); + sStatic.updateDiscoveryRequest(); + } else { + Log.w(TAG, "removeCallback(" + cb + "): callback not registered"); + } + } + + private int findCallbackInfo(Callback cb) { + final int count = sStatic.mCallbacks.size(); + for (int i = 0; i < count; i++) { + final CallbackInfo info = sStatic.mCallbacks.get(i); + if (info.cb == cb) { + return i; + } + } + return -1; + } + + /** + * Select the specified route to use for output of the given media types. + * <p class="note"> + * As API version 18, this function may be used to select any route. + * In prior versions, this function could only be used to select user + * routes and would ignore any attempt to select a system route. + * </p> + * + * @param types type flags indicating which types this route should be used for. + * The route must support at least a subset. + * @param route Route to select + * @throws IllegalArgumentException if the given route is {@code null} + */ + public void selectRoute(int types, @NonNull RouteInfo route) { + if (route == null) { + throw new IllegalArgumentException("Route cannot be null."); + } + selectRouteStatic(types, route, true); + } + + /** + * @hide internal use + */ + @UnsupportedAppUsage + public void selectRouteInt(int types, RouteInfo route, boolean explicit) { + selectRouteStatic(types, route, explicit); + } + + static void selectRouteStatic(int types, @NonNull RouteInfo route, boolean explicit) { + Log.v(TAG, "Selecting route: " + route); + assert(route != null); + final RouteInfo oldRoute = sStatic.mSelectedRoute; + final RouteInfo currentSystemRoute = sStatic.isBluetoothA2dpOn() + ? sStatic.mBluetoothA2dpRoute : sStatic.mDefaultAudioVideo; + boolean wasDefaultOrBluetoothRoute = (oldRoute == sStatic.mDefaultAudioVideo + || oldRoute == sStatic.mBluetoothA2dpRoute); + if (oldRoute == route + && (!wasDefaultOrBluetoothRoute || route == currentSystemRoute)) { + return; + } + if (!route.matchesTypes(types)) { + Log.w(TAG, "selectRoute ignored; cannot select route with supported types " + + typesToString(route.getSupportedTypes()) + " into route types " + + typesToString(types)); + return; + } + + final RouteInfo btRoute = sStatic.mBluetoothA2dpRoute; + if (sStatic.isPlaybackActive() && btRoute != null && (types & ROUTE_TYPE_LIVE_AUDIO) != 0 + && (route == btRoute || route == sStatic.mDefaultAudioVideo)) { + try { + sStatic.mAudioService.setBluetoothA2dpOn(route == btRoute); + } catch (RemoteException e) { + Log.e(TAG, "Error changing Bluetooth A2DP state", e); + } + } + + final WifiDisplay activeDisplay = + sStatic.mDisplayService.getWifiDisplayStatus().getActiveDisplay(); + final boolean oldRouteHasAddress = oldRoute != null && oldRoute.mDeviceAddress != null; + final boolean newRouteHasAddress = route.mDeviceAddress != null; + if (activeDisplay != null || oldRouteHasAddress || newRouteHasAddress) { + if (newRouteHasAddress && !matchesDeviceAddress(activeDisplay, route)) { + if (sStatic.mCanConfigureWifiDisplays) { + sStatic.mDisplayService.connectWifiDisplay(route.mDeviceAddress); + } else { + Log.e(TAG, "Cannot connect to wifi displays because this process " + + "is not allowed to do so."); + } + } else if (activeDisplay != null && !newRouteHasAddress) { + sStatic.mDisplayService.disconnectWifiDisplay(); + } + } + + sStatic.setSelectedRoute(route, explicit); + + if (oldRoute != null) { + dispatchRouteUnselected(types & oldRoute.getSupportedTypes(), oldRoute); + if (oldRoute.resolveStatusCode()) { + dispatchRouteChanged(oldRoute); + } + } + if (route != null) { + if (route.resolveStatusCode()) { + dispatchRouteChanged(route); + } + dispatchRouteSelected(types & route.getSupportedTypes(), route); + } + + // The behavior of active scans may depend on the currently selected route. + sStatic.updateDiscoveryRequest(); + } + + static void selectDefaultRouteStatic() { + // TODO: Be smarter about the route types here; this selects for all valid. + if (sStatic.mSelectedRoute != sStatic.mBluetoothA2dpRoute && sStatic.isBluetoothA2dpOn()) { + selectRouteStatic(ROUTE_TYPE_ANY, sStatic.mBluetoothA2dpRoute, false); + } else { + selectRouteStatic(ROUTE_TYPE_ANY, sStatic.mDefaultAudioVideo, false); + } + } + + /** + * Compare the device address of a display and a route. + * Nulls/no device address will match another null/no address. + */ + static boolean matchesDeviceAddress(WifiDisplay display, RouteInfo info) { + final boolean routeHasAddress = info != null && info.mDeviceAddress != null; + if (display == null && !routeHasAddress) { + return true; + } + + if (display != null && routeHasAddress) { + return display.getDeviceAddress().equals(info.mDeviceAddress); + } + return false; + } + + /** + * Add an app-specified route for media to the MediaRouter. + * App-specified route definitions are created using {@link #createUserRoute(RouteCategory)} + * + * @param info Definition of the route to add + * @see #createUserRoute(RouteCategory) + * @see #removeUserRoute(UserRouteInfo) + */ + public void addUserRoute(UserRouteInfo info) { + addRouteStatic(info); + } + + /** + * @hide Framework use only + */ + public void addRouteInt(RouteInfo info) { + addRouteStatic(info); + } + + static void addRouteStatic(RouteInfo info) { + if (DEBUG) { + Log.d(TAG, "Adding route: " + info); + } + final RouteCategory cat = info.getCategory(); + if (!sStatic.mCategories.contains(cat)) { + sStatic.mCategories.add(cat); + } + if (cat.isGroupable() && !(info instanceof RouteGroup)) { + // Enforce that any added route in a groupable category must be in a group. + final RouteGroup group = new RouteGroup(info.getCategory()); + group.mSupportedTypes = info.mSupportedTypes; + sStatic.mRoutes.add(group); + dispatchRouteAdded(group); + group.addRoute(info); + + info = group; + } else { + sStatic.mRoutes.add(info); + dispatchRouteAdded(info); + } + } + + /** + * Remove an app-specified route for media from the MediaRouter. + * + * @param info Definition of the route to remove + * @see #addUserRoute(UserRouteInfo) + */ + public void removeUserRoute(UserRouteInfo info) { + removeRouteStatic(info); + } + + /** + * Remove all app-specified routes from the MediaRouter. + * + * @see #removeUserRoute(UserRouteInfo) + */ + public void clearUserRoutes() { + for (int i = 0; i < sStatic.mRoutes.size(); i++) { + final RouteInfo info = sStatic.mRoutes.get(i); + // TODO Right now, RouteGroups only ever contain user routes. + // The code below will need to change if this assumption does. + if (info instanceof UserRouteInfo || info instanceof RouteGroup) { + removeRouteStatic(info); + i--; + } + } + } + + /** + * @hide internal use only + */ + public void removeRouteInt(RouteInfo info) { + removeRouteStatic(info); + } + + static void removeRouteStatic(RouteInfo info) { + if (DEBUG) { + Log.d(TAG, "Removing route: " + info); + } + if (sStatic.mRoutes.remove(info)) { + final RouteCategory removingCat = info.getCategory(); + final int count = sStatic.mRoutes.size(); + boolean found = false; + for (int i = 0; i < count; i++) { + final RouteCategory cat = sStatic.mRoutes.get(i).getCategory(); + if (removingCat == cat) { + found = true; + break; + } + } + if (info.isSelected()) { + // Removing the currently selected route? Select the default before we remove it. + selectDefaultRouteStatic(); + } + if (!found) { + sStatic.mCategories.remove(removingCat); + } + dispatchRouteRemoved(info); + } + } + + /** + * Return the number of {@link MediaRouter.RouteCategory categories} currently + * represented by routes known to this MediaRouter. + * + * @return the number of unique categories represented by this MediaRouter's known routes + */ + public int getCategoryCount() { + return sStatic.mCategories.size(); + } + + /** + * Return the {@link MediaRouter.RouteCategory category} at the given index. + * Valid indices are in the range [0-getCategoryCount). + * + * @param index which category to return + * @return the category at index + */ + public RouteCategory getCategoryAt(int index) { + return sStatic.mCategories.get(index); + } + + /** + * Return the number of {@link MediaRouter.RouteInfo routes} currently known + * to this MediaRouter. + * + * @return the number of routes tracked by this router + */ + public int getRouteCount() { + return sStatic.mRoutes.size(); + } + + /** + * Return the route at the specified index. + * + * @param index index of the route to return + * @return the route at index + */ + public RouteInfo getRouteAt(int index) { + return sStatic.mRoutes.get(index); + } + + static int getRouteCountStatic() { + return sStatic.mRoutes.size(); + } + + static RouteInfo getRouteAtStatic(int index) { + return sStatic.mRoutes.get(index); + } + + /** + * Create a new user route that may be modified and registered for use by the application. + * + * @param category The category the new route will belong to + * @return A new UserRouteInfo for use by the application + * + * @see #addUserRoute(UserRouteInfo) + * @see #removeUserRoute(UserRouteInfo) + * @see #createRouteCategory(CharSequence, boolean) + */ + public UserRouteInfo createUserRoute(RouteCategory category) { + return new UserRouteInfo(category); + } + + /** + * Create a new route category. Each route must belong to a category. + * + * @param name Name of the new category + * @param isGroupable true if routes in this category may be grouped with one another + * @return the new RouteCategory + */ + public RouteCategory createRouteCategory(CharSequence name, boolean isGroupable) { + return new RouteCategory(name, ROUTE_TYPE_USER, isGroupable); + } + + /** + * Create a new route category. Each route must belong to a category. + * + * @param nameResId Resource ID of the name of the new category + * @param isGroupable true if routes in this category may be grouped with one another + * @return the new RouteCategory + */ + public RouteCategory createRouteCategory(int nameResId, boolean isGroupable) { + return new RouteCategory(nameResId, ROUTE_TYPE_USER, isGroupable); + } + + /** + * Rebinds the media router to handle routes that belong to the specified user. + * Requires the interact across users permission to access the routes of another user. + * <p> + * This method is a complete hack to work around the singleton nature of the + * media router when running inside of singleton processes like QuickSettings. + * This mechanism should be burned to the ground when MediaRouter is redesigned. + * Ideally the current user would be pulled from the Context but we need to break + * down MediaRouter.Static before we can get there. + * </p> + * + * @hide + */ + public void rebindAsUser(int userId) { + sStatic.rebindAsUser(userId); + } + + static void updateRoute(final RouteInfo info) { + dispatchRouteChanged(info); + } + + static void dispatchRouteSelected(int type, RouteInfo info) { + for (CallbackInfo cbi : sStatic.mCallbacks) { + if (cbi.filterRouteEvent(info)) { + cbi.cb.onRouteSelected(cbi.router, type, info); + } + } + } + + static void dispatchRouteUnselected(int type, RouteInfo info) { + for (CallbackInfo cbi : sStatic.mCallbacks) { + if (cbi.filterRouteEvent(info)) { + cbi.cb.onRouteUnselected(cbi.router, type, info); + } + } + } + + static void dispatchRouteChanged(RouteInfo info) { + dispatchRouteChanged(info, info.mSupportedTypes); + } + + static void dispatchRouteChanged(RouteInfo info, int oldSupportedTypes) { + if (DEBUG) { + Log.d(TAG, "Dispatching route change: " + info); + } + final int newSupportedTypes = info.mSupportedTypes; + for (CallbackInfo cbi : sStatic.mCallbacks) { + // Reconstruct some of the history for callbacks that may not have observed + // all of the events needed to correctly interpret the current state. + // FIXME: This is a strong signal that we should deprecate route type filtering + // completely in the future because it can lead to inconsistencies in + // applications. + final boolean oldVisibility = cbi.filterRouteEvent(oldSupportedTypes); + final boolean newVisibility = cbi.filterRouteEvent(newSupportedTypes); + if (!oldVisibility && newVisibility) { + cbi.cb.onRouteAdded(cbi.router, info); + if (info.isSelected()) { + cbi.cb.onRouteSelected(cbi.router, newSupportedTypes, info); + } + } + if (oldVisibility || newVisibility) { + cbi.cb.onRouteChanged(cbi.router, info); + } + if (oldVisibility && !newVisibility) { + if (info.isSelected()) { + cbi.cb.onRouteUnselected(cbi.router, oldSupportedTypes, info); + } + cbi.cb.onRouteRemoved(cbi.router, info); + } + } + } + + static void dispatchRouteAdded(RouteInfo info) { + for (CallbackInfo cbi : sStatic.mCallbacks) { + if (cbi.filterRouteEvent(info)) { + cbi.cb.onRouteAdded(cbi.router, info); + } + } + } + + static void dispatchRouteRemoved(RouteInfo info) { + for (CallbackInfo cbi : sStatic.mCallbacks) { + if (cbi.filterRouteEvent(info)) { + cbi.cb.onRouteRemoved(cbi.router, info); + } + } + } + + static void dispatchRouteGrouped(RouteInfo info, RouteGroup group, int index) { + for (CallbackInfo cbi : sStatic.mCallbacks) { + if (cbi.filterRouteEvent(group)) { + cbi.cb.onRouteGrouped(cbi.router, info, group, index); + } + } + } + + static void dispatchRouteUngrouped(RouteInfo info, RouteGroup group) { + for (CallbackInfo cbi : sStatic.mCallbacks) { + if (cbi.filterRouteEvent(group)) { + cbi.cb.onRouteUngrouped(cbi.router, info, group); + } + } + } + + static void dispatchRouteVolumeChanged(RouteInfo info) { + for (CallbackInfo cbi : sStatic.mCallbacks) { + if (cbi.filterRouteEvent(info)) { + cbi.cb.onRouteVolumeChanged(cbi.router, info); + } + } + } + + static void dispatchRoutePresentationDisplayChanged(RouteInfo info) { + for (CallbackInfo cbi : sStatic.mCallbacks) { + if (cbi.filterRouteEvent(info)) { + cbi.cb.onRoutePresentationDisplayChanged(cbi.router, info); + } + } + } + + static void systemVolumeChanged(int newValue) { + final RouteInfo selectedRoute = sStatic.mSelectedRoute; + if (selectedRoute == null) return; + + if (selectedRoute == sStatic.mBluetoothA2dpRoute || + selectedRoute == sStatic.mDefaultAudioVideo) { + dispatchRouteVolumeChanged(selectedRoute); + } else if (sStatic.mBluetoothA2dpRoute != null) { + try { + dispatchRouteVolumeChanged(sStatic.mAudioService.isBluetoothA2dpOn() ? + sStatic.mBluetoothA2dpRoute : sStatic.mDefaultAudioVideo); + } catch (RemoteException e) { + Log.e(TAG, "Error checking Bluetooth A2DP state to report volume change", e); + } + } else { + dispatchRouteVolumeChanged(sStatic.mDefaultAudioVideo); + } + } + + static void updateWifiDisplayStatus(WifiDisplayStatus status) { + WifiDisplay[] displays; + WifiDisplay activeDisplay; + if (status.getFeatureState() == WifiDisplayStatus.FEATURE_STATE_ON) { + displays = status.getDisplays(); + activeDisplay = status.getActiveDisplay(); + + // Only the system is able to connect to wifi display routes. + // The display manager will enforce this with a permission check but it + // still publishes information about all available displays. + // Filter the list down to just the active display. + if (!sStatic.mCanConfigureWifiDisplays) { + if (activeDisplay != null) { + displays = new WifiDisplay[] { activeDisplay }; + } else { + displays = WifiDisplay.EMPTY_ARRAY; + } + } + } else { + displays = WifiDisplay.EMPTY_ARRAY; + activeDisplay = null; + } + String activeDisplayAddress = activeDisplay != null ? + activeDisplay.getDeviceAddress() : null; + + // Add or update routes. + for (int i = 0; i < displays.length; i++) { + final WifiDisplay d = displays[i]; + if (shouldShowWifiDisplay(d, activeDisplay)) { + RouteInfo route = findWifiDisplayRoute(d); + if (route == null) { + route = makeWifiDisplayRoute(d, status); + addRouteStatic(route); + } else { + String address = d.getDeviceAddress(); + boolean disconnected = !address.equals(activeDisplayAddress) + && address.equals(sStatic.mPreviousActiveWifiDisplayAddress); + updateWifiDisplayRoute(route, d, status, disconnected); + } + if (d.equals(activeDisplay)) { + selectRouteStatic(route.getSupportedTypes(), route, false); + } + } + } + + // Remove stale routes. + for (int i = sStatic.mRoutes.size(); i-- > 0; ) { + RouteInfo route = sStatic.mRoutes.get(i); + if (route.mDeviceAddress != null) { + WifiDisplay d = findWifiDisplay(displays, route.mDeviceAddress); + if (d == null || !shouldShowWifiDisplay(d, activeDisplay)) { + removeRouteStatic(route); + } + } + } + + // Remember the current active wifi display address so that we can infer disconnections. + // TODO: This hack will go away once all of this is moved into the media router service. + sStatic.mPreviousActiveWifiDisplayAddress = activeDisplayAddress; + } + + private static boolean shouldShowWifiDisplay(WifiDisplay d, WifiDisplay activeDisplay) { + return d.isRemembered() || d.equals(activeDisplay); + } + + static int getWifiDisplayStatusCode(WifiDisplay d, WifiDisplayStatus wfdStatus) { + int newStatus; + if (wfdStatus.getScanState() == WifiDisplayStatus.SCAN_STATE_SCANNING) { + newStatus = RouteInfo.STATUS_SCANNING; + } else if (d.isAvailable()) { + newStatus = d.canConnect() ? + RouteInfo.STATUS_AVAILABLE: RouteInfo.STATUS_IN_USE; + } else { + newStatus = RouteInfo.STATUS_NOT_AVAILABLE; + } + + if (d.equals(wfdStatus.getActiveDisplay())) { + final int activeState = wfdStatus.getActiveDisplayState(); + switch (activeState) { + case WifiDisplayStatus.DISPLAY_STATE_CONNECTED: + newStatus = RouteInfo.STATUS_CONNECTED; + break; + case WifiDisplayStatus.DISPLAY_STATE_CONNECTING: + newStatus = RouteInfo.STATUS_CONNECTING; + break; + case WifiDisplayStatus.DISPLAY_STATE_NOT_CONNECTED: + Log.e(TAG, "Active display is not connected!"); + break; + } + } + + return newStatus; + } + + static boolean isWifiDisplayEnabled(WifiDisplay d, WifiDisplayStatus wfdStatus) { + return d.isAvailable() && (d.canConnect() || d.equals(wfdStatus.getActiveDisplay())); + } + + static RouteInfo makeWifiDisplayRoute(WifiDisplay display, WifiDisplayStatus wfdStatus) { + final RouteInfo newRoute = new RouteInfo(sStatic.mSystemCategory); + newRoute.mDeviceAddress = display.getDeviceAddress(); + newRoute.mSupportedTypes = ROUTE_TYPE_LIVE_AUDIO | ROUTE_TYPE_LIVE_VIDEO + | ROUTE_TYPE_REMOTE_DISPLAY; + newRoute.mVolumeHandling = RouteInfo.PLAYBACK_VOLUME_FIXED; + newRoute.mPlaybackType = RouteInfo.PLAYBACK_TYPE_REMOTE; + + newRoute.setRealStatusCode(getWifiDisplayStatusCode(display, wfdStatus)); + newRoute.mEnabled = isWifiDisplayEnabled(display, wfdStatus); + newRoute.mName = display.getFriendlyDisplayName(); + newRoute.mDescription = sStatic.mResources.getText( + com.android.internal.R.string.wireless_display_route_description); + newRoute.updatePresentationDisplay(); + newRoute.mDeviceType = RouteInfo.DEVICE_TYPE_TV; + return newRoute; + } + + private static void updateWifiDisplayRoute( + RouteInfo route, WifiDisplay display, WifiDisplayStatus wfdStatus, + boolean disconnected) { + boolean changed = false; + final String newName = display.getFriendlyDisplayName(); + if (!route.getName().equals(newName)) { + route.mName = newName; + changed = true; + } + + boolean enabled = isWifiDisplayEnabled(display, wfdStatus); + changed |= route.mEnabled != enabled; + route.mEnabled = enabled; + + changed |= route.setRealStatusCode(getWifiDisplayStatusCode(display, wfdStatus)); + + if (changed) { + dispatchRouteChanged(route); + } + + if ((!enabled || disconnected) && route.isSelected()) { + // Oops, no longer available. Reselect the default. + selectDefaultRouteStatic(); + } + } + + private static WifiDisplay findWifiDisplay(WifiDisplay[] displays, String deviceAddress) { + for (int i = 0; i < displays.length; i++) { + final WifiDisplay d = displays[i]; + if (d.getDeviceAddress().equals(deviceAddress)) { + return d; + } + } + return null; + } + + private static RouteInfo findWifiDisplayRoute(WifiDisplay d) { + final int count = sStatic.mRoutes.size(); + for (int i = 0; i < count; i++) { + final RouteInfo info = sStatic.mRoutes.get(i); + if (d.getDeviceAddress().equals(info.mDeviceAddress)) { + return info; + } + } + return null; + } + + /** + * Information about a media route. + */ + public static class RouteInfo { + CharSequence mName; + @UnsupportedAppUsage + int mNameResId; + CharSequence mDescription; + private CharSequence mStatus; + int mSupportedTypes; + int mDeviceType; + RouteGroup mGroup; + final RouteCategory mCategory; + Drawable mIcon; + // playback information + int mPlaybackType = PLAYBACK_TYPE_LOCAL; + int mVolumeMax = RemoteControlClient.DEFAULT_PLAYBACK_VOLUME; + int mVolume = RemoteControlClient.DEFAULT_PLAYBACK_VOLUME; + int mVolumeHandling = RemoteControlClient.DEFAULT_PLAYBACK_VOLUME_HANDLING; + int mPlaybackStream = AudioManager.STREAM_MUSIC; + VolumeCallbackInfo mVcb; + Display mPresentationDisplay; + int mPresentationDisplayId = -1; + + String mDeviceAddress; + boolean mEnabled = true; + + // An id by which the route is known to the media router service. + // Null if this route only exists as an artifact within this process. + String mGlobalRouteId; + + // A predetermined connection status that can override mStatus + private int mRealStatusCode; + private int mResolvedStatusCode; + + /** @hide */ public static final int STATUS_NONE = 0; + /** @hide */ public static final int STATUS_SCANNING = 1; + /** @hide */ + @UnsupportedAppUsage + public static final int STATUS_CONNECTING = 2; + /** @hide */ public static final int STATUS_AVAILABLE = 3; + /** @hide */ public static final int STATUS_NOT_AVAILABLE = 4; + /** @hide */ public static final int STATUS_IN_USE = 5; + /** @hide */ public static final int STATUS_CONNECTED = 6; + + /** @hide */ + @IntDef({DEVICE_TYPE_UNKNOWN, DEVICE_TYPE_TV, DEVICE_TYPE_SPEAKER, DEVICE_TYPE_BLUETOOTH}) + @Retention(RetentionPolicy.SOURCE) + public @interface DeviceType {} + + /** + * The default receiver device type of the route indicating the type is unknown. + * + * @see #getDeviceType + */ + public static final int DEVICE_TYPE_UNKNOWN = 0; + + /** + * A receiver device type of the route indicating the presentation of the media is happening + * on a TV. + * + * @see #getDeviceType + */ + public static final int DEVICE_TYPE_TV = 1; + + /** + * A receiver device type of the route indicating the presentation of the media is happening + * on a speaker. + * + * @see #getDeviceType + */ + public static final int DEVICE_TYPE_SPEAKER = 2; + + /** + * A receiver device type of the route indicating the presentation of the media is happening + * on a bluetooth device such as a bluetooth speaker. + * + * @see #getDeviceType + */ + public static final int DEVICE_TYPE_BLUETOOTH = 3; + + private Object mTag; + + /** @hide */ + @IntDef({PLAYBACK_TYPE_LOCAL, PLAYBACK_TYPE_REMOTE}) + @Retention(RetentionPolicy.SOURCE) + public @interface PlaybackType {} + + /** + * The default playback type, "local", indicating the presentation of the media is happening + * on the same device (e.g. a phone, a tablet) as where it is controlled from. + * @see #getPlaybackType() + */ + public final static int PLAYBACK_TYPE_LOCAL = 0; + + /** + * A playback type indicating the presentation of the media is happening on + * a different device (i.e. the remote device) than where it is controlled from. + * @see #getPlaybackType() + */ + public final static int PLAYBACK_TYPE_REMOTE = 1; + + /** @hide */ + @IntDef({PLAYBACK_VOLUME_FIXED,PLAYBACK_VOLUME_VARIABLE}) + @Retention(RetentionPolicy.SOURCE) + private @interface PlaybackVolume {} + + /** + * Playback information indicating the playback volume is fixed, i.e. it cannot be + * controlled from this object. An example of fixed playback volume is a remote player, + * playing over HDMI where the user prefers to control the volume on the HDMI sink, rather + * than attenuate at the source. + * @see #getVolumeHandling() + */ + public final static int PLAYBACK_VOLUME_FIXED = 0; + /** + * Playback information indicating the playback volume is variable and can be controlled + * from this object. + * @see #getVolumeHandling() + */ + public final static int PLAYBACK_VOLUME_VARIABLE = 1; + + RouteInfo(RouteCategory category) { + mCategory = category; + mDeviceType = DEVICE_TYPE_UNKNOWN; + } + + /** + * Gets the user-visible name of the route. + * <p> + * The route name identifies the destination represented by the route. + * It may be a user-supplied name, an alias, or device serial number. + * </p> + * + * @return The user-visible name of a media route. This is the string presented + * to users who may select this as the active route. + */ + public CharSequence getName() { + return getName(sStatic.mResources); + } + + /** + * Return the properly localized/resource user-visible name of this route. + * <p> + * The route name identifies the destination represented by the route. + * It may be a user-supplied name, an alias, or device serial number. + * </p> + * + * @param context Context used to resolve the correct configuration to load + * @return The user-visible name of a media route. This is the string presented + * to users who may select this as the active route. + */ + public CharSequence getName(Context context) { + return getName(context.getResources()); + } + + @UnsupportedAppUsage + CharSequence getName(Resources res) { + if (mNameResId != 0) { + return res.getText(mNameResId); + } + return mName; + } + + /** + * Gets the user-visible description of the route. + * <p> + * The route description describes the kind of destination represented by the route. + * It may be a user-supplied string, a model number or brand of device. + * </p> + * + * @return The description of the route, or null if none. + */ + public CharSequence getDescription() { + return mDescription; + } + + /** + * @return The user-visible status for a media route. This may include a description + * of the currently playing media, if available. + */ + public CharSequence getStatus() { + return mStatus; + } + + /** + * Set this route's status by predetermined status code. If the caller + * should dispatch a route changed event this call will return true; + */ + boolean setRealStatusCode(int statusCode) { + if (mRealStatusCode != statusCode) { + mRealStatusCode = statusCode; + return resolveStatusCode(); + } + return false; + } + + /** + * Resolves the status code whenever the real status code or selection state + * changes. + */ + boolean resolveStatusCode() { + int statusCode = mRealStatusCode; + if (isSelected()) { + switch (statusCode) { + // If the route is selected and its status appears to be between states + // then report it as connecting even though it has not yet had a chance + // to officially move into the CONNECTING state. Note that routes in + // the NONE state are assumed to not require an explicit connection + // lifecycle whereas those that are AVAILABLE are assumed to have + // to eventually proceed to CONNECTED. + case STATUS_AVAILABLE: + case STATUS_SCANNING: + statusCode = STATUS_CONNECTING; + break; + } + } + if (mResolvedStatusCode == statusCode) { + return false; + } + + mResolvedStatusCode = statusCode; + int resId; + switch (statusCode) { + case STATUS_SCANNING: + resId = com.android.internal.R.string.media_route_status_scanning; + break; + case STATUS_CONNECTING: + resId = com.android.internal.R.string.media_route_status_connecting; + break; + case STATUS_AVAILABLE: + resId = com.android.internal.R.string.media_route_status_available; + break; + case STATUS_NOT_AVAILABLE: + resId = com.android.internal.R.string.media_route_status_not_available; + break; + case STATUS_IN_USE: + resId = com.android.internal.R.string.media_route_status_in_use; + break; + case STATUS_CONNECTED: + case STATUS_NONE: + default: + resId = 0; + break; + } + mStatus = resId != 0 ? sStatic.mResources.getText(resId) : null; + return true; + } + + /** + * @hide + */ + @UnsupportedAppUsage + public int getStatusCode() { + return mResolvedStatusCode; + } + + /** + * @return A media type flag set describing which types this route supports. + */ + public int getSupportedTypes() { + return mSupportedTypes; + } + + /** + * Gets the type of the receiver device associated with this route. + * + * @return The type of the receiver device associated with this route: + * {@link #DEVICE_TYPE_BLUETOOTH}, {@link #DEVICE_TYPE_TV}, {@link #DEVICE_TYPE_SPEAKER}, + * or {@link #DEVICE_TYPE_UNKNOWN}. + */ + @DeviceType + public int getDeviceType() { + return mDeviceType; + } + + /** @hide */ + @UnsupportedAppUsage + public boolean matchesTypes(int types) { + return (mSupportedTypes & types) != 0; + } + + /** + * @return The group that this route belongs to. + */ + public RouteGroup getGroup() { + return mGroup; + } + + /** + * @return the category this route belongs to. + */ + public RouteCategory getCategory() { + return mCategory; + } + + /** + * Get the icon representing this route. + * This icon will be used in picker UIs if available. + * + * @return the icon representing this route or null if no icon is available + */ + public Drawable getIconDrawable() { + return mIcon; + } + + /** + * Set an application-specific tag object for this route. + * The application may use this to store arbitrary data associated with the + * route for internal tracking. + * + * <p>Note that the lifespan of a route may be well past the lifespan of + * an Activity or other Context; take care that objects you store here + * will not keep more data in memory alive than you intend.</p> + * + * @param tag Arbitrary, app-specific data for this route to hold for later use + */ + public void setTag(Object tag) { + mTag = tag; + routeUpdated(); + } + + /** + * @return The tag object previously set by the application + * @see #setTag(Object) + */ + public Object getTag() { + return mTag; + } + + /** + * @return the type of playback associated with this route + * @see UserRouteInfo#setPlaybackType(int) + */ + @PlaybackType + public int getPlaybackType() { + return mPlaybackType; + } + + /** + * @return the stream over which the playback associated with this route is performed + * @see UserRouteInfo#setPlaybackStream(int) + */ + public int getPlaybackStream() { + return mPlaybackStream; + } + + /** + * Return the current volume for this route. Depending on the route, this may only + * be valid if the route is currently selected. + * + * @return the volume at which the playback associated with this route is performed + * @see UserRouteInfo#setVolume(int) + */ + public int getVolume() { + if (mPlaybackType == PLAYBACK_TYPE_LOCAL) { + int vol = 0; + try { + vol = sStatic.mAudioService.getStreamVolume(mPlaybackStream); + } catch (RemoteException e) { + Log.e(TAG, "Error getting local stream volume", e); + } + return vol; + } else { + return mVolume; + } + } + + /** + * Request a volume change for this route. + * @param volume value between 0 and getVolumeMax + */ + public void requestSetVolume(int volume) { + if (mPlaybackType == PLAYBACK_TYPE_LOCAL) { + try { + sStatic.mAudioService.setStreamVolume(mPlaybackStream, volume, 0, + ActivityThread.currentPackageName()); + } catch (RemoteException e) { + Log.e(TAG, "Error setting local stream volume", e); + } + } else { + sStatic.requestSetVolume(this, volume); + } + } + + /** + * Request an incremental volume update for this route. + * @param direction Delta to apply to the current volume + */ + public void requestUpdateVolume(int direction) { + if (mPlaybackType == PLAYBACK_TYPE_LOCAL) { + try { + final int volume = + Math.max(0, Math.min(getVolume() + direction, getVolumeMax())); + sStatic.mAudioService.setStreamVolume(mPlaybackStream, volume, 0, + ActivityThread.currentPackageName()); + } catch (RemoteException e) { + Log.e(TAG, "Error setting local stream volume", e); + } + } else { + sStatic.requestUpdateVolume(this, direction); + } + } + + /** + * @return the maximum volume at which the playback associated with this route is performed + * @see UserRouteInfo#setVolumeMax(int) + */ + public int getVolumeMax() { + if (mPlaybackType == PLAYBACK_TYPE_LOCAL) { + int volMax = 0; + try { + volMax = sStatic.mAudioService.getStreamMaxVolume(mPlaybackStream); + } catch (RemoteException e) { + Log.e(TAG, "Error getting local stream volume", e); + } + return volMax; + } else { + return mVolumeMax; + } + } + + /** + * @return how volume is handling on the route + * @see UserRouteInfo#setVolumeHandling(int) + */ + @PlaybackVolume + public int getVolumeHandling() { + return mVolumeHandling; + } + + /** + * Gets the {@link Display} that should be used by the application to show + * a {@link android.app.Presentation} on an external display when this route is selected. + * Depending on the route, this may only be valid if the route is currently + * selected. + * <p> + * The preferred presentation display may change independently of the route + * being selected or unselected. For example, the presentation display + * of the default system route may change when an external HDMI display is connected + * or disconnected even though the route itself has not changed. + * </p><p> + * This method may return null if there is no external display associated with + * the route or if the display is not ready to show UI yet. + * </p><p> + * The application should listen for changes to the presentation display + * using the {@link Callback#onRoutePresentationDisplayChanged} callback and + * show or dismiss its {@link android.app.Presentation} accordingly when the display + * becomes available or is removed. + * </p><p> + * This method only makes sense for {@link #ROUTE_TYPE_LIVE_VIDEO live video} routes. + * </p> + * + * @return The preferred presentation display to use when this route is + * selected or null if none. + * + * @see #ROUTE_TYPE_LIVE_VIDEO + * @see android.app.Presentation + */ + public Display getPresentationDisplay() { + return mPresentationDisplay; + } + + boolean updatePresentationDisplay() { + Display display = choosePresentationDisplay(); + if (mPresentationDisplay != display) { + mPresentationDisplay = display; + return true; + } + return false; + } + + private Display choosePresentationDisplay() { + if ((mSupportedTypes & ROUTE_TYPE_LIVE_VIDEO) != 0) { + Display[] displays = sStatic.getAllPresentationDisplays(); + + // Ensure that the specified display is valid for presentations. + // This check will normally disallow the default display unless it was + // configured as a presentation display for some reason. + if (mPresentationDisplayId >= 0) { + for (Display display : displays) { + if (display.getDisplayId() == mPresentationDisplayId) { + return display; + } + } + return null; + } + + // Find the indicated Wifi display by its address. + if (mDeviceAddress != null) { + for (Display display : displays) { + if (display.getType() == Display.TYPE_WIFI + && mDeviceAddress.equals(display.getAddress())) { + return display; + } + } + return null; + } + + // For the default route, choose the first presentation display from the list. + if (this == sStatic.mDefaultAudioVideo && displays.length > 0) { + return displays[0]; + } + } + return null; + } + + /** @hide */ + @UnsupportedAppUsage + public String getDeviceAddress() { + return mDeviceAddress; + } + + /** + * Returns true if this route is enabled and may be selected. + * + * @return True if this route is enabled. + */ + public boolean isEnabled() { + return mEnabled; + } + + /** + * Returns true if the route is in the process of connecting and is not + * yet ready for use. + * + * @return True if this route is in the process of connecting. + */ + public boolean isConnecting() { + return mResolvedStatusCode == STATUS_CONNECTING; + } + + /** @hide */ + @UnsupportedAppUsage + public boolean isSelected() { + return this == sStatic.mSelectedRoute; + } + + /** @hide */ + @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 115609023) + public boolean isDefault() { + return this == sStatic.mDefaultAudioVideo; + } + + /** @hide */ + public boolean isBluetooth() { + return this == sStatic.mBluetoothA2dpRoute; + } + + /** @hide */ + @UnsupportedAppUsage + public void select() { + selectRouteStatic(mSupportedTypes, this, true); + } + + void setStatusInt(CharSequence status) { + if (!status.equals(mStatus)) { + mStatus = status; + if (mGroup != null) { + mGroup.memberStatusChanged(this, status); + } + routeUpdated(); + } + } + + final IRemoteVolumeObserver.Stub mRemoteVolObserver = new IRemoteVolumeObserver.Stub() { + @Override + public void dispatchRemoteVolumeUpdate(final int direction, final int value) { + sStatic.mHandler.post(new Runnable() { + @Override + public void run() { + if (mVcb != null) { + if (direction != 0) { + mVcb.vcb.onVolumeUpdateRequest(mVcb.route, direction); + } else { + mVcb.vcb.onVolumeSetRequest(mVcb.route, value); + } + } + } + }); + } + }; + + void routeUpdated() { + updateRoute(this); + } + + @Override + public String toString() { + String supportedTypes = typesToString(getSupportedTypes()); + return getClass().getSimpleName() + "{ name=" + getName() + + ", description=" + getDescription() + + ", status=" + getStatus() + + ", category=" + getCategory() + + ", supportedTypes=" + supportedTypes + + ", presentationDisplay=" + mPresentationDisplay + " }"; + } + } + + /** + * Information about a route that the application may define and modify. + * A user route defaults to {@link RouteInfo#PLAYBACK_TYPE_REMOTE} and + * {@link RouteInfo#PLAYBACK_VOLUME_FIXED}. + * + * @see MediaRouter.RouteInfo + */ + public static class UserRouteInfo extends RouteInfo { + RemoteControlClient mRcc; + SessionVolumeProvider mSvp; + + UserRouteInfo(RouteCategory category) { + super(category); + mSupportedTypes = ROUTE_TYPE_USER; + mPlaybackType = PLAYBACK_TYPE_REMOTE; + mVolumeHandling = PLAYBACK_VOLUME_FIXED; + } + + /** + * Set the user-visible name of this route. + * @param name Name to display to the user to describe this route + */ + public void setName(CharSequence name) { + mNameResId = 0; + mName = name; + routeUpdated(); + } + + /** + * Set the user-visible name of this route. + * <p> + * The route name identifies the destination represented by the route. + * It may be a user-supplied name, an alias, or device serial number. + * </p> + * + * @param resId Resource ID of the name to display to the user to describe this route + */ + public void setName(int resId) { + mNameResId = resId; + mName = null; + routeUpdated(); + } + + /** + * Set the user-visible description of this route. + * <p> + * The route description describes the kind of destination represented by the route. + * It may be a user-supplied string, a model number or brand of device. + * </p> + * + * @param description The description of the route, or null if none. + */ + public void setDescription(CharSequence description) { + mDescription = description; + routeUpdated(); + } + + /** + * Set the current user-visible status for this route. + * @param status Status to display to the user to describe what the endpoint + * of this route is currently doing + */ + public void setStatus(CharSequence status) { + setStatusInt(status); + } + + /** + * Set the RemoteControlClient responsible for reporting playback info for this + * user route. + * + * <p>If this route manages remote playback, the data exposed by this + * RemoteControlClient will be used to reflect and update information + * such as route volume info in related UIs.</p> + * + * <p>The RemoteControlClient must have been previously registered with + * {@link AudioManager#registerRemoteControlClient(RemoteControlClient)}.</p> + * + * @param rcc RemoteControlClient associated with this route + */ + public void setRemoteControlClient(RemoteControlClient rcc) { + mRcc = rcc; + updatePlaybackInfoOnRcc(); + } + + /** + * Retrieve the RemoteControlClient associated with this route, if one has been set. + * + * @return the RemoteControlClient associated with this route + * @see #setRemoteControlClient(RemoteControlClient) + */ + public RemoteControlClient getRemoteControlClient() { + return mRcc; + } + + /** + * Set an icon that will be used to represent this route. + * The system may use this icon in picker UIs or similar. + * + * @param icon icon drawable to use to represent this route + */ + public void setIconDrawable(Drawable icon) { + mIcon = icon; + } + + /** + * Set an icon that will be used to represent this route. + * The system may use this icon in picker UIs or similar. + * + * @param resId Resource ID of an icon drawable to use to represent this route + */ + public void setIconResource(@DrawableRes int resId) { + setIconDrawable(sStatic.mResources.getDrawable(resId)); + } + + /** + * Set a callback to be notified of volume update requests + * @param vcb + */ + public void setVolumeCallback(VolumeCallback vcb) { + mVcb = new VolumeCallbackInfo(vcb, this); + } + + /** + * Defines whether playback associated with this route is "local" + * ({@link RouteInfo#PLAYBACK_TYPE_LOCAL}) or "remote" + * ({@link RouteInfo#PLAYBACK_TYPE_REMOTE}). + * @param type + */ + public void setPlaybackType(@RouteInfo.PlaybackType int type) { + if (mPlaybackType != type) { + mPlaybackType = type; + configureSessionVolume(); + } + } + + /** + * Defines whether volume for the playback associated with this route is fixed + * ({@link RouteInfo#PLAYBACK_VOLUME_FIXED}) or can modified + * ({@link RouteInfo#PLAYBACK_VOLUME_VARIABLE}). + * @param volumeHandling + */ + public void setVolumeHandling(@RouteInfo.PlaybackVolume int volumeHandling) { + if (mVolumeHandling != volumeHandling) { + mVolumeHandling = volumeHandling; + configureSessionVolume(); + } + } + + /** + * Defines at what volume the playback associated with this route is performed (for user + * feedback purposes). This information is only used when the playback is not local. + * @param volume + */ + public void setVolume(int volume) { + volume = Math.max(0, Math.min(volume, getVolumeMax())); + if (mVolume != volume) { + mVolume = volume; + if (mSvp != null) { + mSvp.setCurrentVolume(mVolume); + } + dispatchRouteVolumeChanged(this); + if (mGroup != null) { + mGroup.memberVolumeChanged(this); + } + } + } + + @Override + public void requestSetVolume(int volume) { + if (mVolumeHandling == PLAYBACK_VOLUME_VARIABLE) { + if (mVcb == null) { + Log.e(TAG, "Cannot requestSetVolume on user route - no volume callback set"); + return; + } + mVcb.vcb.onVolumeSetRequest(this, volume); + } + } + + @Override + public void requestUpdateVolume(int direction) { + if (mVolumeHandling == PLAYBACK_VOLUME_VARIABLE) { + if (mVcb == null) { + Log.e(TAG, "Cannot requestChangeVolume on user route - no volumec callback set"); + return; + } + mVcb.vcb.onVolumeUpdateRequest(this, direction); + } + } + + /** + * Defines the maximum volume at which the playback associated with this route is performed + * (for user feedback purposes). This information is only used when the playback is not + * local. + * @param volumeMax + */ + public void setVolumeMax(int volumeMax) { + if (mVolumeMax != volumeMax) { + mVolumeMax = volumeMax; + configureSessionVolume(); + } + } + + /** + * Defines over what stream type the media is presented. + * @param stream + */ + public void setPlaybackStream(int stream) { + if (mPlaybackStream != stream) { + mPlaybackStream = stream; + configureSessionVolume(); + } + } + + private void updatePlaybackInfoOnRcc() { + configureSessionVolume(); + } + + private void configureSessionVolume() { + if (mRcc == null) { + if (DEBUG) { + Log.d(TAG, "No Rcc to configure volume for route " + getName()); + } + return; + } + MediaSession session = mRcc.getMediaSession(); + if (session == null) { + if (DEBUG) { + Log.d(TAG, "Rcc has no session to configure volume"); + } + return; + } + if (mPlaybackType == RemoteControlClient.PLAYBACK_TYPE_REMOTE) { + int volumeControl = VolumeProvider.VOLUME_CONTROL_FIXED; + switch (mVolumeHandling) { + case RemoteControlClient.PLAYBACK_VOLUME_VARIABLE: + volumeControl = VolumeProvider.VOLUME_CONTROL_ABSOLUTE; + break; + case RemoteControlClient.PLAYBACK_VOLUME_FIXED: + default: + break; + } + // Only register a new listener if necessary + if (mSvp == null || mSvp.getVolumeControl() != volumeControl + || mSvp.getMaxVolume() != mVolumeMax) { + mSvp = new SessionVolumeProvider(volumeControl, mVolumeMax, mVolume); + session.setPlaybackToRemote(mSvp); + } + } else { + // We only know how to handle local and remote, fall back to local if not remote. + AudioAttributes.Builder bob = new AudioAttributes.Builder(); + bob.setLegacyStreamType(mPlaybackStream); + session.setPlaybackToLocal(bob.build()); + mSvp = null; + } + } + + class SessionVolumeProvider extends VolumeProvider { + + SessionVolumeProvider(int volumeControl, int maxVolume, int currentVolume) { + super(volumeControl, maxVolume, currentVolume); + } + + @Override + public void onSetVolumeTo(final int volume) { + sStatic.mHandler.post(new Runnable() { + @Override + public void run() { + if (mVcb != null) { + mVcb.vcb.onVolumeSetRequest(mVcb.route, volume); + } + } + }); + } + + @Override + public void onAdjustVolume(final int direction) { + sStatic.mHandler.post(new Runnable() { + @Override + public void run() { + if (mVcb != null) { + mVcb.vcb.onVolumeUpdateRequest(mVcb.route, direction); + } + } + }); + } + } + } + + /** + * Information about a route that consists of multiple other routes in a group. + */ + public static class RouteGroup extends RouteInfo { + final ArrayList<RouteInfo> mRoutes = new ArrayList<RouteInfo>(); + private boolean mUpdateName; + + RouteGroup(RouteCategory category) { + super(category); + mGroup = this; + mVolumeHandling = PLAYBACK_VOLUME_FIXED; + } + + @Override + CharSequence getName(Resources res) { + if (mUpdateName) updateName(); + return super.getName(res); + } + + /** + * Add a route to this group. The route must not currently belong to another group. + * + * @param route route to add to this group + */ + public void addRoute(RouteInfo route) { + if (route.getGroup() != null) { + throw new IllegalStateException("Route " + route + " is already part of a group."); + } + if (route.getCategory() != mCategory) { + throw new IllegalArgumentException( + "Route cannot be added to a group with a different category. " + + "(Route category=" + route.getCategory() + + " group category=" + mCategory + ")"); + } + final int at = mRoutes.size(); + mRoutes.add(route); + route.mGroup = this; + mUpdateName = true; + updateVolume(); + routeUpdated(); + dispatchRouteGrouped(route, this, at); + } + + /** + * Add a route to this group before the specified index. + * + * @param route route to add + * @param insertAt insert the new route before this index + */ + public void addRoute(RouteInfo route, int insertAt) { + if (route.getGroup() != null) { + throw new IllegalStateException("Route " + route + " is already part of a group."); + } + if (route.getCategory() != mCategory) { + throw new IllegalArgumentException( + "Route cannot be added to a group with a different category. " + + "(Route category=" + route.getCategory() + + " group category=" + mCategory + ")"); + } + mRoutes.add(insertAt, route); + route.mGroup = this; + mUpdateName = true; + updateVolume(); + routeUpdated(); + dispatchRouteGrouped(route, this, insertAt); + } + + /** + * Remove a route from this group. + * + * @param route route to remove + */ + public void removeRoute(RouteInfo route) { + if (route.getGroup() != this) { + throw new IllegalArgumentException("Route " + route + + " is not a member of this group."); + } + mRoutes.remove(route); + route.mGroup = null; + mUpdateName = true; + updateVolume(); + dispatchRouteUngrouped(route, this); + routeUpdated(); + } + + /** + * Remove the route at the specified index from this group. + * + * @param index index of the route to remove + */ + public void removeRoute(int index) { + RouteInfo route = mRoutes.remove(index); + route.mGroup = null; + mUpdateName = true; + updateVolume(); + dispatchRouteUngrouped(route, this); + routeUpdated(); + } + + /** + * @return The number of routes in this group + */ + public int getRouteCount() { + return mRoutes.size(); + } + + /** + * Return the route in this group at the specified index + * + * @param index Index to fetch + * @return The route at index + */ + public RouteInfo getRouteAt(int index) { + return mRoutes.get(index); + } + + /** + * Set an icon that will be used to represent this group. + * The system may use this icon in picker UIs or similar. + * + * @param icon icon drawable to use to represent this group + */ + public void setIconDrawable(Drawable icon) { + mIcon = icon; + } + + /** + * Set an icon that will be used to represent this group. + * The system may use this icon in picker UIs or similar. + * + * @param resId Resource ID of an icon drawable to use to represent this group + */ + public void setIconResource(@DrawableRes int resId) { + setIconDrawable(sStatic.mResources.getDrawable(resId)); + } + + @Override + public void requestSetVolume(int volume) { + final int maxVol = getVolumeMax(); + if (maxVol == 0) { + return; + } + + final float scaledVolume = (float) volume / maxVol; + final int routeCount = getRouteCount(); + for (int i = 0; i < routeCount; i++) { + final RouteInfo route = getRouteAt(i); + final int routeVol = (int) (scaledVolume * route.getVolumeMax()); + route.requestSetVolume(routeVol); + } + if (volume != mVolume) { + mVolume = volume; + dispatchRouteVolumeChanged(this); + } + } + + @Override + public void requestUpdateVolume(int direction) { + final int maxVol = getVolumeMax(); + if (maxVol == 0) { + return; + } + + final int routeCount = getRouteCount(); + int volume = 0; + for (int i = 0; i < routeCount; i++) { + final RouteInfo route = getRouteAt(i); + route.requestUpdateVolume(direction); + final int routeVol = route.getVolume(); + if (routeVol > volume) { + volume = routeVol; + } + } + if (volume != mVolume) { + mVolume = volume; + dispatchRouteVolumeChanged(this); + } + } + + void memberNameChanged(RouteInfo info, CharSequence name) { + mUpdateName = true; + routeUpdated(); + } + + void memberStatusChanged(RouteInfo info, CharSequence status) { + setStatusInt(status); + } + + void memberVolumeChanged(RouteInfo info) { + updateVolume(); + } + + void updateVolume() { + // A group always represents the highest component volume value. + final int routeCount = getRouteCount(); + int volume = 0; + for (int i = 0; i < routeCount; i++) { + final int routeVol = getRouteAt(i).getVolume(); + if (routeVol > volume) { + volume = routeVol; + } + } + if (volume != mVolume) { + mVolume = volume; + dispatchRouteVolumeChanged(this); + } + } + + @Override + void routeUpdated() { + int types = 0; + final int count = mRoutes.size(); + if (count == 0) { + // Don't keep empty groups in the router. + MediaRouter.removeRouteStatic(this); + return; + } + + int maxVolume = 0; + boolean isLocal = true; + boolean isFixedVolume = true; + for (int i = 0; i < count; i++) { + final RouteInfo route = mRoutes.get(i); + types |= route.mSupportedTypes; + final int routeMaxVolume = route.getVolumeMax(); + if (routeMaxVolume > maxVolume) { + maxVolume = routeMaxVolume; + } + isLocal &= route.getPlaybackType() == PLAYBACK_TYPE_LOCAL; + isFixedVolume &= route.getVolumeHandling() == PLAYBACK_VOLUME_FIXED; + } + mPlaybackType = isLocal ? PLAYBACK_TYPE_LOCAL : PLAYBACK_TYPE_REMOTE; + mVolumeHandling = isFixedVolume ? PLAYBACK_VOLUME_FIXED : PLAYBACK_VOLUME_VARIABLE; + mSupportedTypes = types; + mVolumeMax = maxVolume; + mIcon = count == 1 ? mRoutes.get(0).getIconDrawable() : null; + super.routeUpdated(); + } + + void updateName() { + final StringBuilder sb = new StringBuilder(); + final int count = mRoutes.size(); + for (int i = 0; i < count; i++) { + final RouteInfo info = mRoutes.get(i); + // TODO: There's probably a much more correct way to localize this. + if (i > 0) { + sb.append(", "); + } + sb.append(info.getName()); + } + mName = sb.toString(); + mUpdateName = false; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(super.toString()); + sb.append('['); + final int count = mRoutes.size(); + for (int i = 0; i < count; i++) { + if (i > 0) sb.append(", "); + sb.append(mRoutes.get(i)); + } + sb.append(']'); + return sb.toString(); + } + } + + /** + * Definition of a category of routes. All routes belong to a category. + */ + public static class RouteCategory { + CharSequence mName; + int mNameResId; + int mTypes; + final boolean mGroupable; + boolean mIsSystem; + + RouteCategory(CharSequence name, int types, boolean groupable) { + mName = name; + mTypes = types; + mGroupable = groupable; + } + + RouteCategory(int nameResId, int types, boolean groupable) { + mNameResId = nameResId; + mTypes = types; + mGroupable = groupable; + } + + /** + * @return the name of this route category + */ + public CharSequence getName() { + return getName(sStatic.mResources); + } + + /** + * Return the properly localized/configuration dependent name of this RouteCategory. + * + * @param context Context to resolve name resources + * @return the name of this route category + */ + public CharSequence getName(Context context) { + return getName(context.getResources()); + } + + CharSequence getName(Resources res) { + if (mNameResId != 0) { + return res.getText(mNameResId); + } + return mName; + } + + /** + * Return the current list of routes in this category that have been added + * to the MediaRouter. + * + * <p>This list will not include routes that are nested within RouteGroups. + * A RouteGroup is treated as a single route within its category.</p> + * + * @param out a List to fill with the routes in this category. If this parameter is + * non-null, it will be cleared, filled with the current routes with this + * category, and returned. If this parameter is null, a new List will be + * allocated to report the category's current routes. + * @return A list with the routes in this category that have been added to the MediaRouter. + */ + public List<RouteInfo> getRoutes(List<RouteInfo> out) { + if (out == null) { + out = new ArrayList<RouteInfo>(); + } else { + out.clear(); + } + + final int count = getRouteCountStatic(); + for (int i = 0; i < count; i++) { + final RouteInfo route = getRouteAtStatic(i); + if (route.mCategory == this) { + out.add(route); + } + } + return out; + } + + /** + * @return Flag set describing the route types supported by this category + */ + public int getSupportedTypes() { + return mTypes; + } + + /** + * Return whether or not this category supports grouping. + * + * <p>If this method returns true, all routes obtained from this category + * via calls to {@link #getRouteAt(int)} will be {@link MediaRouter.RouteGroup}s.</p> + * + * @return true if this category supports + */ + public boolean isGroupable() { + return mGroupable; + } + + /** + * @return true if this is the category reserved for system routes. + * @hide + */ + public boolean isSystem() { + return mIsSystem; + } + + @Override + public String toString() { + return "RouteCategory{ name=" + getName() + " types=" + typesToString(mTypes) + + " groupable=" + mGroupable + " }"; + } + } + + static class CallbackInfo { + public int type; + public int flags; + public final Callback cb; + public final MediaRouter router; + + public CallbackInfo(Callback cb, int type, int flags, MediaRouter router) { + this.cb = cb; + this.type = type; + this.flags = flags; + this.router = router; + } + + public boolean filterRouteEvent(RouteInfo route) { + return filterRouteEvent(route.mSupportedTypes); + } + + public boolean filterRouteEvent(int supportedTypes) { + return (flags & CALLBACK_FLAG_UNFILTERED_EVENTS) != 0 + || (type & supportedTypes) != 0; + } + } + + /** + * Interface for receiving events about media routing changes. + * All methods of this interface will be called from the application's main thread. + * <p> + * A Callback will only receive events relevant to routes that the callback + * was registered for unless the {@link MediaRouter#CALLBACK_FLAG_UNFILTERED_EVENTS} + * flag was specified in {@link MediaRouter#addCallback(int, Callback, int)}. + * </p> + * + * @see MediaRouter#addCallback(int, Callback, int) + * @see MediaRouter#removeCallback(Callback) + */ + public static abstract class Callback { + /** + * Called when the supplied route becomes selected as the active route + * for the given route type. + * + * @param router the MediaRouter reporting the event + * @param type Type flag set indicating the routes that have been selected + * @param info Route that has been selected for the given route types + */ + public abstract void onRouteSelected(MediaRouter router, int type, RouteInfo info); + + /** + * Called when the supplied route becomes unselected as the active route + * for the given route type. + * + * @param router the MediaRouter reporting the event + * @param type Type flag set indicating the routes that have been unselected + * @param info Route that has been unselected for the given route types + */ + public abstract void onRouteUnselected(MediaRouter router, int type, RouteInfo info); + + /** + * Called when a route for the specified type was added. + * + * @param router the MediaRouter reporting the event + * @param info Route that has become available for use + */ + public abstract void onRouteAdded(MediaRouter router, RouteInfo info); + + /** + * Called when a route for the specified type was removed. + * + * @param router the MediaRouter reporting the event + * @param info Route that has been removed from availability + */ + public abstract void onRouteRemoved(MediaRouter router, RouteInfo info); + + /** + * Called when an aspect of the indicated route has changed. + * + * <p>This will not indicate that the types supported by this route have + * changed, only that cosmetic info such as name or status have been updated.</p> + * + * @param router the MediaRouter reporting the event + * @param info The route that was changed + */ + public abstract void onRouteChanged(MediaRouter router, RouteInfo info); + + /** + * Called when a route is added to a group. + * + * @param router the MediaRouter reporting the event + * @param info The route that was added + * @param group The group the route was added to + * @param index The route index within group that info was added at + */ + public abstract void onRouteGrouped(MediaRouter router, RouteInfo info, RouteGroup group, + int index); + + /** + * Called when a route is removed from a group. + * + * @param router the MediaRouter reporting the event + * @param info The route that was removed + * @param group The group the route was removed from + */ + public abstract void onRouteUngrouped(MediaRouter router, RouteInfo info, RouteGroup group); + + /** + * Called when a route's volume changes. + * + * @param router the MediaRouter reporting the event + * @param info The route with altered volume + */ + public abstract void onRouteVolumeChanged(MediaRouter router, RouteInfo info); + + /** + * Called when a route's presentation display changes. + * <p> + * This method is called whenever the route's presentation display becomes + * available, is removes or has changes to some of its properties (such as its size). + * </p> + * + * @param router the MediaRouter reporting the event + * @param info The route whose presentation display changed + * + * @see RouteInfo#getPresentationDisplay() + */ + public void onRoutePresentationDisplayChanged(MediaRouter router, RouteInfo info) { + } + } + + /** + * Stub implementation of {@link MediaRouter.Callback}. + * Each abstract method is defined as a no-op. Override just the ones + * you need. + */ + public static class SimpleCallback extends Callback { + + @Override + public void onRouteSelected(MediaRouter router, int type, RouteInfo info) { + } + + @Override + public void onRouteUnselected(MediaRouter router, int type, RouteInfo info) { + } + + @Override + public void onRouteAdded(MediaRouter router, RouteInfo info) { + } + + @Override + public void onRouteRemoved(MediaRouter router, RouteInfo info) { + } + + @Override + public void onRouteChanged(MediaRouter router, RouteInfo info) { + } + + @Override + public void onRouteGrouped(MediaRouter router, RouteInfo info, RouteGroup group, + int index) { + } + + @Override + public void onRouteUngrouped(MediaRouter router, RouteInfo info, RouteGroup group) { + } + + @Override + public void onRouteVolumeChanged(MediaRouter router, RouteInfo info) { + } + } + + static class VolumeCallbackInfo { + public final VolumeCallback vcb; + public final RouteInfo route; + + public VolumeCallbackInfo(VolumeCallback vcb, RouteInfo route) { + this.vcb = vcb; + this.route = route; + } + } + + /** + * Interface for receiving events about volume changes. + * All methods of this interface will be called from the application's main thread. + * + * <p>A VolumeCallback will only receive events relevant to routes that the callback + * was registered for.</p> + * + * @see UserRouteInfo#setVolumeCallback(VolumeCallback) + */ + public static abstract class VolumeCallback { + /** + * Called when the volume for the route should be increased or decreased. + * @param info the route affected by this event + * @param direction an integer indicating whether the volume is to be increased + * (positive value) or decreased (negative value). + * For bundled changes, the absolute value indicates the number of changes + * in the same direction, e.g. +3 corresponds to three "volume up" changes. + */ + public abstract void onVolumeUpdateRequest(RouteInfo info, int direction); + /** + * Called when the volume for the route should be set to the given value + * @param info the route affected by this event + * @param volume an integer indicating the new volume value that should be used, always + * between 0 and the value set by {@link UserRouteInfo#setVolumeMax(int)}. + */ + public abstract void onVolumeSetRequest(RouteInfo info, int volume); + } + + static class VolumeChangeReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals(AudioManager.VOLUME_CHANGED_ACTION)) { + final int streamType = intent.getIntExtra(AudioManager.EXTRA_VOLUME_STREAM_TYPE, + -1); + if (streamType != AudioManager.STREAM_MUSIC) { + return; + } + + final int newVolume = intent.getIntExtra(AudioManager.EXTRA_VOLUME_STREAM_VALUE, 0); + final int oldVolume = intent.getIntExtra( + AudioManager.EXTRA_PREV_VOLUME_STREAM_VALUE, 0); + if (newVolume != oldVolume) { + systemVolumeChanged(newVolume); + } + } + } + } + + static class WifiDisplayStatusChangedReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.getAction().equals(DisplayManager.ACTION_WIFI_DISPLAY_STATUS_CHANGED)) { + updateWifiDisplayStatus((WifiDisplayStatus) intent.getParcelableExtra( + DisplayManager.EXTRA_WIFI_DISPLAY_STATUS)); + } + } + } +}