blob: 605d46f5595a84c0638bbf5927c337a1e0db8c7b [file] [log] [blame]
/*
* 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.
*/
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<ResolveInfo> queryAllowedServices(Intent providerIntent);
}
private int mPrimaryColor;
private int mPrimaryColorDark;
private int mAccentColor;
private CharSequence mName;
private final Context mContext;
private final List<Listener> 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<ResolveInfo> 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<Listener> temp = new ArrayList<Listener>(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<ResolveInfo> 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<MediaManager> 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<MediaManager> 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();
}
}
}