summaryrefslogtreecommitdiff
path: root/src/com/android/car/media/MediaManager.java
blob: 605d46f5595a84c0638bbf5927c337a1e0db8c7b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
/*
 * 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();
        }
    }
}