| /* |
| * 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); |
| |
| if (LocalMediaBrowserService.GENRES_ID.equals(lastCategory)) { |
| // 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); |
| } |
| } |
| } |
| } |