1 /*
2  * Copyright 2018 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  */
16 
17 package com.android.car.media.common;
18 
19 import android.content.Context;
20 import android.content.res.Resources;
21 import android.content.res.TypedArray;
22 import android.graphics.Bitmap;
23 import android.graphics.Color;
24 import android.graphics.drawable.BitmapDrawable;
25 import android.graphics.drawable.Drawable;
26 import android.net.Uri;
27 import android.os.Bundle;
28 import android.os.Parcel;
29 import android.os.Parcelable;
30 import android.support.v4.media.MediaBrowserCompat;
31 import android.support.v4.media.MediaDescriptionCompat;
32 import android.support.v4.media.MediaMetadataCompat;
33 import android.support.v4.media.session.MediaSessionCompat;
34 import android.text.TextUtils;
35 
36 import androidx.annotation.NonNull;
37 import androidx.annotation.Nullable;
38 import androidx.annotation.VisibleForTesting;
39 
40 import com.android.car.apps.common.BitmapUtils;
41 import com.android.car.apps.common.CommonFlags;
42 import com.android.car.apps.common.imaging.ImageBinder;
43 import com.android.car.apps.common.imaging.ImageBinder.PlaceholderType;
44 
45 import java.util.ArrayList;
46 import java.util.HashMap;
47 import java.util.List;
48 import java.util.Map;
49 import java.util.Objects;
50 
51 /**
52  * Abstract representation of a media item metadata.
53  *
54  * For media art, only local uris are supported so downloads can be attributed to the media app.
55  * Bitmaps are not supported because they slow down the binder.
56  */
57 public class MediaItemMetadata implements Parcelable {
58     private static final String TAG = "MediaItemMetadata";
59 
60     static final int INVALID_MEDIA_ART_TINT_COLOR = Color.argb(200, 255, 0, 0);
61 
62     @NonNull
63     private final MediaDescriptionCompat mMediaDescription;
64     @Nullable
65     private final Long mQueueId;
66     private final boolean mIsBrowsable;
67     private final boolean mIsPlayable;
68     private final String mAlbumTitle;
69     private final String mArtist;
70     private final ArtworkRef mArtworkKey = new ArtworkRef();
71 
72 
73     /** Creates an instance based on a {@link MediaMetadataCompat} */
MediaItemMetadata(@onNull MediaMetadataCompat metadata)74     public MediaItemMetadata(@NonNull MediaMetadataCompat metadata) {
75         this(metadata.getDescription(), null, false, false,
76                 metadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM),
77                 metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST));
78     }
79 
80     /** Creates an instance based on a {@link MediaSessionCompat.QueueItem} */
MediaItemMetadata(@onNull MediaSessionCompat.QueueItem queueItem)81     public MediaItemMetadata(@NonNull MediaSessionCompat.QueueItem queueItem) {
82         this(queueItem.getDescription(), queueItem.getQueueId(), false, true, null, null);
83     }
84 
85     /** Creates an instance based on a {@link MediaBrowserCompat.MediaItem} */
MediaItemMetadata(@onNull MediaBrowserCompat.MediaItem item)86     public MediaItemMetadata(@NonNull MediaBrowserCompat.MediaItem item) {
87         this(item.getDescription(), null, item.isBrowsable(), item.isPlayable(), null, null);
88     }
89 
90     /** Creates an instance based on a {@link Parcel} */
MediaItemMetadata(@onNull Parcel in)91     public MediaItemMetadata(@NonNull Parcel in) {
92         mMediaDescription = (MediaDescriptionCompat) in.readValue(
93                 MediaDescriptionCompat.class.getClassLoader());
94         mQueueId = in.readByte() == 0x00 ? null : in.readLong();
95         mIsBrowsable = in.readByte() != 0x00;
96         mIsPlayable = in.readByte() != 0x00;
97         mAlbumTitle = in.readString();
98         mArtist = in.readString();
99     }
100 
101     @VisibleForTesting
MediaItemMetadata(MediaDescriptionCompat description, Long queueId, boolean isBrowsable, boolean isPlayable, String albumTitle, String artist)102     public MediaItemMetadata(MediaDescriptionCompat description, Long queueId, boolean isBrowsable,
103             boolean isPlayable, String albumTitle, String artist) {
104         mMediaDescription = description;
105         mQueueId = queueId;
106         mIsPlayable = isPlayable;
107         mIsBrowsable = isBrowsable;
108         mAlbumTitle = albumTitle;
109         mArtist = artist;
110     }
111 
112     /**
113      * The key to access the image to display for this media item.
114      * Implemented as a class so that later we can support showing different images for the same
115      * item (eg: cover and author) by adding other keys.
116      */
117     public class ArtworkRef implements ImageBinder.ImageRef {
118 
getBitmapToFlag(Context context)119         private @Nullable Bitmap getBitmapToFlag(Context context) {
120             CommonFlags flags = CommonFlags.getInstance(context);
121             return (flags.shouldFlagImproperImageRefs() && (mMediaDescription != null))
122                     ? mMediaDescription.getIconBitmap() : null;
123         }
124 
getPlaceholderHash()125         private int getPlaceholderHash() {
126             // Only the title is reliably populated in metadata, since the album/artist fields
127             // aren't set in the items retrieved from the browse service (only Title/Subtitle).
128             return (getTitle() != null) ? getTitle().hashCode() : 0;
129         }
130 
131         @Override
toString()132         public String toString() {
133             return "title: " + getTitle() + " uri: " + getNonEmptyArtworkUri();
134         }
135 
136         @Override
getImageURI()137         public @Nullable Uri getImageURI() {
138             return getNonEmptyArtworkUri();
139         }
140 
141         @Override
equals(Context context, Object o)142         public boolean equals(Context context, Object o) {
143             if (this == o) return true;
144             if (o == null || getClass() != o.getClass()) return false;
145             ArtworkRef other = (ArtworkRef) o;
146 
147             Bitmap myBitmap = getBitmapToFlag(context);
148             Bitmap otherBitmap = other.getBitmapToFlag(context);
149             if ((myBitmap != null) || (otherBitmap != null)) {
150                 return Objects.equals(myBitmap, otherBitmap);
151             }
152 
153             Uri myUri = getImageURI();
154             Uri otherUri = other.getImageURI();
155             if ((myUri != null) || (otherUri != null)) {
156                 return Objects.equals(myUri, otherUri);
157             }
158 
159             return getPlaceholderHash() == other.getPlaceholderHash();
160         }
161 
162         @Override
getImage(Context context)163         public @Nullable Drawable getImage(Context context) {
164             Bitmap bitmap = getBitmapToFlag(context);
165             if (bitmap != null) {
166                 Resources res = context.getResources();
167                 return new BitmapDrawable(res, BitmapUtils.createTintedBitmap(bitmap,
168                         context.getColor(
169                                 com.android.car.apps.common.R.color.improper_image_refs_tint_color
170                         )));
171             }
172             return null;
173         }
174 
175         @Override
getPlaceholder(Context context, @NonNull PlaceholderType type)176         public Drawable getPlaceholder(Context context, @NonNull PlaceholderType type) {
177             if (type == PlaceholderType.NONE) return null;
178 
179             List<Drawable> placeholders = getPlaceHolders(type, context);
180             int random = Math.floorMod(getPlaceholderHash(), placeholders.size());
181             return placeholders.get(random);
182         }
183     }
184 
185     /** @return media item id */
186     @Nullable
getId()187     public String getId() {
188         return mMediaDescription.getMediaId();
189     }
190 
191     /** @return media item title */
192     @Nullable
getTitle()193     public CharSequence getTitle() {
194         return mMediaDescription.getTitle();
195     }
196 
197     /** @return media item subtitle */
198     @Nullable
getSubtitle()199     public CharSequence getSubtitle() {
200         return mMediaDescription.getSubtitle();
201     }
202 
203     /** @return the album title for the media */
204     @Nullable
getAlbumTitle()205     public String getAlbumTitle() {
206         return mAlbumTitle;
207     }
208 
209     /** @return the artist of the media */
210     @Nullable
getArtist()211     public CharSequence getArtist() {
212         return mArtist;
213     }
214 
215     /**
216      * @return the id of this item in the session queue, or NULL if this is not a session queue
217      * item.
218      */
219     @Nullable
getQueueId()220     public Long getQueueId() {
221         return mQueueId;
222     }
223 
224 
getArtworkKey()225     public ArtworkRef getArtworkKey() {
226         return mArtworkKey;
227     }
228 
229     /**
230      * @return a {@link Uri} referencing the artwork's bitmap.
231      */
getNonEmptyArtworkUri()232     private @Nullable Uri getNonEmptyArtworkUri() {
233         Uri uri = mMediaDescription.getIconUri();
234         return (uri != null && !TextUtils.isEmpty(uri.toString())) ? uri : null;
235     }
236 
237     /**
238      * @return optional extras that can include extra information about the media item to be played.
239      */
getExtras()240     public Bundle getExtras() {
241         return mMediaDescription.getExtras();
242     }
243 
244     /**
245      * @return boolean that indicate if media is explicit.
246      */
isExplicit()247     public boolean isExplicit() {
248         Bundle extras = mMediaDescription.getExtras();
249         return extras != null && extras.getLong(MediaConstants.EXTRA_IS_EXPLICIT)
250                 == MediaConstants.EXTRA_METADATA_ENABLED_VALUE;
251     }
252 
253     /**
254      * @return boolean that indicate if media is downloaded.
255      */
isDownloaded()256     public boolean isDownloaded() {
257         Bundle extras = mMediaDescription.getExtras();
258         return extras != null && extras.getLong(MediaConstants.EXTRA_DOWNLOAD_STATUS)
259                 == MediaDescriptionCompat.STATUS_DOWNLOADED;
260     }
261 
262     private static Map<PlaceholderType, List<Drawable>> sPlaceHolders = new HashMap<>();
263 
getPlaceHolders(PlaceholderType type, Context context)264     private static List<Drawable> getPlaceHolders(PlaceholderType type, Context context) {
265         List<Drawable> placeHolders = sPlaceHolders.get(type);
266         if (placeHolders == null) {
267             TypedArray placeholderImages = context.getResources().obtainTypedArray(
268                     type == PlaceholderType.FOREGROUND
269                             ? R.array.placeholder_images : R.array.placeholder_backgrounds);
270 
271             if (placeholderImages == null) {
272                 throw new NullPointerException("No placeholders for " + type);
273             }
274 
275             placeHolders = new ArrayList<>(placeholderImages.length());
276             for (int i = 0; i < placeholderImages.length(); i++) {
277                 placeHolders.add(placeholderImages.getDrawable(i));
278             }
279             placeholderImages.recycle();
280             sPlaceHolders.put(type, placeHolders);
281 
282             if (sPlaceHolders.size() <= 0) {
283                 throw new Resources.NotFoundException("Placeholders should not be empty " + type);
284             }
285         }
286         return placeHolders;
287     }
288 
isBrowsable()289     public boolean isBrowsable() {
290         return mIsBrowsable;
291     }
292 
293     /**
294      * @return Content style hint for browsable items, if provided as an extra, or
295      * 0 as default value if not provided.
296      */
getBrowsableContentStyleHint()297     public int getBrowsableContentStyleHint() {
298         Bundle extras = mMediaDescription.getExtras();
299         if (extras != null) {
300             if (extras.containsKey(MediaConstants.CONTENT_STYLE_BROWSABLE_HINT)) {
301                 return extras.getInt(MediaConstants.CONTENT_STYLE_BROWSABLE_HINT, 0);
302             } else if (extras.containsKey(MediaConstants.CONTENT_STYLE_BROWSABLE_HINT_PRERELEASE)) {
303                 return extras.getInt(MediaConstants.CONTENT_STYLE_BROWSABLE_HINT_PRERELEASE, 0);
304             }
305         }
306         return 0;
307     }
308 
isPlayable()309     public boolean isPlayable() {
310         return mIsPlayable;
311     }
312 
313     /**
314      * @return Content style hint for playable items, if provided as an extra, or
315      * 0 as default value if not provided.
316      */
getPlayableContentStyleHint()317     public int getPlayableContentStyleHint() {
318         Bundle extras = mMediaDescription.getExtras();
319         if (extras != null) {
320 
321             if (extras.containsKey(MediaConstants.CONTENT_STYLE_PLAYABLE_HINT)) {
322                 return extras.getInt(MediaConstants.CONTENT_STYLE_PLAYABLE_HINT, 0);
323             } else if (extras.containsKey(MediaConstants.CONTENT_STYLE_PLAYABLE_HINT_PRERELEASE)) {
324                 return extras.getInt(MediaConstants.CONTENT_STYLE_PLAYABLE_HINT_PRERELEASE, 0);
325             }
326         }
327         return 0;
328     }
329 
330     /**
331      * @return Content style title group this item belongs to, or null if not provided
332      */
getTitleGrouping()333     public String getTitleGrouping() {
334         Bundle extras = mMediaDescription.getExtras();
335         if (extras != null) {
336             if (extras.containsKey(MediaConstants.CONTENT_STYLE_GROUP_TITLE_HINT)) {
337                 return extras.getString(MediaConstants.CONTENT_STYLE_GROUP_TITLE_HINT, null);
338             } else if (extras.containsKey(
339                     MediaConstants.CONTENT_STYLE_GROUP_TITLE_HINT_PRERELEASE)) {
340                 return extras.getString(MediaConstants.CONTENT_STYLE_GROUP_TITLE_HINT_PRERELEASE,
341                         null);
342             }
343         }
344         return null;
345     }
346 
347     @Override
equals(Object o)348     public boolean equals(Object o) {
349         if (this == o) return true;
350         if (o == null || getClass() != o.getClass()) return false;
351         MediaItemMetadata that = (MediaItemMetadata) o;
352         return mIsBrowsable == that.mIsBrowsable
353                 && mIsPlayable == that.mIsPlayable
354                 && Objects.equals(getId(), that.getId())
355                 && Objects.equals(getTitle(), that.getTitle())
356                 && Objects.equals(getSubtitle(), that.getSubtitle())
357                 && Objects.equals(getAlbumTitle(), that.getAlbumTitle())
358                 && Objects.equals(getArtist(), that.getArtist())
359                 && Objects.equals(getNonEmptyArtworkUri(), that.getNonEmptyArtworkUri())
360                 && Objects.equals(mQueueId, that.mQueueId);
361     }
362 
363     @Override
hashCode()364     public int hashCode() {
365         return Objects.hash(mMediaDescription.getMediaId(), mQueueId, mIsBrowsable, mIsPlayable);
366     }
367 
368     @Override
describeContents()369     public int describeContents() {
370         return 0;
371     }
372 
373     @Override
writeToParcel(Parcel dest, int flags)374     public void writeToParcel(Parcel dest, int flags) {
375         dest.writeValue(mMediaDescription);
376         if (mQueueId == null) {
377             dest.writeByte((byte) (0x00));
378         } else {
379             dest.writeByte((byte) (0x01));
380             dest.writeLong(mQueueId);
381         }
382         dest.writeByte((byte) (mIsBrowsable ? 0x01 : 0x00));
383         dest.writeByte((byte) (mIsPlayable ? 0x01 : 0x00));
384         dest.writeString(mAlbumTitle);
385         dest.writeString(mArtist);
386     }
387 
388     @SuppressWarnings("unused")
389     public static final Parcelable.Creator<MediaItemMetadata> CREATOR =
390             new Parcelable.Creator<MediaItemMetadata>() {
391                 @Override
392                 public MediaItemMetadata createFromParcel(Parcel in) {
393                     return new MediaItemMetadata(in);
394                 }
395 
396                 @Override
397                 public MediaItemMetadata[] newArray(int size) {
398                     return new MediaItemMetadata[size];
399                 }
400             };
401 
402     @Override
toString()403     public String toString() {
404         return "[Id: "
405                 + (mMediaDescription != null ? mMediaDescription.getMediaId() : "-")
406                 + ", Queue Id: "
407                 + (mQueueId != null ? mQueueId : "-")
408                 + ", title: "
409                 + mMediaDescription != null ? mMediaDescription.getTitle().toString() : "-"
410                 + ", subtitle: "
411                 + mMediaDescription != null ? mMediaDescription.getSubtitle().toString() : "-"
412                 + ", album title: "
413                 + mAlbumTitle != null ? mAlbumTitle : "-"
414                 + ", artist: "
415                 + mArtist != null ? mArtist : "-"
416                 + ", album art URI: "
417                 + (mMediaDescription != null ? mMediaDescription.getIconUri() : "-")
418                 + "]";
419     }
420 }
421