blob: 1b9485521aefd23b10bf00b8554085d15fa00a91 [file] [log] [blame]
/*
* 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.localmediaplayer;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.media.MediaDescription;
import android.media.MediaMetadata;
import android.media.browse.MediaBrowser.MediaItem;
import android.media.session.MediaSession.QueueItem;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.provider.MediaStore;
import android.provider.MediaStore.Audio.AlbumColumns;
import android.provider.MediaStore.Audio.AudioColumns;
import android.service.media.MediaBrowserService.Result;
import android.util.Log;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class DataModel {
private static final String TAG = "LMBDataModel";
private static final Uri[] ALL_AUDIO_URI = new Uri[] {
MediaStore.Audio.Media.INTERNAL_CONTENT_URI,
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
};
private static final Uri[] ALBUMS_URI = new Uri[] {
MediaStore.Audio.Albums.INTERNAL_CONTENT_URI,
MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI
};
private static final Uri[] ARTISTS_URI = new Uri[] {
MediaStore.Audio.Artists.INTERNAL_CONTENT_URI,
MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI
};
private static final Uri[] GENRES_URI = new Uri[] {
MediaStore.Audio.Genres.INTERNAL_CONTENT_URI,
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI
};
private static final String QUERY_BY_KEY_WHERE_CLAUSE =
AudioColumns.ALBUM_KEY + "= ? or "
+ AudioColumns.ARTIST_KEY + " = ? or "
+ AudioColumns.TITLE_KEY + " = ? or "
+ AudioColumns.DATA + " like ?";
private static final String EXTERNAL = "external";
private static final String INTERNAL = "internal";
private static final Uri ART_BASE_URI = Uri.parse("content://media/external/audio/albumart");
public static final String PATH_KEY = "PATH";
private Context mContext;
private ContentResolver mResolver;
private AsyncTask mPendingTask;
private List<QueueItem> mQueue = new ArrayList<>();
public DataModel(Context context) {
mContext = context;
mResolver = context.getContentResolver();
}
public void onQueryByFolder(String parentId, Result<List<MediaItem>> result) {
FilesystemListTask query = new FilesystemListTask(result, ALL_AUDIO_URI, mResolver);
queryInBackground(result, query);
}
public void onQueryByAlbum(String parentId, Result<List<MediaItem>> result) {
QueryTask query = new QueryTask.Builder()
.setResolver(mResolver)
.setResult(result)
.setUri(ALBUMS_URI)
.setKeyColumn(AudioColumns.ALBUM_KEY)
.setTitleColumn(AudioColumns.ALBUM)
.setFlags(MediaItem.FLAG_BROWSABLE)
.build();
queryInBackground(result, query);
}
public void onQueryByArtist(String parentId, Result<List<MediaItem>> result) {
QueryTask query = new QueryTask.Builder()
.setResolver(mResolver)
.setResult(result)
.setUri(ARTISTS_URI)
.setKeyColumn(AudioColumns.ARTIST_KEY)
.setTitleColumn(AudioColumns.ARTIST)
.setFlags(MediaItem.FLAG_BROWSABLE)
.build();
queryInBackground(result, query);
}
public void onQueryByGenre(String parentId, Result<List<MediaItem>> result) {
QueryTask query = new QueryTask.Builder()
.setResolver(mResolver)
.setResult(result)
.setUri(GENRES_URI)
.setKeyColumn(MediaStore.Audio.Genres._ID)
.setTitleColumn(MediaStore.Audio.Genres.NAME)
.setFlags(MediaItem.FLAG_BROWSABLE)
.build();
queryInBackground(result, query);
}
private void queryInBackground(Result<List<MediaItem>> result,
AsyncTask<Void, Void, Void> task) {
result.detach();
if (mPendingTask != null) {
mPendingTask.cancel(true);
}
mPendingTask = task;
task.execute();
}
public List<QueueItem> getQueue() {
return mQueue;
}
public MediaMetadata getMetadata(String key) {
Cursor cursor = null;
MediaMetadata.Builder metadata = new MediaMetadata.Builder();
try {
for (Uri uri : ALL_AUDIO_URI) {
cursor = mResolver.query(uri, null, AudioColumns.TITLE_KEY + " = ?",
new String[]{ key }, null);
if (cursor != null) {
int title = cursor.getColumnIndex(AudioColumns.TITLE);
int artist = cursor.getColumnIndex(AudioColumns.ARTIST);
int album = cursor.getColumnIndex(AudioColumns.ALBUM);
int albumId = cursor.getColumnIndex(AudioColumns.ALBUM_ID);
int duration = cursor.getColumnIndex(AudioColumns.DURATION);
while (cursor.moveToNext()) {
metadata.putString(MediaMetadata.METADATA_KEY_TITLE,
cursor.getString(title));
metadata.putString(MediaMetadata.METADATA_KEY_ARTIST,
cursor.getString(artist));
metadata.putString(MediaMetadata.METADATA_KEY_ALBUM,
cursor.getString(album));
metadata.putLong(MediaMetadata.METADATA_KEY_DURATION,
cursor.getLong(duration));
String albumArt = null;
Uri albumArtUri = ContentUris.withAppendedId(ART_BASE_URI,
cursor.getLong(albumId));
try {
InputStream dummy = mResolver.openInputStream(albumArtUri);
albumArt = albumArtUri.toString();
dummy.close();
} catch (IOException e) {
// Ignored because the albumArt is intialized correctly anyway.
}
metadata.putString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, albumArt);
break;
}
}
}
} finally {
if (cursor != null) {
cursor.close();
}
}
return metadata.build();
}
/**
* Note: This clears out the queue. You should have a local copy of the queue before calling
* this method.
*/
public void onQueryByKey(String lastCategory, String parentId, Result<List<MediaItem>> result) {
mQueue.clear();
QueryTask.Builder query = new QueryTask.Builder()
.setResolver(mResolver)
.setResult(result);
Uri[] uri = null;
if (lastCategory.equals(LocalMediaBrowserService.GENRES_ID)) {
// Genres come from a different table and don't use the where clause from the
// usual media table so we need to have this condition.
try {
long id = Long.parseLong(parentId);
query.setUri(new Uri[] {
MediaStore.Audio.Genres.Members.getContentUri(EXTERNAL, id),
MediaStore.Audio.Genres.Members.getContentUri(INTERNAL, id) });
} catch (NumberFormatException e) {
// This should never happen.
Log.e(TAG, "Incorrect key type: " + parentId + ", sending empty result");
result.sendResult(new ArrayList<MediaItem>());
return;
}
} else {
query.setUri(ALL_AUDIO_URI)
.setWhereClause(QUERY_BY_KEY_WHERE_CLAUSE)
.setWhereArgs(new String[] { parentId, parentId, parentId, parentId });
}
query.setKeyColumn(AudioColumns.TITLE_KEY)
.setTitleColumn(AudioColumns.TITLE)
.setSubtitleColumn(AudioColumns.ALBUM)
.setFlags(MediaItem.FLAG_PLAYABLE)
.setQueue(mQueue);
queryInBackground(result, query.build());
}
// This async task is similar enough to all the others that it feels like it can be unified
// but is different enough that unifying it makes the code for both cases look really weird
// and over paramterized so at the risk of being a little more verbose, this is separated out
// in the name of understandability.
private static class FilesystemListTask extends AsyncTask<Void, Void, Void> {
private static final String[] COLUMNS = { AudioColumns.DATA };
private Result<List<MediaItem>> mResult;
private Uri[] mUris;
private ContentResolver mResolver;
public FilesystemListTask(Result<List<MediaItem>> result, Uri[] uris,
ContentResolver resolver) {
mResult = result;
mUris = uris;
mResolver = resolver;
}
@Override
protected Void doInBackground(Void... voids) {
Set<String> paths = new HashSet<String>();
Cursor cursor = null;
for (Uri uri : mUris) {
try {
cursor = mResolver.query(uri, COLUMNS, null , null, null);
if (cursor != null) {
int pathColumn = cursor.getColumnIndex(AudioColumns.DATA);
while (cursor.moveToNext()) {
// We want to de-dupe paths of each of the songs so we get just a list
// of containing directories.
String fullPath = cursor.getString(pathColumn);
int fileNameStart = fullPath.lastIndexOf(File.separator);
if (fileNameStart < 0) {
continue;
}
String dirPath = fullPath.substring(0, fileNameStart);
paths.add(dirPath);
}
}
} catch (SQLiteException e) {
Log.e(TAG, "Failed to execute query " + e); // Stack trace is noisy.
} finally {
if (cursor != null) {
cursor.close();
}
}
}
// Take the list of deduplicated directories and put them into the results list with
// the full directory path as the key so we can match on it later.
List<MediaItem> results = new ArrayList<>();
for (String path : paths) {
int dirNameStart = path.lastIndexOf(File.separator) + 1;
String dirName = path.substring(dirNameStart, path.length());
MediaDescription description = new MediaDescription.Builder()
.setMediaId(path + "%") // Used in a like query.
.setTitle(dirName)
.setSubtitle(path)
.build();
results.add(new MediaItem(description, MediaItem.FLAG_BROWSABLE));
}
mResult.sendResult(results);
return null;
}
}
private static class QueryTask extends AsyncTask<Void, Void, Void> {
private Result<List<MediaItem>> mResult;
private String[] mColumns;
private String mWhereClause;
private String[] mWhereArgs;
private String mKeyColumn;
private String mTitleColumn;
private String mSubtitleColumn;
private Uri[] mUris;
private int mFlags;
private ContentResolver mResolver;
private List<QueueItem> mQueue;
private QueryTask(Builder builder) {
mColumns = builder.mColumns;
mWhereClause = builder.mWhereClause;
mWhereArgs = builder.mWhereArgs;
mKeyColumn = builder.mKeyColumn;
mTitleColumn = builder.mTitleColumn;
mUris = builder.mUris;
mFlags = builder.mFlags;
mResolver = builder.mResolver;
mResult = builder.mResult;
mQueue = builder.mQueue;
mSubtitleColumn = builder.mSubtitleColumn;
}
@Override
protected Void doInBackground(Void... voids) {
List<MediaItem> results = new ArrayList<>();
long idx = 0;
Cursor cursor = null;
for (Uri uri : mUris) {
try {
cursor = mResolver.query(uri, mColumns, mWhereClause, mWhereArgs, null);
if (cursor != null) {
int keyColumn = cursor.getColumnIndex(mKeyColumn);
int titleColumn = cursor.getColumnIndex(mTitleColumn);
int pathColumn = cursor.getColumnIndex(AudioColumns.DATA);
int subtitleColumn = -1;
if (mSubtitleColumn != null) {
subtitleColumn = cursor.getColumnIndex(mSubtitleColumn);
}
while (cursor.moveToNext()) {
Bundle path = new Bundle();
if (pathColumn != -1) {
path.putString(PATH_KEY, cursor.getString(pathColumn));
}
MediaDescription.Builder builder = new MediaDescription.Builder()
.setMediaId(cursor.getString(keyColumn))
.setTitle(cursor.getString(titleColumn))
.setExtras(path);
if (subtitleColumn != -1) {
builder.setSubtitle(cursor.getString(subtitleColumn));
}
MediaDescription description = builder.build();
results.add(new MediaItem(description, mFlags));
// We rebuild the queue here so if the user selects the item then we
// can immediately use this queue.
if (mQueue != null) {
mQueue.add(new QueueItem(description, idx));
}
idx++;
}
}
} catch (SQLiteException e) {
// Sometimes tables don't exist if the media scanner hasn't seen data of that
// type yet. For example, the genres table doesn't seem to exist at all until
// the first time a song with a genre is encountered. If we hit an exception,
// the result is never sent causing the other end to hang up, which is a bad
// thing. We can instead just be resilient and return an empty list.
Log.i(TAG, "Failed to execute query " + e); // Stack trace is noisy.
} finally {
if (cursor != null) {
cursor.close();
}
}
}
mResult.sendResult(results);
return null; // Ignored.
}
//
// Boilerplate Alert!
//
public static class Builder {
private Result<List<MediaItem>> mResult;
private String[] mColumns;
private String mWhereClause;
private String[] mWhereArgs;
private String mKeyColumn;
private String mTitleColumn;
private String mSubtitleColumn;
private Uri[] mUris;
private int mFlags;
private ContentResolver mResolver;
private List<QueueItem> mQueue;
public Builder setColumns(String[] columns) {
mColumns = columns;
return this;
}
public Builder setWhereClause(String whereClause) {
mWhereClause = whereClause;
return this;
}
public Builder setWhereArgs(String[] whereArgs) {
mWhereArgs = whereArgs;
return this;
}
public Builder setUri(Uri[] uris) {
mUris = uris;
return this;
}
public Builder setKeyColumn(String keyColumn) {
mKeyColumn = keyColumn;
return this;
}
public Builder setTitleColumn(String titleColumn) {
mTitleColumn = titleColumn;
return this;
}
public Builder setSubtitleColumn(String subtitleColumn) {
mSubtitleColumn = subtitleColumn;
return this;
}
public Builder setFlags(int flags) {
mFlags = flags;
return this;
}
public Builder setResult(Result<List<MediaItem>> result) {
mResult = result;
return this;
}
public Builder setResolver(ContentResolver resolver) {
mResolver = resolver;
return this;
}
public Builder setQueue(List<QueueItem> queue) {
mQueue = queue;
return this;
}
public QueryTask build() {
if (mUris == null || mKeyColumn == null || mResolver == null ||
mResult == null || mTitleColumn == null) {
throw new IllegalStateException(
"uri, keyColumn, resolver, result and titleColumn are required.");
}
return new QueryTask(this);
}
}
}
}