blob: b3c9841e0b25f7103f7ef88b42c39104626ecd6f [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.graphics.Bitmap.Config.ARGB_8888;
import android.annotation.DrawableRes;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.os.SystemProperties;
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.util.Log;
import android.view.View;
import android.widget.ImageView;
import androidx.annotation.VisibleForTesting;
import com.android.car.apps.common.UriUtils;
import com.bumptech.glide.Glide;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.engine.GlideException;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.RequestOptions;
import com.bumptech.glide.request.target.SimpleTarget;
import com.bumptech.glide.request.target.Target;
import com.bumptech.glide.request.transition.Transition;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
/**
* 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 implements Parcelable {
private static final String TAG = "MediaItemMetadata";
/**
* To red tint invalid art: adb root && adb shell setprop com.android.car.media.FlagInvalidArt 1
* Or set R.bool.flag_invalid_media_art to true.
*/
static final String FLAG_INVALID_MEDIA_ART_KEY = "com.android.car.media.FlagInvalidArt";
static final int INVALID_MEDIA_ART_TINT_COLOR = Color.argb(200, 255, 0, 0);
private static Boolean sFlagNonLocalMediaArt;
@NonNull
private final MediaDescriptionCompat mMediaDescription;
@Nullable
private final Long mQueueId;
private final boolean mIsBrowsable;
private final boolean mIsPlayable;
private final String mAlbumTitle;
private final String mArtist;
public MediaItemMetadata(@NonNull MediaDescriptionCompat description) {
this(description, null, false, false, null, null);
}
/** Creates an instance based on a {@link MediaMetadataCompat} */
public MediaItemMetadata(@NonNull MediaMetadataCompat metadata) {
this(metadata.getDescription(), null, false, false,
metadata.getString(MediaMetadataCompat.METADATA_KEY_ALBUM),
metadata.getString(MediaMetadataCompat.METADATA_KEY_ARTIST));
}
/** Creates an instance based on a {@link MediaSessionCompat.QueueItem} */
public MediaItemMetadata(@NonNull MediaSessionCompat.QueueItem queueItem) {
this(queueItem.getDescription(), queueItem.getQueueId(), false, true, null, 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, null);
}
/** Creates an instance based on a {@link Parcel} */
public MediaItemMetadata(@NonNull Parcel in) {
mMediaDescription = (MediaDescriptionCompat) in.readValue(
MediaDescriptionCompat.class.getClassLoader());
mQueueId = in.readByte() == 0x00 ? null : in.readLong();
mIsBrowsable = in.readByte() != 0x00;
mIsPlayable = in.readByte() != 0x00;
mAlbumTitle = in.readString();
mArtist = in.readString();
}
/**
* Creates a clone of this item
*
* @deprecated this method will be removed as part of b/79089344
*/
@Deprecated
public MediaItemMetadata(@NonNull MediaItemMetadata item) {
mMediaDescription = item.mMediaDescription;
mQueueId = item.mQueueId;
mIsBrowsable = item.mIsBrowsable;
mIsPlayable = item.mIsPlayable;
mAlbumTitle = item.mAlbumTitle;
mArtist = item.mArtist;
}
@VisibleForTesting
public MediaItemMetadata(MediaDescriptionCompat description, Long queueId, boolean isBrowsable,
boolean isPlayable, String albumTitle, String artist) {
mMediaDescription = description;
mQueueId = queueId;
mIsPlayable = isPlayable;
mIsBrowsable = isBrowsable;
mAlbumTitle = albumTitle;
mArtist = artist;
}
/** @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();
}
/** @return the album title for the media */
@Nullable
public String getAlbumTitle() {
return mAlbumTitle;
}
/** @return the artist of the media */
@Nullable
public CharSequence getArtist() {
return mArtist;
}
/**
* @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;
}
/**
* @return album art bitmap, or NULL if this item doesn't have a local album art. In this,
* the {@link #getAlbumArtUri()} should be used to obtain a reference to a remote bitmap.
*/
public Bitmap getAlbumArtBitmap() {
return mMediaDescription.getIconBitmap();
}
/**
* @return an {@link Uri} referencing the album art bitmap.
*/
public Uri getAlbumArtUri() {
return mMediaDescription.getIconUri();
}
/**
* @return boolean that indicate if media is explicit.
*/
public boolean isExplicit() {
Bundle extras = mMediaDescription.getExtras();
return extras != null && extras.getLong(MediaConstants.EXTRA_IS_EXPLICIT)
== MediaConstants.EXTRA_METADATA_ENABLED_VALUE;
}
/**
* @return boolean that indicate if media is downloaded.
*/
public boolean isDownloaded() {
Bundle extras = mMediaDescription.getExtras();
return extras != null && extras.getLong(MediaConstants.EXTRA_DOWNLOAD_STATUS)
== MediaDescriptionCompat.STATUS_DOWNLOADED;
}
static boolean flagInvalidMediaArt(Context context) {
if (sFlagNonLocalMediaArt == null) {
Resources res = context.getResources();
sFlagNonLocalMediaArt = res.getBoolean(R.bool.flag_invalid_media_art)
|| "1".equals(SystemProperties.get(FLAG_INVALID_MEDIA_ART_KEY, "0"));
}
return sFlagNonLocalMediaArt;
}
static Drawable getPlaceholderDrawable(Context context,
@NonNull MediaItemMetadata metadata) {
TypedArray placeholderImages = context.getResources().obtainTypedArray(
R.array.placeholder_images);
if (placeholderImages != null && placeholderImages.length() > 0) {
// 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).
int titleHash = (metadata.getTitle() != null) ? metadata.getTitle().hashCode() : 0;
int random = Math.floorMod(titleHash, placeholderImages.length());
Drawable placeholder = placeholderImages.getDrawable(random);
placeholderImages.recycle();
return placeholder;
}
return context.getDrawable(R.drawable.ic_placeholder);
}
/**
* Updates the given {@link ImageView} with the album art of the given media item. This is an
* asynchronous operation.
* Note: If a view is set using this method, it should also be cleared using this same method.
* Given that the loading is asynchronous, missing to use this method for clearing could cause
* a delayed request to set an undesired image, or caching entries to be used for images not
* longer necessary.
*
* @param context {@link Context} used to load resources from
* @param metadata metadata to use, or NULL if the {@link ImageView} should be cleared.
* @param imageView loading target
* @param loadingIndicator a drawable resource that would be set into the {@link ImageView}
* while the image is being downloaded, or 0 if no loading indicator
* is required.
* @param showPlaceholder whether to show an image placeholder when the image is null.
*/
public static void updateImageView(Context context, @Nullable MediaItemMetadata metadata,
ImageView imageView, @DrawableRes int loadingIndicator, boolean showPlaceholder) {
Glide.with(context).clear(imageView);
imageView.clearColorFilter();
if (metadata == null) {
imageView.setImageBitmap(null);
imageView.setVisibility(View.GONE);
return;
}
boolean flagInvalidArt = flagInvalidMediaArt(context);
// Don't even try to get the album art bitmap unless flagging is active.
if (flagInvalidArt) {
Bitmap image = metadata.getAlbumArtBitmap();
if (image != null) {
imageView.setImageBitmap(image);
imageView.setVisibility(View.VISIBLE);
imageView.setColorFilter(INVALID_MEDIA_ART_TINT_COLOR);
return;
}
}
Uri imageUri = metadata.getAlbumArtUri();
if (imageUri != null) {
boolean hasArtwork = false;
if (UriUtils.isAndroidResourceUri(imageUri)) {
// Glide doesn't support loading resources from other applications
Drawable pic = UriUtils.getDrawable(context, UriUtils.getIconResource(imageUri));
if (pic != null) {
hasArtwork = true;
imageView.setImageDrawable(pic);
} else {
Log.e(TAG, "Unable to load resource " + imageUri);
}
} else if (flagInvalidArt || UriUtils.isContentUri(imageUri)) {
hasArtwork = true;
Glide.with(context)
.load(imageUri)
.apply(RequestOptions.placeholderOf(loadingIndicator))
.listener(new RequestListener<Drawable>() {
@Override
public boolean onLoadFailed(@Nullable GlideException e, Object model,
Target<Drawable> target, boolean isFirstResource) {
showPlaceholderOrHideView(context, metadata, imageView,
showPlaceholder);
return true;
}
@Override
public boolean onResourceReady(Drawable resource, Object model,
Target<Drawable> target, DataSource dataSource,
boolean isFirstResource) {
return false;
}
})
.into(imageView);
if (!UriUtils.isContentUri(imageUri)) {
imageView.setColorFilter(INVALID_MEDIA_ART_TINT_COLOR);
}
} else {
Log.e(TAG, "unsupported uri: " + imageUri);
}
if (hasArtwork) {
imageView.setVisibility(View.VISIBLE);
return;
}
}
showPlaceholderOrHideView(context, metadata, imageView, showPlaceholder);
}
private static void showPlaceholderOrHideView(Context context,
@Nullable MediaItemMetadata metadata, ImageView imageView, boolean showPlaceholder) {
if (showPlaceholder) {
imageView.setImageDrawable(getPlaceholderDrawable(context, metadata));
imageView.setVisibility(View.VISIBLE);
} else {
imageView.setImageBitmap(null);
imageView.setVisibility(View.GONE);
}
}
/**
* Loads the album art of this media item asynchronously. The loaded image will be scaled to
* fit into the given view size.
* Using {@link #updateImageView(Context, MediaItemMetadata, ImageView, int)} method is
* preferred. Only use this method if you are going to apply transformations to the loaded
* image.
*
* @param width desired width (should be > 0)
* @param height desired height (should be > 0)
* @param fit whether the image should be scaled to fit (fitCenter), or it should be cropped
* (centerCrop).
* @return a {@link CompletableFuture} that will be completed once the image is loaded, or the
* loading fails.
*/
public CompletableFuture<Bitmap> getAlbumArt(Context context, int width, int height,
boolean fit) {
boolean flagInvalidArt = flagInvalidMediaArt(context);
// Don't even try to get the album art bitmap unless flagging is active.
if (flagInvalidArt) {
Bitmap image = getAlbumArtBitmap();
if (image != null) {
return CompletableFuture.completedFuture(flagBitmap(image));
}
}
Uri imageUri = getAlbumArtUri();
if (imageUri != null) {
if (UriUtils.isAndroidResourceUri(imageUri)) {
// Glide doesn't support loading resources for other applications...
Drawable pic = UriUtils.getDrawable(context, UriUtils.getIconResource(imageUri));
if (pic != null) {
Bitmap bitmap = Bitmap.createBitmap(width, height, ARGB_8888);
Canvas canvas = new Canvas(bitmap);
pic.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
pic.draw(canvas);
return CompletableFuture.completedFuture(bitmap);
} else {
String errorMessage = "Unable to load resource " + imageUri;
Log.e(TAG, errorMessage);
return CompletableFuture.failedFuture(new Exception(errorMessage));
}
} else if (flagInvalidArt || UriUtils.isContentUri(imageUri)) {
CompletableFuture<Bitmap> bitmapCompletableFuture = new CompletableFuture<>();
RequestBuilder<Bitmap> builder = Glide.with(context)
.asBitmap()
.load(getAlbumArtUri());
if (fit) {
builder = builder.apply(RequestOptions.fitCenterTransform());
} else {
builder = builder.apply(RequestOptions.centerCropTransform());
}
Target<Bitmap> target = new SimpleTarget<Bitmap>(width, height) {
@Override
public void onResourceReady(@NonNull Bitmap bitmap,
@Nullable Transition<? super Bitmap> transition) {
if (!UriUtils.isContentUri(imageUri)) {
bitmap = flagBitmap(bitmap);
}
bitmapCompletableFuture.complete(bitmap);
}
@Override
public void onLoadFailed(@Nullable Drawable errorDrawable) {
bitmapCompletableFuture.completeExceptionally(
new IllegalStateException("Unknown error"));
}
};
builder.into(target);
return bitmapCompletableFuture;
} else {
String errorMessage = "unsupported uri: \n" + imageUri;
Log.e(TAG, errorMessage);
return CompletableFuture.failedFuture(new Exception(errorMessage));
}
}
return CompletableFuture.completedFuture(null);
}
private static Bitmap flagBitmap(Bitmap image) {
Bitmap clone = Bitmap.createBitmap(image.getWidth(), image.getHeight(), ARGB_8888);
Canvas canvas = new Canvas(clone);
canvas.drawBitmap(image, 0f, 0f, new Paint());
canvas.drawColor(INVALID_MEDIA_ART_TINT_COLOR);
return clone;
}
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.CONTENT_STYLE_BROWSABLE_HINT)) {
return extras.getInt(MediaConstants.CONTENT_STYLE_BROWSABLE_HINT, 0);
} else if (extras.containsKey(MediaConstants.CONTENT_STYLE_BROWSABLE_HINT_PRERELEASE)) {
return extras.getInt(MediaConstants.CONTENT_STYLE_BROWSABLE_HINT_PRERELEASE, 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.CONTENT_STYLE_PLAYABLE_HINT)) {
return extras.getInt(MediaConstants.CONTENT_STYLE_PLAYABLE_HINT, 0);
} else if (extras.containsKey(MediaConstants.CONTENT_STYLE_PLAYABLE_HINT_PRERELEASE)) {
return extras.getInt(MediaConstants.CONTENT_STYLE_PLAYABLE_HINT_PRERELEASE, 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.CONTENT_STYLE_GROUP_TITLE_HINT)) {
return extras.getString(MediaConstants.CONTENT_STYLE_GROUP_TITLE_HINT, null);
} else if (extras.containsKey(
MediaConstants.CONTENT_STYLE_GROUP_TITLE_HINT_PRERELEASE)) {
return extras.getString(MediaConstants.CONTENT_STYLE_GROUP_TITLE_HINT_PRERELEASE,
null);
}
}
return null;
}
@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(getAlbumTitle(), that.getAlbumTitle())
&& Objects.equals(getArtist(), that.getArtist())
&& Objects.equals(getAlbumArtUri(), that.getAlbumArtUri())
&& Objects.equals(mQueueId, that.mQueueId);
}
@Override
public int hashCode() {
return Objects.hash(mMediaDescription.getMediaId(), mQueueId, mIsBrowsable, mIsPlayable);
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeValue(mMediaDescription);
if (mQueueId == null) {
dest.writeByte((byte) (0x00));
} else {
dest.writeByte((byte) (0x01));
dest.writeLong(mQueueId);
}
dest.writeByte((byte) (mIsBrowsable ? 0x01 : 0x00));
dest.writeByte((byte) (mIsPlayable ? 0x01 : 0x00));
dest.writeString(mAlbumTitle);
dest.writeString(mArtist);
}
@SuppressWarnings("unused")
public static final Parcelable.Creator<MediaItemMetadata> CREATOR =
new Parcelable.Creator<MediaItemMetadata>() {
@Override
public MediaItemMetadata createFromParcel(Parcel in) {
return new MediaItemMetadata(in);
}
@Override
public MediaItemMetadata[] newArray(int size) {
return new MediaItemMetadata[size];
}
};
@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 title: "
+ mAlbumTitle != null ? mAlbumTitle : "-"
+ ", artist: "
+ mArtist != null ? mArtist : "-"
+ ", album art URI: "
+ (mMediaDescription != null ? mMediaDescription.getIconUri() : "-")
+ "]";
}
}