1 /*
2  * Copyright (c) 2016, 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 package com.android.car.media.localmediaplayer;
17 
18 import android.content.ContentResolver;
19 import android.content.ContentUris;
20 import android.content.Context;
21 import android.database.Cursor;
22 import android.database.sqlite.SQLiteException;
23 import android.media.MediaDescription;
24 import android.media.MediaMetadata;
25 import android.media.browse.MediaBrowser.MediaItem;
26 import android.media.session.MediaSession.QueueItem;
27 import android.net.Uri;
28 import android.os.AsyncTask;
29 import android.os.Bundle;
30 import android.provider.MediaStore;
31 import android.provider.MediaStore.Audio.AlbumColumns;
32 import android.provider.MediaStore.Audio.AudioColumns;
33 import android.service.media.MediaBrowserService.Result;
34 import android.util.Log;
35 
36 import java.io.File;
37 import java.io.FileNotFoundException;
38 import java.io.IOException;
39 import java.io.InputStream;
40 import java.util.ArrayList;
41 import java.util.HashSet;
42 import java.util.List;
43 import java.util.Set;
44 
45 public class DataModel {
46     private static final String TAG = "LMBDataModel";
47 
48     private static final Uri[] ALL_AUDIO_URI = new Uri[] {
49             MediaStore.Audio.Media.INTERNAL_CONTENT_URI,
50             MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
51     };
52 
53     private static final Uri[] ALBUMS_URI = new Uri[] {
54             MediaStore.Audio.Albums.INTERNAL_CONTENT_URI,
55             MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI
56     };
57 
58     private static final Uri[] ARTISTS_URI = new Uri[] {
59             MediaStore.Audio.Artists.INTERNAL_CONTENT_URI,
60             MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI
61     };
62 
63     private static final Uri[] GENRES_URI = new Uri[] {
64         MediaStore.Audio.Genres.INTERNAL_CONTENT_URI,
65         MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI
66     };
67 
68     private static final String QUERY_BY_KEY_WHERE_CLAUSE =
69             AudioColumns.ALBUM_KEY + "= ? or "
70                     + AudioColumns.ARTIST_KEY + " = ? or "
71                     + AudioColumns.TITLE_KEY + " = ? or "
72                     + AudioColumns.DATA + " like ?";
73 
74     private static final String EXTERNAL = "external";
75     private static final String INTERNAL = "internal";
76 
77     private static final Uri ART_BASE_URI = Uri.parse("content://media/external/audio/albumart");
78 
79     public static final String PATH_KEY = "PATH";
80 
81     private Context mContext;
82     private ContentResolver mResolver;
83     private AsyncTask mPendingTask;
84 
85     private List<QueueItem> mQueue = new ArrayList<>();
86 
DataModel(Context context)87     public DataModel(Context context) {
88         mContext = context;
89         mResolver = context.getContentResolver();
90     }
91 
onQueryByFolder(String parentId, Result<List<MediaItem>> result)92     public void onQueryByFolder(String parentId, Result<List<MediaItem>> result) {
93         FilesystemListTask query = new FilesystemListTask(result, ALL_AUDIO_URI, mResolver);
94         queryInBackground(result, query);
95     }
96 
onQueryByAlbum(String parentId, Result<List<MediaItem>> result)97     public void onQueryByAlbum(String parentId, Result<List<MediaItem>> result) {
98         QueryTask query = new QueryTask.Builder()
99                 .setResolver(mResolver)
100                 .setResult(result)
101                 .setUri(ALBUMS_URI)
102                 .setKeyColumn(AudioColumns.ALBUM_KEY)
103                 .setTitleColumn(AudioColumns.ALBUM)
104                 .setFlags(MediaItem.FLAG_BROWSABLE)
105                 .build();
106         queryInBackground(result, query);
107     }
108 
onQueryByArtist(String parentId, Result<List<MediaItem>> result)109     public void onQueryByArtist(String parentId, Result<List<MediaItem>> result) {
110         QueryTask query = new QueryTask.Builder()
111                 .setResolver(mResolver)
112                 .setResult(result)
113                 .setUri(ARTISTS_URI)
114                 .setKeyColumn(AudioColumns.ARTIST_KEY)
115                 .setTitleColumn(AudioColumns.ARTIST)
116                 .setFlags(MediaItem.FLAG_BROWSABLE)
117                 .build();
118         queryInBackground(result, query);
119     }
120 
onQueryByGenre(String parentId, Result<List<MediaItem>> result)121     public void onQueryByGenre(String parentId, Result<List<MediaItem>> result) {
122         QueryTask query = new QueryTask.Builder()
123                 .setResolver(mResolver)
124                 .setResult(result)
125                 .setUri(GENRES_URI)
126                 .setKeyColumn(MediaStore.Audio.Genres._ID)
127                 .setTitleColumn(MediaStore.Audio.Genres.NAME)
128                 .setFlags(MediaItem.FLAG_BROWSABLE)
129                 .build();
130         queryInBackground(result, query);
131     }
132 
queryInBackground(Result<List<MediaItem>> result, AsyncTask<Void, Void, Void> task)133     private void queryInBackground(Result<List<MediaItem>> result,
134             AsyncTask<Void, Void, Void> task) {
135         result.detach();
136 
137         if (mPendingTask != null) {
138             mPendingTask.cancel(true);
139         }
140 
141         mPendingTask = task;
142         task.execute();
143     }
144 
getQueue()145     public List<QueueItem> getQueue() {
146         return mQueue;
147     }
148 
getMetadata(String key)149     public MediaMetadata getMetadata(String key) {
150         Cursor cursor = null;
151         MediaMetadata.Builder metadata = new MediaMetadata.Builder();
152         try {
153             for (Uri uri : ALL_AUDIO_URI) {
154                 cursor = mResolver.query(uri, null, AudioColumns.TITLE_KEY + " = ?",
155                         new String[]{ key }, null);
156                 if (cursor != null) {
157                     int title = cursor.getColumnIndex(AudioColumns.TITLE);
158                     int artist = cursor.getColumnIndex(AudioColumns.ARTIST);
159                     int album = cursor.getColumnIndex(AudioColumns.ALBUM);
160                     int albumId = cursor.getColumnIndex(AudioColumns.ALBUM_ID);
161                     int duration = cursor.getColumnIndex(AudioColumns.DURATION);
162 
163                     while (cursor.moveToNext()) {
164                         metadata.putString(MediaMetadata.METADATA_KEY_TITLE,
165                                 cursor.getString(title));
166                         metadata.putString(MediaMetadata.METADATA_KEY_ARTIST,
167                                 cursor.getString(artist));
168                         metadata.putString(MediaMetadata.METADATA_KEY_ALBUM,
169                                 cursor.getString(album));
170                         metadata.putLong(MediaMetadata.METADATA_KEY_DURATION,
171                                 cursor.getLong(duration));
172 
173                         String albumArt = null;
174                         Uri albumArtUri = ContentUris.withAppendedId(ART_BASE_URI,
175                                 cursor.getLong(albumId));
176                         try {
177                             InputStream dummy = mResolver.openInputStream(albumArtUri);
178                             albumArt = albumArtUri.toString();
179                             dummy.close();
180                         } catch (IOException e) {
181                             // Ignored because the albumArt is intialized correctly anyway.
182                         }
183                         metadata.putString(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, albumArt);
184                         break;
185                     }
186                 }
187             }
188         } finally {
189             if (cursor != null) {
190                 cursor.close();
191             }
192         }
193 
194         return metadata.build();
195     }
196 
197     /**
198      * Note: This clears out the queue. You should have a local copy of the queue before calling
199      * this method.
200      */
onQueryByKey(String lastCategory, String parentId, Result<List<MediaItem>> result)201     public void onQueryByKey(String lastCategory, String parentId, Result<List<MediaItem>> result) {
202         mQueue.clear();
203 
204         QueryTask.Builder query = new QueryTask.Builder()
205                 .setResolver(mResolver)
206                 .setResult(result);
207 
208         if (LocalMediaBrowserService.GENRES_ID.equals(lastCategory)) {
209             // Genres come from a different table and don't use the where clause from the
210             // usual media table so we need to have this condition.
211             try {
212                 long id = Long.parseLong(parentId);
213                 query.setUri(new Uri[] {
214                     MediaStore.Audio.Genres.Members.getContentUri(EXTERNAL, id),
215                     MediaStore.Audio.Genres.Members.getContentUri(INTERNAL, id) });
216             } catch (NumberFormatException e) {
217                 // This should never happen.
218                 Log.e(TAG, "Incorrect key type: " + parentId + ", sending empty result");
219                 result.sendResult(new ArrayList<MediaItem>());
220                 return;
221             }
222         } else {
223             query.setUri(ALL_AUDIO_URI)
224                     .setWhereClause(QUERY_BY_KEY_WHERE_CLAUSE)
225                     .setWhereArgs(new String[] { parentId, parentId, parentId, parentId });
226         }
227 
228         query.setKeyColumn(AudioColumns.TITLE_KEY)
229                 .setTitleColumn(AudioColumns.TITLE)
230                 .setSubtitleColumn(AudioColumns.ALBUM)
231                 .setFlags(MediaItem.FLAG_PLAYABLE)
232                 .setQueue(mQueue);
233         queryInBackground(result, query.build());
234     }
235 
236     // This async task is similar enough to all the others that it feels like it can be unified
237     // but is different enough that unifying it makes the code for both cases look really weird
238     // and over paramterized so at the risk of being a little more verbose, this is separated out
239     // in the name of understandability.
240     private static class FilesystemListTask extends AsyncTask<Void, Void, Void> {
241         private static final String[] COLUMNS = { AudioColumns.DATA };
242         private Result<List<MediaItem>> mResult;
243         private Uri[] mUris;
244         private ContentResolver mResolver;
245 
FilesystemListTask(Result<List<MediaItem>> result, Uri[] uris, ContentResolver resolver)246         public FilesystemListTask(Result<List<MediaItem>> result, Uri[] uris,
247                 ContentResolver resolver) {
248             mResult = result;
249             mUris = uris;
250             mResolver = resolver;
251         }
252 
253         @Override
doInBackground(Void... voids)254         protected Void doInBackground(Void... voids) {
255             Set<String> paths = new HashSet<String>();
256 
257             Cursor cursor = null;
258             for (Uri uri : mUris) {
259                 try {
260                     cursor = mResolver.query(uri, COLUMNS, null , null, null);
261                     if (cursor != null) {
262                         int pathColumn = cursor.getColumnIndex(AudioColumns.DATA);
263 
264                         while (cursor.moveToNext()) {
265                             // We want to de-dupe paths of each of the songs so we get just a list
266                             // of containing directories.
267                             String fullPath = cursor.getString(pathColumn);
268                             int fileNameStart = fullPath.lastIndexOf(File.separator);
269                             if (fileNameStart < 0) {
270                                 continue;
271                             }
272 
273                             String dirPath = fullPath.substring(0, fileNameStart);
274                             paths.add(dirPath);
275                         }
276                     }
277                 } catch (SQLiteException e) {
278                     Log.e(TAG, "Failed to execute query " + e);  // Stack trace is noisy.
279                 } finally {
280                     if (cursor != null) {
281                         cursor.close();
282                     }
283                 }
284             }
285 
286             // Take the list of deduplicated directories and put them into the results list with
287             // the full directory path as the key so we can match on it later.
288             List<MediaItem> results = new ArrayList<>();
289             for (String path : paths) {
290                 int dirNameStart = path.lastIndexOf(File.separator) + 1;
291                 String dirName = path.substring(dirNameStart, path.length());
292                 MediaDescription description = new MediaDescription.Builder()
293                         .setMediaId(path + "%")  // Used in a like query.
294                         .setTitle(dirName)
295                         .setSubtitle(path)
296                         .build();
297                 results.add(new MediaItem(description, MediaItem.FLAG_BROWSABLE));
298             }
299             mResult.sendResult(results);
300             return null;
301         }
302     }
303 
304     private static class QueryTask extends AsyncTask<Void, Void, Void> {
305         private Result<List<MediaItem>> mResult;
306         private String[] mColumns;
307         private String mWhereClause;
308         private String[] mWhereArgs;
309         private String mKeyColumn;
310         private String mTitleColumn;
311         private String mSubtitleColumn;
312         private Uri[] mUris;
313         private int mFlags;
314         private ContentResolver mResolver;
315         private List<QueueItem> mQueue;
316 
QueryTask(Builder builder)317         private QueryTask(Builder builder) {
318             mColumns = builder.mColumns;
319             mWhereClause = builder.mWhereClause;
320             mWhereArgs = builder.mWhereArgs;
321             mKeyColumn = builder.mKeyColumn;
322             mTitleColumn = builder.mTitleColumn;
323             mUris = builder.mUris;
324             mFlags = builder.mFlags;
325             mResolver = builder.mResolver;
326             mResult = builder.mResult;
327             mQueue = builder.mQueue;
328             mSubtitleColumn = builder.mSubtitleColumn;
329         }
330 
331         @Override
doInBackground(Void... voids)332         protected Void doInBackground(Void... voids) {
333             List<MediaItem> results = new ArrayList<>();
334 
335             long idx = 0;
336 
337             Cursor cursor = null;
338             for (Uri uri : mUris) {
339                 try {
340                     cursor = mResolver.query(uri, mColumns, mWhereClause, mWhereArgs, null);
341                     if (cursor != null) {
342                         int keyColumn = cursor.getColumnIndex(mKeyColumn);
343                         int titleColumn = cursor.getColumnIndex(mTitleColumn);
344                         int pathColumn = cursor.getColumnIndex(AudioColumns.DATA);
345                         int subtitleColumn = -1;
346                         if (mSubtitleColumn != null) {
347                             subtitleColumn = cursor.getColumnIndex(mSubtitleColumn);
348                         }
349 
350                         while (cursor.moveToNext()) {
351                             Bundle path = new Bundle();
352                             if (pathColumn != -1) {
353                                 path.putString(PATH_KEY, cursor.getString(pathColumn));
354                             }
355 
356                             MediaDescription.Builder builder = new MediaDescription.Builder()
357                                     .setMediaId(cursor.getString(keyColumn))
358                                     .setTitle(cursor.getString(titleColumn))
359                                     .setExtras(path);
360 
361                             if (subtitleColumn != -1) {
362                                 builder.setSubtitle(cursor.getString(subtitleColumn));
363                             }
364 
365                             MediaDescription description = builder.build();
366                             results.add(new MediaItem(description, mFlags));
367 
368                             // We rebuild the queue here so if the user selects the item then we
369                             // can immediately use this queue.
370                             if (mQueue != null) {
371                                 mQueue.add(new QueueItem(description, idx));
372                             }
373                             idx++;
374                         }
375                     }
376                 } catch (SQLiteException e) {
377                     // Sometimes tables don't exist if the media scanner hasn't seen data of that
378                     // type yet. For example, the genres table doesn't seem to exist at all until
379                     // the first time a song with a genre is encountered. If we hit an exception,
380                     // the result is never sent causing the other end to hang up, which is a bad
381                     // thing. We can instead just be resilient and return an empty list.
382                     Log.i(TAG, "Failed to execute query " + e);  // Stack trace is noisy.
383                 } finally {
384                     if (cursor != null) {
385                         cursor.close();
386                     }
387                 }
388             }
389 
390             mResult.sendResult(results);
391             return null;  // Ignored.
392         }
393 
394         //
395         // Boilerplate Alert!
396         //
397         public static class Builder {
398             private Result<List<MediaItem>> mResult;
399             private String[] mColumns;
400             private String mWhereClause;
401             private String[] mWhereArgs;
402             private String mKeyColumn;
403             private String mTitleColumn;
404             private String mSubtitleColumn;
405             private Uri[] mUris;
406             private int mFlags;
407             private ContentResolver mResolver;
408             private List<QueueItem> mQueue;
409 
setColumns(String[] columns)410             public Builder setColumns(String[] columns) {
411                 mColumns = columns;
412                 return this;
413             }
414 
setWhereClause(String whereClause)415             public Builder setWhereClause(String whereClause) {
416                 mWhereClause = whereClause;
417                 return this;
418             }
419 
setWhereArgs(String[] whereArgs)420             public Builder setWhereArgs(String[] whereArgs) {
421                 mWhereArgs = whereArgs;
422                 return this;
423             }
424 
setUri(Uri[] uris)425             public Builder setUri(Uri[] uris) {
426                 mUris = uris;
427                 return this;
428             }
429 
setKeyColumn(String keyColumn)430             public Builder setKeyColumn(String keyColumn) {
431                 mKeyColumn = keyColumn;
432                 return this;
433             }
434 
setTitleColumn(String titleColumn)435             public Builder setTitleColumn(String titleColumn) {
436                 mTitleColumn = titleColumn;
437                 return this;
438             }
439 
setSubtitleColumn(String subtitleColumn)440             public Builder setSubtitleColumn(String subtitleColumn) {
441                 mSubtitleColumn = subtitleColumn;
442                 return this;
443             }
444 
setFlags(int flags)445             public Builder setFlags(int flags) {
446                 mFlags = flags;
447                 return this;
448             }
449 
setResult(Result<List<MediaItem>> result)450             public Builder setResult(Result<List<MediaItem>> result) {
451                 mResult = result;
452                 return this;
453             }
454 
setResolver(ContentResolver resolver)455             public Builder setResolver(ContentResolver resolver) {
456                 mResolver = resolver;
457                 return this;
458             }
459 
setQueue(List<QueueItem> queue)460             public Builder setQueue(List<QueueItem> queue) {
461                 mQueue = queue;
462                 return this;
463             }
464 
build()465             public QueryTask build() {
466                 if (mUris == null || mKeyColumn == null || mResolver == null ||
467                         mResult == null || mTitleColumn == null) {
468                     throw new IllegalStateException(
469                             "uri, keyColumn, resolver, result and titleColumn are required.");
470                 }
471                 return new QueryTask(this);
472             }
473         }
474     }
475 }
476