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 Uri[] uri = null; 209 if (lastCategory.equals(LocalMediaBrowserService.GENRES_ID)) { 210 // Genres come from a different table and don't use the where clause from the 211 // usual media table so we need to have this condition. 212 try { 213 long id = Long.parseLong(parentId); 214 query.setUri(new Uri[] { 215 MediaStore.Audio.Genres.Members.getContentUri(EXTERNAL, id), 216 MediaStore.Audio.Genres.Members.getContentUri(INTERNAL, id) }); 217 } catch (NumberFormatException e) { 218 // This should never happen. 219 Log.e(TAG, "Incorrect key type: " + parentId + ", sending empty result"); 220 result.sendResult(new ArrayList<MediaItem>()); 221 return; 222 } 223 } else { 224 query.setUri(ALL_AUDIO_URI) 225 .setWhereClause(QUERY_BY_KEY_WHERE_CLAUSE) 226 .setWhereArgs(new String[] { parentId, parentId, parentId, parentId }); 227 } 228 229 query.setKeyColumn(AudioColumns.TITLE_KEY) 230 .setTitleColumn(AudioColumns.TITLE) 231 .setSubtitleColumn(AudioColumns.ALBUM) 232 .setFlags(MediaItem.FLAG_PLAYABLE) 233 .setQueue(mQueue); 234 queryInBackground(result, query.build()); 235 } 236 237 // This async task is similar enough to all the others that it feels like it can be unified 238 // but is different enough that unifying it makes the code for both cases look really weird 239 // and over paramterized so at the risk of being a little more verbose, this is separated out 240 // in the name of understandability. 241 private static class FilesystemListTask extends AsyncTask<Void, Void, Void> { 242 private static final String[] COLUMNS = { AudioColumns.DATA }; 243 private Result<List<MediaItem>> mResult; 244 private Uri[] mUris; 245 private ContentResolver mResolver; 246 FilesystemListTask(Result<List<MediaItem>> result, Uri[] uris, ContentResolver resolver)247 public FilesystemListTask(Result<List<MediaItem>> result, Uri[] uris, 248 ContentResolver resolver) { 249 mResult = result; 250 mUris = uris; 251 mResolver = resolver; 252 } 253 254 @Override doInBackground(Void... voids)255 protected Void doInBackground(Void... voids) { 256 Set<String> paths = new HashSet<String>(); 257 258 Cursor cursor = null; 259 for (Uri uri : mUris) { 260 try { 261 cursor = mResolver.query(uri, COLUMNS, null , null, null); 262 if (cursor != null) { 263 int pathColumn = cursor.getColumnIndex(AudioColumns.DATA); 264 265 while (cursor.moveToNext()) { 266 // We want to de-dupe paths of each of the songs so we get just a list 267 // of containing directories. 268 String fullPath = cursor.getString(pathColumn); 269 int fileNameStart = fullPath.lastIndexOf(File.separator); 270 if (fileNameStart < 0) { 271 continue; 272 } 273 274 String dirPath = fullPath.substring(0, fileNameStart); 275 paths.add(dirPath); 276 } 277 } 278 } catch (SQLiteException e) { 279 Log.e(TAG, "Failed to execute query " + e); // Stack trace is noisy. 280 } finally { 281 if (cursor != null) { 282 cursor.close(); 283 } 284 } 285 } 286 287 // Take the list of deduplicated directories and put them into the results list with 288 // the full directory path as the key so we can match on it later. 289 List<MediaItem> results = new ArrayList<>(); 290 for (String path : paths) { 291 int dirNameStart = path.lastIndexOf(File.separator) + 1; 292 String dirName = path.substring(dirNameStart, path.length()); 293 MediaDescription description = new MediaDescription.Builder() 294 .setMediaId(path + "%") // Used in a like query. 295 .setTitle(dirName) 296 .setSubtitle(path) 297 .build(); 298 results.add(new MediaItem(description, MediaItem.FLAG_BROWSABLE)); 299 } 300 mResult.sendResult(results); 301 return null; 302 } 303 } 304 305 private static class QueryTask extends AsyncTask<Void, Void, Void> { 306 private Result<List<MediaItem>> mResult; 307 private String[] mColumns; 308 private String mWhereClause; 309 private String[] mWhereArgs; 310 private String mKeyColumn; 311 private String mTitleColumn; 312 private String mSubtitleColumn; 313 private Uri[] mUris; 314 private int mFlags; 315 private ContentResolver mResolver; 316 private List<QueueItem> mQueue; 317 QueryTask(Builder builder)318 private QueryTask(Builder builder) { 319 mColumns = builder.mColumns; 320 mWhereClause = builder.mWhereClause; 321 mWhereArgs = builder.mWhereArgs; 322 mKeyColumn = builder.mKeyColumn; 323 mTitleColumn = builder.mTitleColumn; 324 mUris = builder.mUris; 325 mFlags = builder.mFlags; 326 mResolver = builder.mResolver; 327 mResult = builder.mResult; 328 mQueue = builder.mQueue; 329 mSubtitleColumn = builder.mSubtitleColumn; 330 } 331 332 @Override doInBackground(Void... voids)333 protected Void doInBackground(Void... voids) { 334 List<MediaItem> results = new ArrayList<>(); 335 336 long idx = 0; 337 338 Cursor cursor = null; 339 for (Uri uri : mUris) { 340 try { 341 cursor = mResolver.query(uri, mColumns, mWhereClause, mWhereArgs, null); 342 if (cursor != null) { 343 int keyColumn = cursor.getColumnIndex(mKeyColumn); 344 int titleColumn = cursor.getColumnIndex(mTitleColumn); 345 int pathColumn = cursor.getColumnIndex(AudioColumns.DATA); 346 int subtitleColumn = -1; 347 if (mSubtitleColumn != null) { 348 subtitleColumn = cursor.getColumnIndex(mSubtitleColumn); 349 } 350 351 while (cursor.moveToNext()) { 352 Bundle path = new Bundle(); 353 if (pathColumn != -1) { 354 path.putString(PATH_KEY, cursor.getString(pathColumn)); 355 } 356 357 MediaDescription.Builder builder = new MediaDescription.Builder() 358 .setMediaId(cursor.getString(keyColumn)) 359 .setTitle(cursor.getString(titleColumn)) 360 .setExtras(path); 361 362 if (subtitleColumn != -1) { 363 builder.setSubtitle(cursor.getString(subtitleColumn)); 364 } 365 366 MediaDescription description = builder.build(); 367 results.add(new MediaItem(description, mFlags)); 368 369 // We rebuild the queue here so if the user selects the item then we 370 // can immediately use this queue. 371 if (mQueue != null) { 372 mQueue.add(new QueueItem(description, idx)); 373 } 374 idx++; 375 } 376 } 377 } catch (SQLiteException e) { 378 // Sometimes tables don't exist if the media scanner hasn't seen data of that 379 // type yet. For example, the genres table doesn't seem to exist at all until 380 // the first time a song with a genre is encountered. If we hit an exception, 381 // the result is never sent causing the other end to hang up, which is a bad 382 // thing. We can instead just be resilient and return an empty list. 383 Log.i(TAG, "Failed to execute query " + e); // Stack trace is noisy. 384 } finally { 385 if (cursor != null) { 386 cursor.close(); 387 } 388 } 389 } 390 391 mResult.sendResult(results); 392 return null; // Ignored. 393 } 394 395 // 396 // Boilerplate Alert! 397 // 398 public static class Builder { 399 private Result<List<MediaItem>> mResult; 400 private String[] mColumns; 401 private String mWhereClause; 402 private String[] mWhereArgs; 403 private String mKeyColumn; 404 private String mTitleColumn; 405 private String mSubtitleColumn; 406 private Uri[] mUris; 407 private int mFlags; 408 private ContentResolver mResolver; 409 private List<QueueItem> mQueue; 410 setColumns(String[] columns)411 public Builder setColumns(String[] columns) { 412 mColumns = columns; 413 return this; 414 } 415 setWhereClause(String whereClause)416 public Builder setWhereClause(String whereClause) { 417 mWhereClause = whereClause; 418 return this; 419 } 420 setWhereArgs(String[] whereArgs)421 public Builder setWhereArgs(String[] whereArgs) { 422 mWhereArgs = whereArgs; 423 return this; 424 } 425 setUri(Uri[] uris)426 public Builder setUri(Uri[] uris) { 427 mUris = uris; 428 return this; 429 } 430 setKeyColumn(String keyColumn)431 public Builder setKeyColumn(String keyColumn) { 432 mKeyColumn = keyColumn; 433 return this; 434 } 435 setTitleColumn(String titleColumn)436 public Builder setTitleColumn(String titleColumn) { 437 mTitleColumn = titleColumn; 438 return this; 439 } 440 setSubtitleColumn(String subtitleColumn)441 public Builder setSubtitleColumn(String subtitleColumn) { 442 mSubtitleColumn = subtitleColumn; 443 return this; 444 } 445 setFlags(int flags)446 public Builder setFlags(int flags) { 447 mFlags = flags; 448 return this; 449 } 450 setResult(Result<List<MediaItem>> result)451 public Builder setResult(Result<List<MediaItem>> result) { 452 mResult = result; 453 return this; 454 } 455 setResolver(ContentResolver resolver)456 public Builder setResolver(ContentResolver resolver) { 457 mResolver = resolver; 458 return this; 459 } 460 setQueue(List<QueueItem> queue)461 public Builder setQueue(List<QueueItem> queue) { 462 mQueue = queue; 463 return this; 464 } 465 build()466 public QueryTask build() { 467 if (mUris == null || mKeyColumn == null || mResolver == null || 468 mResult == null || mTitleColumn == null) { 469 throw new IllegalStateException( 470 "uri, keyColumn, resolver, result and titleColumn are required."); 471 } 472 return new QueryTask(this); 473 } 474 } 475 } 476 } 477