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