/* * Copyright (C) 2016 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 com.android.car.media; import android.app.SearchManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.pm.ServiceInfo; import android.content.res.Resources; import android.content.res.TypedArray; import android.media.browse.MediaBrowser; import android.media.session.MediaController; import android.media.session.MediaSession; import android.media.session.PlaybackState; import android.os.Bundle; import android.service.media.MediaBrowserService; import android.text.TextUtils; import android.util.Log; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.List; /** * Manages which media app we should connect to. The manager also retrieves various attributes * from the media app and share among different components in GearHead media app. * * @deprecated This manager is being replaced by {@link com.android.car.media.common.PlaybackModel}. */ @Deprecated public class MediaManager { private static final String TAG = "GH.MediaManager"; private static final String PREFS_FILE_NAME = "MediaClientManager.Preferences"; /** The package of the most recently used media component **/ private static final String PREFS_KEY_PACKAGE = "media_package"; /** The class of the most recently used media class **/ private static final String PREFS_KEY_CLASS = "media_class"; /** Third-party defined application theme to use **/ private static final String THEME_META_DATA_NAME = "com.google.android.gms.car.application.theme"; public static final String KEY_MEDIA_COMPONENT = "media_component"; /** Intent extra specifying the package with the MediaBrowser **/ public static final String KEY_MEDIA_PACKAGE = "media_package"; /** Intent extra specifying the MediaBrowserService **/ public static final String KEY_MEDIA_CLASS = "media_class"; /** * Flag for when GSA is not 100% confident on the query and therefore, the result in the * {@link #KEY_MEDIA_PACKAGE_FROM_GSA} should be ignored. */ private static final String KEY_IGNORE_ORIGINAL_PKG = "com.google.android.projection.gearhead.ignore_original_pkg"; /** * Intent extra specifying the package name of the media app that should handle * {@link android.provider.MediaStore#INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH}. This must match * KEY_PACKAGE defined in ProjectionIntentStarter in GSA. */ public static final String KEY_MEDIA_PACKAGE_FROM_GSA = "android.car.intent.extra.MEDIA_PACKAGE"; private static final String GOOGLE_PLAY_MUSIC_PACKAGE = "com.google.android.music"; // Extras along with the Knowledge Graph that are not meant to be seen by external apps. private static final String[] INTERNAL_EXTRAS = {"KEY_LAUNCH_HANDOVER_UNDERNEATH", "com.google.android.projection.gearhead.ignore_original_pkg"}; private static final Intent MEDIA_BROWSER_INTENT = new Intent(MediaBrowserService.SERVICE_INTERFACE); private static MediaManager sInstance; private final MediaController.Callback mMediaControllerCallback = new MediaManagerCallback(this); private final MediaBrowser.ConnectionCallback mMediaBrowserConnectionCallback = new MediaManagerConnectionCallback(this); public interface Listener { void onMediaAppChanged(ComponentName componentName); /** * Called when we want to show a message on playback screen. * @param msg if null, dismiss any previous message and * restore the track title and subtitle. */ void onStatusMessageChanged(String msg); } /** * An adapter interface to abstract the specifics of how media services are queried. This allows * for Vanagon to query for allowed media services without the need to connect to carClientApi. */ public interface ServiceAdapter { List queryAllowedServices(Intent providerIntent); } private int mPrimaryColor; private int mPrimaryColorDark; private int mAccentColor; private CharSequence mName; private final Context mContext; private final List mListeners = new ArrayList<>(); private ServiceAdapter mServiceAdapter; private Intent mPendingSearchIntent; private MediaController mController; private MediaBrowser mBrowser; private ComponentName mCurrentComponent; private PendingMsg mPendingMsg; public synchronized static MediaManager getInstance(Context context) { if (sInstance == null) { sInstance = new MediaManager(context.getApplicationContext()); } return sInstance; } private MediaManager(Context context) { mContext = context; // Set some sane default values for the attributes mName = ""; int color = context.getResources().getColor(android.R.color.background_dark); mPrimaryColor = color; mAccentColor = color; mPrimaryColorDark = color; } /** * Returns the default component used to load media. */ public ComponentName getDefaultComponent(ServiceAdapter serviceAdapter) { SharedPreferences prefs = mContext .getSharedPreferences(PREFS_FILE_NAME, Context.MODE_PRIVATE); String packageName = prefs.getString(PREFS_KEY_PACKAGE, null); String className = prefs.getString(PREFS_KEY_CLASS, null); final Intent providerIntent = new Intent(MediaBrowserService.SERVICE_INTERFACE); List mediaApps = serviceAdapter.queryAllowedServices(providerIntent); // check if the previous component we connected to is still valid. if (packageName != null && className != null) { boolean componentValid = false; for (ResolveInfo info : mediaApps) { if (info.serviceInfo.packageName.equals(packageName) && info.serviceInfo.name.equals(className)) { componentValid = true; } } // if not valid, null it and we will bring up the lens switcher or connect to another // app (this may happen when the app has been uninstalled) if (!componentValid) { packageName = null; className = null; } } // If there are no apps used before or previous app is not valid, // try to connect to a supported media app. if (packageName == null || className == null) { // Only one app installed, connect to it. if (mediaApps.size() == 1) { ResolveInfo info = mediaApps.get(0); packageName = info.serviceInfo.packageName; className = info.serviceInfo.name; } else { // there are '0' or >1 media apps installed; don't know what to run return null; } } return new ComponentName(packageName, className); } /** * Connects to the most recently used media app if it exists and return true. * Otherwise check the number of supported media apps installed, * if only one installed, connect to it return true. Otherwise return false. */ public boolean connectToMostRecentMediaComponent(ServiceAdapter serviceAdapter) { ComponentName component = getDefaultComponent(serviceAdapter); if (component != null) { setMediaClientComponent(serviceAdapter, component); return true; } return false; } public ComponentName getCurrentComponent() { return mCurrentComponent; } public void setMediaClientComponent(ComponentName component) { setMediaClientComponent(null, component); } /** * Change the media component. This will connect to a {@link android.media.browse.MediaBrowser} if necessary. * All registered listener will be updated with the new component. */ public void setMediaClientComponent(ServiceAdapter serviceAdapter, ComponentName component) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "setMediaClientComponent(), " + "component: " + (component == null ? "<< NULL >>" : component.toString())); } if (component == null) { return; } // mController will be set to null if previously connected media session has crashed. if (mCurrentComponent != null && mCurrentComponent.equals(component) && mController != null) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Already connected to " + component.toString()); } return; } mCurrentComponent = component; mServiceAdapter = serviceAdapter; disconnectCurrentBrowser(); updateClientPackageAttributes(mCurrentComponent); if (mController != null) { mController.unregisterCallback(mMediaControllerCallback); mController = null; } mBrowser = new MediaBrowser(mContext, component, mMediaBrowserConnectionCallback, null); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Connecting to " + component.toString()); } mBrowser.connect(); writeComponentToPrefs(component); ArrayList temp = new ArrayList(mListeners); for (Listener listener : temp) { listener.onMediaAppChanged(mCurrentComponent); } } /** * Processes the search intent using the current media app. If it's not connected yet, store it * in the {@code mPendingSearchIntent} and process it when the app is connected. * * @param intent The intent containing the query and * MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH action */ public void processSearchIntent(Intent intent) { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "processSearchIntent(), query: " + (intent == null ? "<< NULL >>" : intent.getStringExtra(SearchManager.QUERY))); } if (intent == null) { return; } mPendingSearchIntent = intent; String mediaPackageName; if (intent.getBooleanExtra(KEY_IGNORE_ORIGINAL_PKG, false)) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Ignoring package from gsa and falling back to default media app"); } mediaPackageName = null; } else if (intent.hasExtra(KEY_MEDIA_PACKAGE_FROM_GSA)) { // Legacy way of piping through the media app package. mediaPackageName = intent.getStringExtra(KEY_MEDIA_PACKAGE_FROM_GSA); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Package from extras: " + mediaPackageName); } } else { mediaPackageName = intent.getPackage(); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Package from getPackage(): " + mediaPackageName); } } if (mediaPackageName != null && mCurrentComponent != null && !mediaPackageName.equals(mCurrentComponent.getPackageName())) { final ComponentName componentName = getMediaBrowserComponent(mServiceAdapter, mediaPackageName); if (componentName == null) { Log.w(TAG, "There are no matching media app to handle intent: " + intent); return; } setMediaClientComponent(mServiceAdapter, componentName); // It's safe to return here as pending search intent will be processed // when newly created media controller for the new media component is connected. return; } String query = mPendingSearchIntent.getStringExtra(SearchManager.QUERY); if (mController != null) { mController.getTransportControls().pause(); mPendingMsg = new PendingMsg(PendingMsg.STATUS_UPDATE, mContext.getResources().getString(R.string.loading)); notifyStatusMessage(mPendingMsg.mMsg); Bundle extras = mPendingSearchIntent.getExtras(); // Remove two extras that are not meant to be seen by external apps. if (!GOOGLE_PLAY_MUSIC_PACKAGE.equals(mediaPackageName)) { for (String key : INTERNAL_EXTRAS) { extras.remove(key); } } mController.getTransportControls().playFromSearch(query, extras); mPendingSearchIntent = null; } else { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "No controller for search intent; save it for later"); } } } private ComponentName getMediaBrowserComponent(ServiceAdapter serviceAdapter, final String packageName) { List queryResults = serviceAdapter.queryAllowedServices(MEDIA_BROWSER_INTENT); if (queryResults != null) { for (int i = 0, N = queryResults.size(); i < N; ++i) { final ResolveInfo ri = queryResults.get(i); if (ri != null && ri.serviceInfo != null && ri.serviceInfo.packageName.equals(packageName)) { return new ComponentName(ri.serviceInfo.packageName, ri.serviceInfo.name); } } } return null; } /** * Add a listener to get media app changes. * Your listener will be called with the initial values when the listener is added. */ public void addListener(Listener listener) { mListeners.add(listener); if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "addListener(); count: " + mListeners.size()); } if (mCurrentComponent != null) { listener.onMediaAppChanged(mCurrentComponent); } if (mPendingMsg != null) { listener.onStatusMessageChanged(mPendingMsg.mMsg); } } public void removeListener(Listener listener) { mListeners.remove(listener); if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "removeListener(); count: " + mListeners.size()); } if (mListeners.size() == 0) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "no manager listeners; destroy manager instance"); } synchronized (MediaManager.class) { sInstance = null; } if (mBrowser != null) { mBrowser.disconnect(); } } } public CharSequence getMediaClientName() { return mName; } public int getMediaClientPrimaryColor() { return mPrimaryColor; } public int getMediaClientPrimaryColorDark() { return mPrimaryColorDark; } public int getMediaClientAccentColor() { return mAccentColor; } private void writeComponentToPrefs(ComponentName componentName) { // Store selected media service to shared preference. SharedPreferences prefs = mContext .getSharedPreferences(PREFS_FILE_NAME, Context.MODE_PRIVATE); SharedPreferences.Editor editor = prefs.edit(); editor.putString(PREFS_KEY_PACKAGE, componentName.getPackageName()); editor.putString(PREFS_KEY_CLASS, componentName.getClassName()); editor.apply(); } /** * Disconnect from the current media browser service if any, and notify the listeners. */ private void disconnectCurrentBrowser() { if (mBrowser != null) { mBrowser.disconnect(); mBrowser = null; } } private void updateClientPackageAttributes(ComponentName componentName) { TypedArray ta = null; try { String packageName = componentName.getPackageName(); ApplicationInfo applicationInfo = mContext.getPackageManager().getApplicationInfo(packageName, PackageManager.GET_META_DATA); ServiceInfo serviceInfo = mContext.getPackageManager().getServiceInfo( componentName, PackageManager.GET_META_DATA); // Get the proper app name, check service label, then application label. CharSequence name = ""; if (serviceInfo.labelRes != 0) { name = serviceInfo.loadLabel(mContext.getPackageManager()); } else if (applicationInfo.labelRes != 0) { name = applicationInfo.loadLabel(mContext.getPackageManager()); } if (TextUtils.isEmpty(name)) { name = mContext.getResources().getString(R.string.unknown_media_provider_name); } mName = name; // Get the proper theme, check theme for service, then application. int appTheme = 0; if (serviceInfo.metaData != null) { appTheme = serviceInfo.metaData.getInt(THEME_META_DATA_NAME); } if (appTheme == 0 && applicationInfo.metaData != null) { appTheme = applicationInfo.metaData.getInt(THEME_META_DATA_NAME); } if (appTheme == 0) { appTheme = applicationInfo.theme; } Context packageContext = mContext.createPackageContext(packageName, 0); packageContext.setTheme(appTheme); Resources.Theme theme = packageContext.getTheme(); ta = theme.obtainStyledAttributes(new int[] { android.R.attr.colorPrimary, android.R.attr.colorAccent, android.R.attr.colorPrimaryDark }); int defaultColor = mContext.getResources().getColor(android.R.color.background_dark); mPrimaryColor = ta.getColor(0, defaultColor); mAccentColor = ta.getColor(1, defaultColor); mPrimaryColorDark = ta.getColor(2, defaultColor); } catch (PackageManager.NameNotFoundException e) { Log.e(TAG, "Unable to update media client package attributes.", e); } finally { if (ta != null) { ta.recycle(); } } } private void notifyStatusMessage(String str) { for (Listener l : mListeners) { l.onStatusMessageChanged(str); } } private void doPlaybackStateChanged(PlaybackState playbackState) { // Display error message in MediaPlaybackFragment. if (mPendingMsg == null) { return; } // Dismiss the error msg if any, // and dismiss status update msg if the state is now playing if ((mPendingMsg.mType == PendingMsg.ERROR) || (playbackState.getState() == PlaybackState.STATE_PLAYING && mPendingMsg.mType == PendingMsg.STATUS_UPDATE)) { mPendingMsg = null; notifyStatusMessage(null); } } private void doOnSessionDestroyed() { if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Media session destroyed"); } if (mController != null) { mController.unregisterCallback(mMediaControllerCallback); } mController = null; mServiceAdapter = null; } private void doOnConnected() { // existing mController has been disconnected before we call MediaBrowser.connect() MediaSession.Token token = mBrowser.getSessionToken(); if (token == null) { Log.e(TAG, "Media session token is null"); return; } mController = new MediaController(mContext, token); mController.registerCallback(mMediaControllerCallback); processSearchIntent(mPendingSearchIntent); } private void doOnConnectionFailed() { Log.w(TAG, "Media browser connection FAILED!"); // disconnect anyway to make sure we get into a sanity state mBrowser.disconnect(); mBrowser = null; } private static class PendingMsg { public static final int ERROR = 0; public static final int STATUS_UPDATE = 1; public int mType; public String mMsg; public PendingMsg(int type, String msg) { mType = type; mMsg = msg; } } private static class MediaManagerCallback extends MediaController.Callback { private final WeakReference mWeakCallback; MediaManagerCallback(MediaManager callback) { mWeakCallback = new WeakReference<>(callback); } @Override public void onPlaybackStateChanged(PlaybackState playbackState) { MediaManager callback = mWeakCallback.get(); if (callback == null) { return; } callback.doPlaybackStateChanged(playbackState); } @Override public void onSessionDestroyed() { MediaManager callback = mWeakCallback.get(); if (callback == null) { return; } callback.doOnSessionDestroyed(); } } private static class MediaManagerConnectionCallback extends MediaBrowser.ConnectionCallback { private final WeakReference mWeakCallback; private MediaManagerConnectionCallback(MediaManager callback) { mWeakCallback = new WeakReference<>(callback); } @Override public void onConnected() { MediaManager callback = mWeakCallback.get(); if (callback == null) { return; } callback.doOnConnected(); } @Override public void onConnectionSuspended() {} @Override public void onConnectionFailed() { MediaManager callback = mWeakCallback.get(); if (callback == null) { return; } callback.doOnConnectionFailed(); } } }