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 unused = mResolver.openInputStream(albumArtUri); 178 albumArt = albumArtUri.toString(); 179 unused.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