blob: 36e6878ec3d65ac2a30736d3373ef54448288781 [file] [log] [blame]
/*
* 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 static android.support.v4.media.MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION;
import static android.support.v4.media.MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE;
import static com.android.car.media.common.MediaConstants.KEY_DESCRIPTION_LINK_MEDIA_ID;
import static com.android.car.media.common.MediaConstants.KEY_IMMERSIVE_AUDIO;
import static com.android.car.media.common.MediaConstants.KEY_SUBTITLE_LINK_MEDIA_ID;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.support.v4.media.MediaBrowserCompat;
import android.support.v4.media.MediaDescriptionCompat;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.ArrayRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media.utils.MediaConstants;
import com.android.car.apps.common.BitmapUtils;
import com.android.car.apps.common.CommonFlags;
import com.android.car.apps.common.imaging.ImageBinder;
import com.android.car.apps.common.imaging.ImageBinder.PlaceholderType;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Abstract representation of a media item metadata.
*
* For media art, only local uris are supported so downloads can be attributed to the media app.
* Bitmaps are not supported because they slow down the binder.
*/
public class MediaItemMetadata {
private static final String TAG = "MediaItemMetadata";
static final int INVALID_MEDIA_ART_TINT_COLOR = Color.argb(200, 255, 0, 0);
public static final int NO_PLAYBACK_STATUS = -1;
public static final double NO_PROGRESS = -1.0;
@NonNull
private final MediaDescriptionCompat mMediaDescription;
/**
* Stores the bundle from {@link MediaMetadataCompat#getBundle} to access its extras since they
* are not included in the {@link MediaMetadataCompat#getDescription}.
*/
@Nullable
private final Bundle mMetadataCompatBundle;
@Nullable
private final Long mQueueId;
private final boolean mIsBrowsable;
private final boolean mIsPlayable;
private final ArtworkRef mArtworkKey = new ArtworkRef();
/** Creates an instance based on a {@link MediaMetadataCompat} */
public MediaItemMetadata(@NonNull MediaMetadataCompat metadata) {
this(metadata.getDescription(), null, false, false,
metadata.getBundle());
}
/** Creates an instance based on a {@link MediaSessionCompat.QueueItem} */
public MediaItemMetadata(@NonNull MediaSessionCompat.QueueItem queueItem) {
this(queueItem.getDescription(), queueItem.getQueueId(), false, true, null);
}
/** Creates an instance based on a {@link MediaBrowserCompat.MediaItem} */
public MediaItemMetadata(@NonNull MediaBrowserCompat.MediaItem item) {
this(item.getDescription(), null, item.isBrowsable(), item.isPlayable(), null);
}
/** Creates a MediaItemMetadata where only the media id is set. */
public static MediaItemMetadata createEmptyRootData(String rootId) {
MediaDescriptionCompat.Builder bb = new MediaDescriptionCompat.Builder();
bb.setMediaId(rootId);
bb.setTitle("Root");
return new MediaItemMetadata(bb.build(), null, true, false, null);
}
@VisibleForTesting
public MediaItemMetadata(@NonNull MediaDescriptionCompat description, @Nullable Long queueId,
boolean isBrowsable, boolean isPlayable,
@Nullable Bundle metadataCompatBundle) {
mMediaDescription = description;
mQueueId = queueId;
mIsPlayable = isPlayable;
mIsBrowsable = isBrowsable;
mMetadataCompatBundle = metadataCompatBundle;
}
/**
* The key to access the image to display for this media item.
* Implemented as a class so that later we can support showing different images for the same
* item (eg: cover and author) by adding other keys.
*/
public class ArtworkRef implements ImageBinder.ImageRef {
private @Nullable Bitmap getBitmapToFlag(Context context) {
CommonFlags flags = CommonFlags.getInstance(context);
return (flags.shouldFlagImproperImageRefs() && (mMediaDescription != null))
? mMediaDescription.getIconBitmap() : null;
}
private int getPlaceholderHash() {
// Only the title is reliably populated in metadata, since the album/artist fields
// aren't set in the items retrieved from the browse service (only Title/Subtitle).
return (getTitle() != null) ? getTitle().hashCode() : 0;
}
@Override
public String toString() {
return "title: " + getTitle() + " uri: " + getNonEmptyArtworkUri();
}
@Override
public @Nullable Uri getImageURI() {
return getNonEmptyArtworkUri();
}
@Override
public boolean equals(Context context, Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ArtworkRef other = (ArtworkRef) o;
Bitmap myBitmap = getBitmapToFlag(context);
Bitmap otherBitmap = other.getBitmapToFlag(context);
if ((myBitmap != null) || (otherBitmap != null)) {
return Objects.equals(myBitmap, otherBitmap);
}
Uri myUri = getImageURI();
Uri otherUri = other.getImageURI();
if ((myUri != null) || (otherUri != null)) {
return Objects.equals(myUri, otherUri);
}
return getPlaceholderHash() == other.getPlaceholderHash();
}
@Override
public @Nullable Drawable getImage(Context context) {
Bitmap bitmap = getBitmapToFlag(context);
if (bitmap != null) {
Resources res = context.getResources();
return new BitmapDrawable(res, BitmapUtils.createTintedBitmap(bitmap,
context.getColor(
com.android.car.apps.common.R.color.improper_image_refs_tint_color
)));
}
return null;
}
@Override
public Drawable getPlaceholder(Context context, @NonNull PlaceholderType type) {
if (type == PlaceholderType.NONE) return null;
List<Drawable> placeholders = getPlaceHolders(type, context);
int random = Math.floorMod(getPlaceholderHash(), placeholders.size());
return placeholders.get(random);
}
}
/** @return media item id */
@Nullable
public String getId() {
return mMediaDescription.getMediaId();
}
/** @return media item title */
@Nullable
public CharSequence getTitle() {
return mMediaDescription.getTitle();
}
/** @return media item subtitle */
@Nullable
public CharSequence getSubtitle() {
return mMediaDescription.getSubtitle();
}
/**
* Returns the media ID of the item associated with the subtitle.
* Note: apps must explicitly set the subtitle string and the link, otherwise null is returned.
*/
@Nullable
public String getSubtitleLinkMediaId() {
if (mMetadataCompatBundle == null) {
return null;
}
String subtitle = mMetadataCompatBundle.getString(METADATA_KEY_DISPLAY_SUBTITLE);
if (TextUtils.isEmpty(subtitle)) {
return null;
}
return mMetadataCompatBundle.getString(KEY_SUBTITLE_LINK_MEDIA_ID);
}
/**
* Returns the media ID of the item associated with the description.
* Note: apps must explicitly set the description string and the link, or null is returned.
*/
@Nullable
public String getDescriptionLinkMediaId() {
if (mMetadataCompatBundle == null) {
return null;
}
String description = mMetadataCompatBundle.getString(METADATA_KEY_DISPLAY_DESCRIPTION);
if (TextUtils.isEmpty(description)) {
return null;
}
return mMetadataCompatBundle.getString(KEY_DESCRIPTION_LINK_MEDIA_ID);
}
/** Returns whether the IMMERSIVE_AUDIO extra is set. */
public boolean isImmersiveAudio() {
return (mMetadataCompatBundle != null) && mMetadataCompatBundle.getLong(KEY_IMMERSIVE_AUDIO)
== MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT;
}
/** Returns the string value associated with the given key. */
@Nullable
public String getStringProperty(String key) {
if (mMetadataCompatBundle == null) {
return null;
}
return mMetadataCompatBundle.getString(key);
}
/** @return media item description */
@Nullable
public CharSequence getDescription() {
return mMediaDescription.getDescription();
}
/**
* @return the id of this item in the session queue, or NULL if this is not a session queue
* item.
*/
@Nullable
public Long getQueueId() {
return mQueueId;
}
public ArtworkRef getArtworkKey() {
return mArtworkKey;
}
/**
* @return a {@link Uri} referencing the artwork's bitmap.
*/
private @Nullable Uri getNonEmptyArtworkUri() {
Uri uri = mMediaDescription.getIconUri();
return (uri != null && !TextUtils.isEmpty(uri.toString())) ? uri : null;
}
/**
* @return optional extras that can include extra information about the media item to be played.
*/
public Bundle getExtras() {
return mMediaDescription.getExtras();
}
/**
* @return boolean that indicate if media is explicit.
*/
public boolean isExplicit() {
Bundle extras = mMediaDescription.getExtras();
return extras != null && extras.getLong(MediaConstants.METADATA_KEY_IS_EXPLICIT)
== MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT;
}
/**
* @return boolean that indicate if media is downloaded.
*/
public boolean isDownloaded() {
Bundle extras = mMediaDescription.getExtras();
return extras != null
&& extras.getLong(MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS)
== MediaDescriptionCompat.STATUS_DOWNLOADED;
}
/**
* Checks {@link MediaConstants#DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS}
* @return
*/
public boolean hasPlaybackStatus() {
if (mMediaDescription.getExtras() != null) {
return mMediaDescription.getExtras().getInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
NO_PLAYBACK_STATUS)
!= NO_PLAYBACK_STATUS;
}
return false;
}
/**
* <p></p>
* Returns the playback status
* {@link MediaConstants#DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS}
* optionally stored in the {@link MediaItemMetadata#getExtras()}.
* </p>
* <p>
* Can return:
*
* {@link MediaConstants#DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED}
* {@link MediaConstants#DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED}
* {@link MediaConstants#DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED}
* </p>
* <p>
* Defaults to DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NO_VALUE
* if the optional value is not in the bundle.
* </p>
* <p>
* If status {@link MediaConstants#DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED}
* call {@link MediaItemMetadata#getProgress} to get the progress percentage
* </p>
*
* @return playback status
* @see MediaConstants#DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS
*/
public int getPlaybackStatus() {
if (mMediaDescription.getExtras() != null) {
return mMediaDescription.getExtras().getInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
NO_PLAYBACK_STATUS);
}
return NO_PLAYBACK_STATUS;
}
/**
* Checks {@link MediaConstants#DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE}
* @return
*/
public boolean hasProgress() {
if (mMediaDescription.getExtras() != null) {
return mMediaDescription.getExtras().getDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE,
NO_PROGRESS)
> NO_PROGRESS;
}
return false;
}
/**
* Returns the playback percentage stored in {@link MediaItemMetadata#getExtras()}
* Value is stored with key {@link MediaConstants#DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE}
*
* @return progress 0.0 - 1.0 inclusive , default to -1.0
* {@link MediaItemMetadata#NO_PROGRESS} if optional key in not
* present @see MediaConstants#DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE
*/
public double getProgress() {
if (mMediaDescription.getExtras() != null) {
return mMediaDescription.getExtras().getDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE,
NO_PROGRESS);
}
return NO_PROGRESS;
}
/**
* Update progress
*/
public void setProgress(double progress) {
if (mMediaDescription.getExtras() != null) {
mMediaDescription.getExtras().putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress);
}
}
private static Map<PlaceholderType, List<Drawable>> sPlaceHolders = new HashMap<>();
private static @ArrayRes int getPlaceHolderArray(PlaceholderType type) {
switch (type) {
case FOREGROUND_ICON:
return R.array.placeholder_icons;
case BACKGROUND:
return R.array.placeholder_backgrounds;
case FOREGROUND:
default:
return R.array.placeholder_images;
}
}
private static List<Drawable> getPlaceHolders(PlaceholderType type, Context context) {
List<Drawable> placeHolders = sPlaceHolders.get(type);
if (placeHolders == null) {
TypedArray placeholderImages = context.getResources().obtainTypedArray(
getPlaceHolderArray(type));
if (placeholderImages == null) {
throw new NullPointerException("No placeholders for " + type);
}
placeHolders = new ArrayList<>(placeholderImages.length());
for (int i = 0; i < placeholderImages.length(); i++) {
placeHolders.add(placeholderImages.getDrawable(i));
}
placeholderImages.recycle();
sPlaceHolders.put(type, placeHolders);
if (sPlaceHolders.size() <= 0) {
throw new Resources.NotFoundException("Placeholders should not be empty " + type);
}
}
return placeHolders;
}
public boolean isBrowsable() {
return mIsBrowsable;
}
/**
* @return Content style hint for browsable items, if provided as an extra, or
* 0 as default value if not provided.
*/
public int getBrowsableContentStyleHint() {
Bundle extras = mMediaDescription.getExtras();
if (extras != null) {
if (extras.containsKey(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE)) {
return extras.getInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
0);
}
}
return 0;
}
public boolean isPlayable() {
return mIsPlayable;
}
/**
* @return Content style hint for playable items, if provided as an extra, or
* 0 as default value if not provided.
*/
public int getPlayableContentStyleHint() {
Bundle extras = mMediaDescription.getExtras();
if (extras != null) {
if (extras.containsKey(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE)) {
return extras.getInt(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
0);
}
}
return 0;
}
/**
* @return Content style hint for single item, if provided as an extra, or 0 as default if not
* provided.
*/
public int getSingleItemContentStyleHint() {
Bundle extras = mMediaDescription.getExtras();
if (extras != null) {
if (extras.containsKey(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM)) {
return extras.getInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, 0);
}
}
return 0;
}
/**
* @return Content style title group this item belongs to, or null if not provided
*/
public String getTitleGrouping() {
Bundle extras = mMediaDescription.getExtras();
if (extras != null) {
if (extras.containsKey(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE)) {
return extras.getString(
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, null);
}
}
return null;
}
/** Returns list of browse custom actions if present, empty if not present */
public List<String> getBrowseCustomActionIds() {
Bundle extras = mMediaDescription.getExtras();
if (extras != null) {
if (extras.containsKey(
com.android.car.media.common.MediaConstants.BROWSE_CUSTOM_ACTIONS_ITEM_LIST)) {
return extras.getStringArrayList(
com.android.car.media.common.MediaConstants
.BROWSE_CUSTOM_ACTIONS_ITEM_LIST);
}
}
return Collections.emptyList();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MediaItemMetadata that = (MediaItemMetadata) o;
return mIsBrowsable == that.mIsBrowsable
&& mIsPlayable == that.mIsPlayable
&& Objects.equals(getId(), that.getId())
&& Objects.equals(getTitle(), that.getTitle())
&& Objects.equals(getSubtitle(), that.getSubtitle())
&& Objects.equals(getDescription(), that.getDescription())
&& Objects.equals(getNonEmptyArtworkUri(), that.getNonEmptyArtworkUri())
&& Objects.equals(mQueueId, that.mQueueId)
&& Objects.equals(hasPlaybackStatus(), that.hasPlaybackStatus())
&& Objects.equals(hasProgress(), that.hasProgress())
&& areDoublesClose(getProgress(), that.getProgress(), .0001)
&& Objects.equals(getSubtitleLinkMediaId(), that.getSubtitleLinkMediaId())
&& Objects.equals(getDescriptionLinkMediaId(), that.getDescriptionLinkMediaId());
}
/**
* Checks whether 2 doubles are within the given threshold.
* Threshold is the maximum difference before it's considered unequal.
*
* @param threshold Usually accurate to .001 or .0001
* @return if equal
*/
public static boolean areDoublesClose(double first, double second, double threshold) {
if (threshold < 0.0) {
Log.w(TAG, "areDoublesClose threshold less than 0.0");
threshold = 0.0;
}
return Math.abs(first - second) < threshold;
}
@Override
public int hashCode() {
return Objects.hash(mIsBrowsable, mIsPlayable, getId(), getTitle(), getSubtitle(),
getDescription(), getNonEmptyArtworkUri(), mQueueId, hasPlaybackStatus(),
hasProgress(), getProgress(), getSubtitleLinkMediaId(),
getDescriptionLinkMediaId());
}
@Override
public String toString() {
return "[Id: "
+ (mMediaDescription != null ? mMediaDescription.getMediaId() : "-")
+ ", Queue Id: "
+ (mQueueId != null ? mQueueId : "-")
+ ", title: "
+ mMediaDescription != null ? mMediaDescription.getTitle().toString() : "-"
+ ", subtitle: "
+ mMediaDescription != null ? mMediaDescription.getSubtitle().toString() : "-"
+ ", album art URI: "
+ (mMediaDescription != null ? mMediaDescription.getIconUri() : "-")
+ "]";
}
}