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