| /* |
| * Copyright 2018 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.common; |
| |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.content.ComponentName; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.pm.ApplicationInfo; |
| import android.content.pm.PackageManager; |
| import android.content.pm.ResolveInfo; |
| import android.content.pm.ServiceInfo; |
| import android.graphics.Bitmap; |
| import android.graphics.Canvas; |
| import android.graphics.Paint; |
| import android.graphics.PorterDuff; |
| import android.graphics.PorterDuffXfermode; |
| import android.graphics.Rect; |
| import android.graphics.drawable.BitmapDrawable; |
| import android.graphics.drawable.Drawable; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.os.RemoteException; |
| import android.service.media.MediaBrowserService; |
| import android.support.v4.media.MediaBrowserCompat; |
| import android.support.v4.media.session.MediaControllerCompat; |
| import android.support.v4.media.session.MediaSessionCompat; |
| import android.util.Log; |
| |
| import java.util.ArrayList; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.Set; |
| import java.util.function.Consumer; |
| import java.util.stream.Collectors; |
| |
| /** |
| * This represents a source of media content. It provides convenient methods to access media source |
| * metadata, such as primary color and application name. |
| * |
| * <p>It also allows consumers to subscribe to its {@link android.service.media.MediaBrowserService} |
| * if such service is implemented by the source. |
| */ |
| public class MediaSource { |
| private static final String TAG = "MediaSource"; |
| |
| /** Number of times we will retry obtaining the list of children of a certain node */ |
| private static final int CHILDREN_SUBSCRIPTION_RETRIES = 3; |
| /** Time between retries while trying to obtain the list of children of a certain node */ |
| private static final int CHILDREN_SUBSCRIPTION_RETRY_TIME_MS = 1000; |
| |
| private final String mPackageName; |
| @Nullable |
| private final String mBrowseServiceClassName; |
| @Nullable |
| private final MediaBrowserCompat mBrowser; |
| private final Context mContext; |
| private final Handler mHandler = new Handler(); |
| private List<Observer> mObservers = new ArrayList<>(); |
| private CharSequence mName; |
| private String mRootNode; |
| |
| /** |
| * Custom media sources which should not be templatized. |
| */ |
| private static final Set<String> CUSTOM_MEDIA_SOURCES = new HashSet<>(); |
| static { |
| CUSTOM_MEDIA_SOURCES.add("com.android.car.radio"); |
| } |
| |
| /** |
| * An observer of this media source. |
| */ |
| public abstract static class Observer { |
| /** |
| * This method is called if a successful connection to the {@link MediaBrowserService} is |
| * made for this source. A connection is initiated as soon as there is at least one |
| * {@link Observer} subscribed by using {@link MediaSource#subscribe(Observer)}. |
| * |
| * @param success true if the connection was successful or false otherwise. |
| */ |
| protected void onBrowseConnected(boolean success) {}; |
| |
| /** |
| * This method is called if the connection to the {@link MediaBrowserService} is lost. |
| */ |
| protected void onBrowseDisconnected() {}; |
| } |
| |
| /** |
| * A subscription to a collection of items |
| */ |
| public interface ItemsSubscription { |
| /** |
| * This method is called whenever media items are loaded or updated. |
| * |
| * @param mediaSource {@link MediaSource} these items belongs to |
| * @param parentId identifier of the items parent. |
| * @param items items loaded, or null if there was an error trying to load them. |
| */ |
| void onChildrenLoaded(MediaSource mediaSource, String parentId, |
| @Nullable List<MediaItemMetadata> items); |
| } |
| |
| private final MediaBrowserCompat.ConnectionCallback mConnectionCallback = |
| new MediaBrowserCompat.ConnectionCallback() { |
| @Override |
| public void onConnected() { |
| MediaSource.this.notify(observer -> observer.onBrowseConnected(true)); |
| } |
| |
| @Override |
| public void onConnectionSuspended() { |
| MediaSource.this.notify(Observer::onBrowseDisconnected); |
| } |
| |
| @Override |
| public void onConnectionFailed() { |
| MediaSource.this.notify(observer -> observer.onBrowseConnected(false)); |
| } |
| }; |
| |
| /** |
| * Creates a {@link MediaSource} for the given application package name |
| */ |
| public MediaSource(Context context, String packageName) { |
| mContext = context; |
| mPackageName = packageName; |
| mBrowseServiceClassName = getBrowseServiceClassName(packageName); |
| if (mBrowseServiceClassName != null) { |
| mBrowser = new MediaBrowserCompat(mContext, |
| new ComponentName(mPackageName, mBrowseServiceClassName), |
| mConnectionCallback, |
| null); |
| } else { |
| // This media source doesn't provide browsing. |
| mBrowser = null; |
| } |
| extractComponentInfo(mPackageName, mBrowseServiceClassName); |
| } |
| |
| /** |
| * @return the classname corresponding to a {@link MediaBrowserService} in the |
| * media source, or null if the media source doesn't implement {@link MediaBrowserService}. |
| * A non-null result doesn't imply that this service is accessible. The consumer code should |
| * attempt to connect and handle rejections gracefully. |
| */ |
| @Nullable |
| private String getBrowseServiceClassName(String packageName) { |
| PackageManager packageManager = mContext.getPackageManager(); |
| Intent intent = new Intent(); |
| intent.setAction(MediaBrowserService.SERVICE_INTERFACE); |
| intent.setPackage(packageName); |
| List<ResolveInfo> resolveInfos = packageManager.queryIntentServices(intent, |
| PackageManager.GET_RESOLVED_FILTER); |
| if (resolveInfos == null || resolveInfos.isEmpty()) { |
| return null; |
| } |
| return resolveInfos.get(0).serviceInfo.name; |
| } |
| |
| /** |
| * Subscribes to this media source. This allows consuming browse information. |
| |
| * @return true if a subscription could be added, or false otherwise (for example, if the |
| * {@link MediaBrowserService} is not available for this source. |
| */ |
| public boolean subscribe(Observer observer) { |
| if (mBrowser == null) { |
| return false; |
| } |
| mObservers.add(observer); |
| if (!mBrowser.isConnected()) { |
| try { |
| mBrowser.connect(); |
| } catch (IllegalStateException ex) { |
| // Ignore: MediaBrowse could be in an intermediate state (not connected, but not |
| // disconnected either.). In this situation, trying to connect again can throw |
| // this exception, but there is no way to know without trying. |
| } |
| } else { |
| observer.onBrowseConnected(true); |
| } |
| return true; |
| } |
| |
| /** |
| * Unsubscribe from this media source |
| */ |
| public void unsubscribe(Observer observer) { |
| mObservers.remove(observer); |
| if (mObservers.isEmpty()) { |
| // TODO(b/77640010): Review MediaBrowse disconnection. |
| // Some media sources are not responding correctly to MediaBrowser#disconnect(). We |
| // are keeping the connection going. |
| // mBrowser.disconnect(); |
| } |
| } |
| |
| private Map<String, ChildrenSubscription> mChildrenSubscriptions = new HashMap<>(); |
| |
| /** |
| * Obtains the root node in the browse tree of this node. |
| */ |
| public String getRoot() { |
| if (mRootNode == null) { |
| mRootNode = mBrowser.getRoot(); |
| } |
| return mRootNode; |
| } |
| |
| /** |
| * Subscribes to changes on the list of media item children of the given parent. Multiple |
| * subscription can be added to the same node. If the node has been already loaded, then all |
| * new subscription will immediately obtain a copy of the last obtained list. |
| * |
| * @param parentId parent of the children to load, or null to indicate children of the root |
| * node. |
| * @param callback callback used to provide updates on the subscribed node. |
| * @throws IllegalStateException if browsing is not available or it is not connected. |
| */ |
| public void subscribeChildren(@Nullable String parentId, ItemsSubscription callback) { |
| if (mBrowser == null) { |
| throw new IllegalStateException("Browsing is not available for this source: " |
| + getName()); |
| } |
| if (mRootNode == null && !mBrowser.isConnected()) { |
| throw new IllegalStateException("Subscribing to the root node can only be done while " |
| + "connected: " + getName()); |
| } |
| mRootNode = mBrowser.getRoot(); |
| |
| String itemId = parentId != null ? parentId : mRootNode; |
| ChildrenSubscription subscription = mChildrenSubscriptions.get(itemId); |
| if (subscription != null) { |
| subscription.add(callback); |
| } else { |
| subscription = new ChildrenSubscription(mBrowser, itemId); |
| subscription.add(callback); |
| mChildrenSubscriptions.put(itemId, subscription); |
| subscription.start(CHILDREN_SUBSCRIPTION_RETRIES, |
| CHILDREN_SUBSCRIPTION_RETRY_TIME_MS); |
| } |
| } |
| |
| /** |
| * Unsubscribes to changes on the list of media items children of the given parent |
| * |
| * @param parentId parent to unsubscribe, or null to unsubscribe from the root node. |
| * @param callback callback to remove |
| * @throws IllegalStateException if browsing is not available or it is not connected. |
| */ |
| public void unsubscribeChildren(@Nullable String parentId, ItemsSubscription callback) { |
| // If we are not connected |
| if (mBrowser == null) { |
| throw new IllegalStateException("Browsing is not available for this source: " |
| + getName()); |
| } |
| if (parentId == null && mRootNode == null) { |
| // If we are trying to unsubscribe from root, but we haven't determine it's Id, then |
| // there is nothing we can do. |
| return; |
| } |
| |
| String itemId = parentId != null ? parentId : mRootNode; |
| ChildrenSubscription subscription = mChildrenSubscriptions.get(itemId); |
| if (subscription != null) { |
| subscription.remove(callback); |
| if (subscription.count() == 0) { |
| subscription.stop(); |
| mChildrenSubscriptions.remove(itemId); |
| } |
| } |
| } |
| |
| /** |
| * {@link MediaBrowserCompat.SubscriptionCallback} wrapper used to overcome the lack of a |
| * reliable method to obtain the initial list of children of a given node. |
| * <p> |
| * When some 3rd party apps go through configuration changes (i.e., in the case of user-switch), |
| * they leave subscriptions in an intermediate state where neither |
| * {@link MediaBrowserCompat.SubscriptionCallback#onChildrenLoaded(String, List)} nor |
| * {@link MediaBrowserCompat.SubscriptionCallback#onError(String)} are invoked. |
| * <p> |
| * This wrapper works around this problem by retrying the subscription a given number of times |
| * if no data is received after a certain amount of time. This process is started by calling |
| * {@link #start(int, int)}, passing the number of retries and delay between them as |
| * parameters. |
| */ |
| private class ChildrenSubscription extends MediaBrowserCompat.SubscriptionCallback { |
| private List<MediaItemMetadata> mItems; |
| private boolean mIsDataLoaded; |
| private List<ItemsSubscription> mSubscriptions = new ArrayList<>(); |
| private String mParentId; |
| private int mRetries; |
| private int mRetryDelay; |
| private MediaBrowserCompat mMediaBrowser; |
| private Runnable mRetryRunnable = new Runnable() { |
| @Override |
| public void run() { |
| if (!mIsDataLoaded) { |
| if (mRetries > 0) { |
| mRetries--; |
| mMediaBrowser.unsubscribe(mParentId); |
| mMediaBrowser.subscribe(mParentId, ChildrenSubscription.this); |
| mHandler.postDelayed(this, mRetryDelay); |
| } else { |
| mItems = null; |
| mIsDataLoaded = true; |
| notifySubscriptions(); |
| } |
| } |
| } |
| }; |
| |
| /** |
| * Creates a subscription to the list of children of a certain media browse item |
| * |
| * @param mediaBrowser {@link MediaBrowserCompat} used to create the subscription |
| * @param parentId identifier of the parent node to subscribe to |
| */ |
| ChildrenSubscription(@NonNull MediaBrowserCompat mediaBrowser, String parentId) { |
| mParentId = parentId; |
| mMediaBrowser = mediaBrowser; |
| } |
| |
| /** |
| * Adds a subscriber to this list of children |
| */ |
| void add(ItemsSubscription subscription) { |
| mSubscriptions.add(subscription); |
| if (mIsDataLoaded) { |
| subscription.onChildrenLoaded(MediaSource.this, mParentId, mItems); |
| } |
| } |
| |
| /** |
| * Removes a subscriber previously added with {@link #add(ItemsSubscription)} |
| */ |
| void remove(ItemsSubscription subscription) { |
| mSubscriptions.remove(subscription); |
| } |
| |
| /** |
| * Number of subscribers currently registered |
| */ |
| int count() { |
| return mSubscriptions.size(); |
| } |
| |
| /** |
| * Starts trying to obtain the list of children |
| * |
| * @param retries number of times to retry. If children are not obtained in this time then |
| * the {@link ItemsSubscription#onChildrenLoaded(MediaSource, String, List)} |
| * will be invoked with a NULL list. |
| * @param retryDelay time between retries in milliseconds |
| */ |
| void start(int retries, int retryDelay) { |
| if (mIsDataLoaded) { |
| notifySubscriptions(); |
| mMediaBrowser.subscribe(mParentId, this); |
| } else { |
| mRetries = retries; |
| mRetryDelay = retryDelay; |
| mHandler.post(mRetryRunnable); |
| } |
| } |
| |
| /** |
| * Stops retrying |
| */ |
| void stop() { |
| mHandler.removeCallbacks(mRetryRunnable); |
| mMediaBrowser.unsubscribe(mParentId); |
| } |
| |
| @Override |
| public void onChildrenLoaded(String parentId, |
| List<MediaBrowserCompat.MediaItem> children) { |
| mHandler.removeCallbacks(mRetryRunnable); |
| mItems = children.stream() |
| .map(child -> new MediaItemMetadata(child)) |
| .collect(Collectors.toList()); |
| mIsDataLoaded = true; |
| notifySubscriptions(); |
| } |
| |
| @Override |
| public void onChildrenLoaded(String parentId, List<MediaBrowserCompat.MediaItem> children, |
| Bundle options) { |
| onChildrenLoaded(parentId, children); |
| } |
| |
| @Override |
| public void onError(String parentId) { |
| mHandler.removeCallbacks(mRetryRunnable); |
| mItems = null; |
| mIsDataLoaded = true; |
| notifySubscriptions(); |
| } |
| |
| @Override |
| public void onError(String parentId, Bundle options) { |
| onError(parentId); |
| } |
| |
| private void notifySubscriptions() { |
| for (ItemsSubscription subscription : mSubscriptions) { |
| subscription.onChildrenLoaded(MediaSource.this, mParentId, mItems); |
| } |
| } |
| } |
| |
| private void extractComponentInfo(@NonNull String packageName, |
| @Nullable String browseServiceClassName) { |
| try { |
| ApplicationInfo applicationInfo = |
| mContext.getPackageManager().getApplicationInfo(packageName, |
| PackageManager.GET_META_DATA); |
| ServiceInfo serviceInfo = browseServiceClassName != null |
| ? mContext.getPackageManager().getServiceInfo( |
| new ComponentName(packageName, browseServiceClassName), |
| PackageManager.GET_META_DATA) |
| : null; |
| |
| // Get the proper app name, check service label, then application label. |
| if (serviceInfo != null && serviceInfo.labelRes != 0) { |
| mName = serviceInfo.loadLabel(mContext.getPackageManager()); |
| } else if (applicationInfo.labelRes != 0) { |
| mName = applicationInfo.loadLabel(mContext.getPackageManager()); |
| } else { |
| mName = null; |
| } |
| } catch (PackageManager.NameNotFoundException e) { |
| Log.w(TAG, "Unable to update media client package attributes.", e); |
| } |
| } |
| |
| private void notify(Consumer<Observer> notification) { |
| mHandler.post(() -> { |
| List<Observer> observers = new ArrayList<>(mObservers); |
| for (Observer observer : observers) { |
| notification.accept(observer); |
| } |
| }); |
| } |
| |
| /** |
| * @return media source human readable name. |
| */ |
| public CharSequence getName() { |
| return mName; |
| } |
| |
| /** |
| * @return the package name that identifies this media source. |
| */ |
| public String getPackageName() { |
| return mPackageName; |
| } |
| |
| /** |
| * @return a {@link ComponentName} referencing this media source's {@link MediaBrowserService}, |
| * or NULL if this media source doesn't implement such service. |
| */ |
| @Nullable |
| public ComponentName getBrowseServiceComponentName() { |
| if (mBrowseServiceClassName != null) { |
| return new ComponentName(mPackageName, mBrowseServiceClassName); |
| } else { |
| return null; |
| } |
| } |
| |
| /** |
| * Returns a {@link MediaControllerCompat} that allows controlling this media source, or NULL |
| * if the media source doesn't support browsing or the browser is not connected. |
| */ |
| @Nullable |
| public MediaControllerCompat getMediaController() { |
| if (mBrowser == null || !mBrowser.isConnected()) { |
| return null; |
| } |
| |
| MediaSessionCompat.Token token = mBrowser.getSessionToken(); |
| try { |
| return new MediaControllerCompat(mContext, token); |
| } catch (RemoteException e) { |
| Log.e(TAG, "Couldn't get MediaControllerCompat", e); |
| return null; |
| } |
| } |
| |
| /** |
| * Returns this media source's icon as a {@link Drawable} |
| */ |
| public Drawable getPackageIcon() { |
| try { |
| return mContext.getPackageManager().getApplicationIcon(getPackageName()); |
| } catch (PackageManager.NameNotFoundException e) { |
| return null; |
| } |
| } |
| |
| /** |
| * Returns this media source's icon cropped to a circle. |
| */ |
| public Bitmap getRoundPackageIcon() { |
| Drawable packageIcon = getPackageIcon(); |
| return packageIcon != null |
| ? getRoundCroppedBitmap(drawableToBitmap(getPackageIcon())) |
| : null; |
| } |
| |
| /** |
| * Indicates if this media source should not be templatize. |
| */ |
| public boolean isCustom() { |
| return CUSTOM_MEDIA_SOURCES.contains(mPackageName); |
| } |
| |
| private Bitmap drawableToBitmap(Drawable drawable) { |
| Bitmap bitmap = null; |
| |
| if (drawable instanceof BitmapDrawable) { |
| BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable; |
| if (bitmapDrawable.getBitmap() != null) { |
| return bitmapDrawable.getBitmap(); |
| } |
| } |
| |
| if (drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0) { |
| bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); |
| } else { |
| bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), |
| drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); |
| } |
| |
| Canvas canvas = new Canvas(bitmap); |
| drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); |
| drawable.draw(canvas); |
| return bitmap; |
| } |
| |
| private Bitmap getRoundCroppedBitmap(Bitmap bitmap) { |
| Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), |
| Bitmap.Config.ARGB_8888); |
| Canvas canvas = new Canvas(output); |
| |
| final int color = 0xff424242; |
| final Paint paint = new Paint(); |
| final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); |
| |
| paint.setAntiAlias(true); |
| canvas.drawARGB(0, 0, 0, 0); |
| paint.setColor(color); |
| canvas.drawCircle(bitmap.getWidth() / 2, bitmap.getHeight() / 2, |
| bitmap.getWidth() / 2f, paint); |
| paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); |
| canvas.drawBitmap(bitmap, rect, rect, paint); |
| return output; |
| } |
| |
| @Override |
| public boolean equals(Object o) { |
| if (this == o) return true; |
| if (o == null || getClass() != o.getClass()) return false; |
| MediaSource that = (MediaSource) o; |
| return Objects.equals(mPackageName, that.mPackageName) |
| && Objects.equals(mBrowseServiceClassName, that.mBrowseServiceClassName); |
| } |
| |
| /** @return the current media browser. This media browser might not be connected yet. */ |
| public MediaBrowserCompat getMediaBrowser() { |
| return mBrowser; |
| } |
| |
| @Override |
| public int hashCode() { |
| return Objects.hash(mPackageName, mBrowseServiceClassName); |
| } |
| |
| @Override |
| public String toString() { |
| return getPackageName(); |
| } |
| } |