1/*
2 * Copyright (C) 2016 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16package com.android.car.media;
17
18import android.app.SearchManager;
19import android.content.ComponentName;
20import android.content.Context;
21import android.content.Intent;
22import android.content.SharedPreferences;
23import android.content.pm.ApplicationInfo;
24import android.content.pm.PackageManager;
25import android.content.pm.ResolveInfo;
26import android.content.pm.ServiceInfo;
27import android.content.res.Resources;
28import android.content.res.TypedArray;
29import android.media.browse.MediaBrowser;
30import android.media.session.MediaController;
31import android.media.session.MediaSession;
32import android.media.session.PlaybackState;
33import android.os.Bundle;
34import android.service.media.MediaBrowserService;
35import android.text.TextUtils;
36import android.util.Log;
37
38import java.lang.ref.WeakReference;
39import java.util.ArrayList;
40import java.util.List;
41
42/**
43 * Manages which media app we should connect to. The manager also retrieves various attributes
44 * from the media app and share among different components in GearHead media app.
45 */
46public class MediaManager {
47    private static final String TAG = "GH.MediaManager";
48    private static final String PREFS_FILE_NAME = "MediaClientManager.Preferences";
49    /** The package of the most recently used media component **/
50    private static final String PREFS_KEY_PACKAGE = "media_package";
51    /** The class of the most recently used media class **/
52    private static final String PREFS_KEY_CLASS = "media_class";
53    /** Third-party defined application theme to use **/
54    private static final String THEME_META_DATA_NAME = "com.google.android.gms.car.application.theme";
55
56    public static final String KEY_MEDIA_COMPONENT = "media_component";
57    /** Intent extra specifying the package with the MediaBrowser **/
58    public static final String KEY_MEDIA_PACKAGE = "media_package";
59    /** Intent extra specifying the MediaBrowserService **/
60    public static final String KEY_MEDIA_CLASS = "media_class";
61
62    /**
63     * Flag for when GSA is not 100% confident on the query and therefore, the result in the
64     * {@link #KEY_MEDIA_PACKAGE_FROM_GSA} should be ignored.
65     */
66    private static final String KEY_IGNORE_ORIGINAL_PKG =
67            "com.google.android.projection.gearhead.ignore_original_pkg";
68
69    /**
70     * Intent extra specifying the package name of the media app that should handle
71     * {@link android.provider.MediaStore#INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH}. This must match
72     * KEY_PACKAGE defined in ProjectionIntentStarter in GSA.
73     */
74    public static final String KEY_MEDIA_PACKAGE_FROM_GSA =
75            "android.car.intent.extra.MEDIA_PACKAGE";
76
77    private static final String GOOGLE_PLAY_MUSIC_PACKAGE = "com.google.android.music";
78    // Extras along with the Knowledge Graph that are not meant to be seen by external apps.
79    private static final String[] INTERNAL_EXTRAS = {"KEY_LAUNCH_HANDOVER_UNDERNEATH",
80            "com.google.android.projection.gearhead.ignore_original_pkg"};
81
82    private static final Intent MEDIA_BROWSER_INTENT =
83            new Intent(MediaBrowserService.SERVICE_INTERFACE);
84    private static MediaManager sInstance;
85
86    private final MediaController.Callback mMediaControllerCallback =
87            new MediaManagerCallback(this);
88    private final MediaBrowser.ConnectionCallback mMediaBrowserConnectionCallback =
89            new MediaManagerConnectionCallback(this);
90
91    public interface Listener {
92        void onMediaAppChanged(ComponentName componentName);
93
94        /**
95         * Called when we want to show a message on playback screen.
96         * @param msg if null, dismiss any previous message and
97         *            restore the track title and subtitle.
98         */
99        void onStatusMessageChanged(String msg);
100    }
101
102    /**
103     * An adapter interface to abstract the specifics of how media services are queried. This allows
104     * for Vanagon to query for allowed media services without the need to connect to carClientApi.
105     */
106    public interface ServiceAdapter {
107        List<ResolveInfo> queryAllowedServices(Intent providerIntent);
108    }
109
110    private int mPrimaryColor;
111    private int mPrimaryColorDark;
112    private int mAccentColor;
113    private CharSequence mName;
114
115    private final Context mContext;
116    private final List<Listener> mListeners = new ArrayList<>();
117
118    private ServiceAdapter mServiceAdapter;
119    private Intent mPendingSearchIntent;
120
121    private MediaController mController;
122    private MediaBrowser mBrowser;
123    private ComponentName mCurrentComponent;
124    private PendingMsg mPendingMsg;
125
126    public synchronized static MediaManager getInstance(Context context) {
127        if (sInstance == null) {
128            sInstance = new MediaManager(context.getApplicationContext());
129        }
130        return sInstance;
131    }
132
133    private MediaManager(Context context) {
134        mContext = context;
135
136        // Set some sane default values for the attributes
137        mName = "";
138        int color = context.getResources().getColor(android.R.color.background_dark);
139        mPrimaryColor = color;
140        mAccentColor = color;
141        mPrimaryColorDark = color;
142    }
143
144    /**
145     * Returns the default component used to load media.
146     */
147    public ComponentName getDefaultComponent(ServiceAdapter serviceAdapter) {
148        SharedPreferences prefs = mContext
149                .getSharedPreferences(PREFS_FILE_NAME, Context.MODE_PRIVATE);
150        String packageName = prefs.getString(PREFS_KEY_PACKAGE, null);
151        String className = prefs.getString(PREFS_KEY_CLASS, null);
152        final Intent providerIntent = new Intent(MediaBrowserService.SERVICE_INTERFACE);
153        List<ResolveInfo> mediaApps = serviceAdapter.queryAllowedServices(providerIntent);
154
155        // check if the previous component we connected to is still valid.
156        if (packageName != null && className != null) {
157            boolean componentValid = false;
158            for (ResolveInfo info : mediaApps) {
159                if (info.serviceInfo.packageName.equals(packageName)
160                        && info.serviceInfo.name.equals(className)) {
161                    componentValid = true;
162                }
163            }
164            // if not valid, null it and we will bring up the lens switcher or connect to another
165            // app (this may happen when the app has been uninstalled)
166            if (!componentValid) {
167                packageName = null;
168                className = null;
169            }
170        }
171
172        // If there are no apps used before or previous app is not valid,
173        // try to connect to a supported media app.
174        if (packageName == null || className == null) {
175            // Only one app installed, connect to it.
176            if (mediaApps.size() == 1) {
177                ResolveInfo info = mediaApps.get(0);
178                packageName = info.serviceInfo.packageName;
179                className = info.serviceInfo.name;
180            } else {
181                // there are '0' or >1 media apps installed; don't know what to run
182                return null;
183            }
184        }
185        return new ComponentName(packageName, className);
186    }
187
188    /**
189     * Connects to the most recently used media app if it exists and return true.
190     * Otherwise check the number of supported media apps installed,
191     * if only one installed, connect to it return true. Otherwise return false.
192     */
193    public boolean connectToMostRecentMediaComponent(ServiceAdapter serviceAdapter) {
194        ComponentName component = getDefaultComponent(serviceAdapter);
195        if (component != null) {
196            setMediaClientComponent(serviceAdapter, component);
197            return true;
198        }
199        return false;
200    }
201
202    public ComponentName getCurrentComponent() {
203        return mCurrentComponent;
204    }
205
206    public void setMediaClientComponent(ComponentName component) {
207        setMediaClientComponent(null, component);
208    }
209
210    /**
211     * Change the media component. This will connect to a {@link android.media.browse.MediaBrowser} if necessary.
212     * All registered listener will be updated with the new component.
213     */
214    public void setMediaClientComponent(ServiceAdapter serviceAdapter, ComponentName component) {
215        if (Log.isLoggable(TAG, Log.VERBOSE)) {
216            Log.v(TAG, "setMediaClientComponent(), "
217                    + "component: " + (component == null ? "<< NULL >>" : component.toString()));
218        }
219
220        if (component == null) {
221            return;
222        }
223
224        // mController will be set to null if previously connected media session has crashed.
225        if (mCurrentComponent != null && mCurrentComponent.equals(component)
226                && mController != null) {
227            if (Log.isLoggable(TAG, Log.DEBUG)) {
228                Log.d(TAG, "Already connected to " + component.toString());
229            }
230            return;
231        }
232
233        mCurrentComponent = component;
234        mServiceAdapter = serviceAdapter;
235        disconnectCurrentBrowser();
236        updateClientPackageAttributes(mCurrentComponent);
237
238        if (mController != null) {
239            mController.unregisterCallback(mMediaControllerCallback);
240            mController = null;
241        }
242        mBrowser = new MediaBrowser(mContext, component, mMediaBrowserConnectionCallback, null);
243        if (Log.isLoggable(TAG, Log.DEBUG)) {
244            Log.d(TAG, "Connecting to " + component.toString());
245        }
246        mBrowser.connect();
247
248        writeComponentToPrefs(component);
249
250        ArrayList<Listener> temp = new ArrayList<Listener>(mListeners);
251        for (Listener listener : temp) {
252            listener.onMediaAppChanged(mCurrentComponent);
253        }
254    }
255
256    /**
257     * Processes the search intent using the current media app. If it's not connected yet, store it
258     * in the {@code mPendingSearchIntent} and process it when the app is connected.
259     *
260     * @param intent The intent containing the query and
261     *            MediaStore.INTENT_ACTION_MEDIA_PLAY_FROM_SEARCH action
262     */
263    public void processSearchIntent(Intent intent) {
264        if (Log.isLoggable(TAG, Log.VERBOSE)) {
265            Log.v(TAG, "processSearchIntent(), query: "
266                    + (intent == null ? "<< NULL >>" : intent.getStringExtra(SearchManager.QUERY)));
267        }
268        if (intent == null) {
269            return;
270        }
271        mPendingSearchIntent = intent;
272
273        String mediaPackageName;
274        if (intent.getBooleanExtra(KEY_IGNORE_ORIGINAL_PKG, false)) {
275            if (Log.isLoggable(TAG, Log.DEBUG)) {
276                Log.d(TAG, "Ignoring package from gsa and falling back to default media app");
277            }
278            mediaPackageName = null;
279        } else if (intent.hasExtra(KEY_MEDIA_PACKAGE_FROM_GSA)) {
280            // Legacy way of piping through the media app package.
281            mediaPackageName = intent.getStringExtra(KEY_MEDIA_PACKAGE_FROM_GSA);
282            if (Log.isLoggable(TAG, Log.DEBUG)) {
283                Log.d(TAG, "Package from extras: " + mediaPackageName);
284            }
285        } else {
286            mediaPackageName = intent.getPackage();
287            if (Log.isLoggable(TAG, Log.DEBUG)) {
288                Log.d(TAG, "Package from getPackage(): " + mediaPackageName);
289            }
290        }
291
292        if (mediaPackageName != null && mCurrentComponent != null
293                && !mediaPackageName.equals(mCurrentComponent.getPackageName())) {
294            final ComponentName componentName =
295                    getMediaBrowserComponent(mServiceAdapter, mediaPackageName);
296            if (componentName == null) {
297                Log.w(TAG, "There are no matching media app to handle intent: " + intent);
298                return;
299            }
300            setMediaClientComponent(mServiceAdapter, componentName);
301            // It's safe to return here as pending search intent will be processed
302            // when newly created media controller for the new media component is connected.
303            return;
304        }
305
306        String query = mPendingSearchIntent.getStringExtra(SearchManager.QUERY);
307        if (mController != null) {
308            mController.getTransportControls().pause();
309            mPendingMsg = new PendingMsg(PendingMsg.STATUS_UPDATE,
310                    mContext.getResources().getString(R.string.loading));
311            notifyStatusMessage(mPendingMsg.mMsg);
312            Bundle extras = mPendingSearchIntent.getExtras();
313            // Remove two extras that are not meant to be seen by external apps.
314            if (!GOOGLE_PLAY_MUSIC_PACKAGE.equals(mediaPackageName)) {
315                for (String key : INTERNAL_EXTRAS) {
316                    extras.remove(key);
317                }
318            }
319            mController.getTransportControls().playFromSearch(query, extras);
320            mPendingSearchIntent = null;
321        } else {
322            if (Log.isLoggable(TAG, Log.DEBUG)) {
323                Log.d(TAG, "No controller for search intent; save it for later");
324            }
325        }
326    }
327
328
329    private ComponentName getMediaBrowserComponent(ServiceAdapter serviceAdapter,
330            final String packageName) {
331        List<ResolveInfo> queryResults = serviceAdapter.queryAllowedServices(MEDIA_BROWSER_INTENT);
332        if (queryResults != null) {
333            for (int i = 0, N = queryResults.size(); i < N; ++i) {
334                final ResolveInfo ri = queryResults.get(i);
335                if (ri != null && ri.serviceInfo != null
336                        && ri.serviceInfo.packageName.equals(packageName)) {
337                    return new ComponentName(ri.serviceInfo.packageName, ri.serviceInfo.name);
338                }
339            }
340        }
341        return null;
342    }
343
344    /**
345     * Add a listener to get media app changes.
346     * Your listener will be called with the initial values when the listener is added.
347     */
348    public void addListener(Listener listener) {
349        mListeners.add(listener);
350        if (Log.isLoggable(TAG, Log.VERBOSE)) {
351            Log.v(TAG, "addListener(); count: " + mListeners.size());
352        }
353
354        if (mCurrentComponent != null) {
355            listener.onMediaAppChanged(mCurrentComponent);
356        }
357
358        if (mPendingMsg != null) {
359            listener.onStatusMessageChanged(mPendingMsg.mMsg);
360        }
361    }
362
363    public void removeListener(Listener listener) {
364        mListeners.remove(listener);
365
366        if (Log.isLoggable(TAG, Log.VERBOSE)) {
367            Log.v(TAG, "removeListener(); count: " + mListeners.size());
368        }
369
370        if (mListeners.size() == 0) {
371            if (Log.isLoggable(TAG, Log.DEBUG)) {
372                Log.d(TAG, "no manager listeners; destroy manager instance");
373            }
374
375            synchronized (MediaManager.class) {
376                sInstance = null;
377            }
378
379            if (mBrowser != null) {
380                mBrowser.disconnect();
381            }
382        }
383    }
384
385    public CharSequence getMediaClientName() {
386        return mName;
387    }
388
389    public int getMediaClientPrimaryColor() {
390        return mPrimaryColor;
391    }
392
393    public int getMediaClientPrimaryColorDark() {
394        return mPrimaryColorDark;
395    }
396
397    public int getMediaClientAccentColor() {
398        return mAccentColor;
399    }
400
401    private void writeComponentToPrefs(ComponentName componentName) {
402        // Store selected media service to shared preference.
403        SharedPreferences prefs = mContext
404                .getSharedPreferences(PREFS_FILE_NAME, Context.MODE_PRIVATE);
405        SharedPreferences.Editor editor = prefs.edit();
406        editor.putString(PREFS_KEY_PACKAGE, componentName.getPackageName());
407        editor.putString(PREFS_KEY_CLASS, componentName.getClassName());
408        editor.apply();
409    }
410
411    /**
412     * Disconnect from the current media browser service if any, and notify the listeners.
413     */
414    private void disconnectCurrentBrowser() {
415        if (mBrowser != null) {
416            mBrowser.disconnect();
417            mBrowser = null;
418        }
419    }
420
421    private void updateClientPackageAttributes(ComponentName componentName) {
422        TypedArray ta = null;
423        try {
424            String packageName = componentName.getPackageName();
425            ApplicationInfo applicationInfo =
426                    mContext.getPackageManager().getApplicationInfo(packageName,
427                            PackageManager.GET_META_DATA);
428            ServiceInfo serviceInfo = mContext.getPackageManager().getServiceInfo(
429                    componentName, PackageManager.GET_META_DATA);
430
431            // Get the proper app name, check service label, then application label.
432            CharSequence name = "";
433            if (serviceInfo.labelRes != 0) {
434                name = serviceInfo.loadLabel(mContext.getPackageManager());
435            } else if (applicationInfo.labelRes != 0) {
436                name = applicationInfo.loadLabel(mContext.getPackageManager());
437            }
438            if (TextUtils.isEmpty(name)) {
439                name = mContext.getResources().getString(R.string.unknown_media_provider_name);
440            }
441            mName = name;
442
443            // Get the proper theme, check theme for service, then application.
444            int appTheme = 0;
445            if (serviceInfo.metaData != null) {
446                appTheme = serviceInfo.metaData.getInt(THEME_META_DATA_NAME);
447            }
448            if (appTheme == 0 && applicationInfo.metaData != null) {
449                appTheme = applicationInfo.metaData.getInt(THEME_META_DATA_NAME);
450            }
451            if (appTheme == 0) {
452                appTheme = applicationInfo.theme;
453            }
454
455            Context packageContext = mContext.createPackageContext(packageName, 0);
456            packageContext.setTheme(appTheme);
457            Resources.Theme theme = packageContext.getTheme();
458            ta = theme.obtainStyledAttributes(new int[] {
459                    android.R.attr.colorPrimary,
460                    android.R.attr.colorAccent,
461                    android.R.attr.colorPrimaryDark
462            });
463            int defaultColor =
464                    mContext.getResources().getColor(android.R.color.background_dark);
465            mPrimaryColor = ta.getColor(0, defaultColor);
466            mAccentColor = ta.getColor(1, defaultColor);
467            mPrimaryColorDark = ta.getColor(2, defaultColor);
468        } catch (PackageManager.NameNotFoundException e) {
469            Log.e(TAG, "Unable to update media client package attributes.", e);
470        } finally {
471            if (ta != null) {
472                ta.recycle();
473            }
474        }
475    }
476
477    private void notifyStatusMessage(String str) {
478        for (Listener l : mListeners) {
479            l.onStatusMessageChanged(str);
480        }
481    }
482
483    private void doPlaybackStateChanged(PlaybackState playbackState) {
484        // Display error message in MediaPlaybackFragment.
485        if (mPendingMsg == null) {
486            return;
487        }
488        // Dismiss the error msg if any,
489        // and dismiss status update msg if the state is now playing
490        if ((mPendingMsg.mType == PendingMsg.ERROR) ||
491                (playbackState.getState() == PlaybackState.STATE_PLAYING
492                        && mPendingMsg.mType == PendingMsg.STATUS_UPDATE)) {
493            mPendingMsg = null;
494            notifyStatusMessage(null);
495        }
496    }
497
498    private void doOnSessionDestroyed() {
499        if (Log.isLoggable(TAG, Log.VERBOSE)) {
500            Log.v(TAG, "Media session destroyed");
501        }
502        if (mController != null) {
503            mController.unregisterCallback(mMediaControllerCallback);
504        }
505        mController = null;
506        mServiceAdapter = null;
507    }
508
509    private void doOnConnected() {
510        // existing mController has been disconnected before we call MediaBrowser.connect()
511        MediaSession.Token token = mBrowser.getSessionToken();
512        if (token == null) {
513            Log.e(TAG, "Media session token is null");
514            return;
515        }
516        mController = new MediaController(mContext, token);
517        mController.registerCallback(mMediaControllerCallback);
518        processSearchIntent(mPendingSearchIntent);
519    }
520
521    private void doOnConnectionFailed() {
522        Log.w(TAG, "Media browser connection FAILED!");
523        // disconnect anyway to make sure we get into a sanity state
524        mBrowser.disconnect();
525        mBrowser = null;
526    }
527
528    private static class PendingMsg {
529        public static final int ERROR = 0;
530        public static final int STATUS_UPDATE = 1;
531
532        public int mType;
533        public String mMsg;
534        public PendingMsg(int type, String msg) {
535            mType = type;
536            mMsg = msg;
537        }
538    }
539
540    private static class MediaManagerCallback extends MediaController.Callback {
541        private final WeakReference<MediaManager> mWeakCallback;
542
543        MediaManagerCallback(MediaManager callback) {
544            mWeakCallback = new WeakReference<>(callback);
545        }
546
547        @Override
548        public void onPlaybackStateChanged(PlaybackState playbackState) {
549            MediaManager callback = mWeakCallback.get();
550            if (callback == null) {
551                return;
552            }
553            callback.doPlaybackStateChanged(playbackState);
554        }
555
556        @Override
557        public void onSessionDestroyed() {
558            MediaManager callback = mWeakCallback.get();
559            if (callback == null) {
560                return;
561            }
562            callback.doOnSessionDestroyed();
563        }
564    }
565
566    private static class MediaManagerConnectionCallback extends MediaBrowser.ConnectionCallback {
567        private final WeakReference<MediaManager> mWeakCallback;
568
569        private MediaManagerConnectionCallback(MediaManager callback) {
570            mWeakCallback = new WeakReference<>(callback);
571        }
572
573        @Override
574        public void onConnected() {
575            MediaManager callback = mWeakCallback.get();
576            if (callback == null) {
577                return;
578            }
579            callback.doOnConnected();
580        }
581
582        @Override
583        public void onConnectionSuspended() {}
584
585        @Override
586        public void onConnectionFailed() {
587            MediaManager callback = mWeakCallback.get();
588            if (callback == null) {
589                return;
590            }
591            callback.doOnConnectionFailed();
592        }
593    }
594}
595