package com.marverenic.music.data.store; import android.annotation.TargetApi; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.database.ContentObserver; import android.database.Cursor; import android.net.Uri; import android.os.Build; import android.provider.MediaStore; import android.support.annotation.Nullable; import android.support.v4.util.ArrayMap; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.marverenic.music.model.Album; import com.marverenic.music.model.Artist; import com.marverenic.music.model.AutoPlaylist; import com.marverenic.music.model.Genre; import com.marverenic.music.model.Playlist; import com.marverenic.music.model.Song; import com.marverenic.music.model.playlistrules.AutoPlaylistRule; import com.marverenic.music.utils.Util; import com.tbruyelle.rxpermissions.RxPermissions; import java.io.File; import java.io.FileReader; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import rx.Observable; import rx.subjects.BehaviorSubject; import timber.log.Timber; import static android.Manifest.permission.READ_EXTERNAL_STORAGE; import static android.Manifest.permission.WRITE_EXTERNAL_STORAGE; public final class MediaStoreUtil { private static final String AUTO_PLAYLIST_EXTENSION = ".jpl"; // This value is hardcoded into Android's sqlite implementation. If a query exceeds this many // variables, an SQLiteException will be thrown, so when doing long queries be sure to check // against this value. This value is defined as SQLITE_MAX_VARIABLE_NUMBER in // https://raw.githubusercontent.com/android/platform_external_sqlite/master/dist/sqlite3.c private static final int SQL_MAX_VARS = 999; private static final String[] SONG_PROJECTION = new String[]{ MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media._ID, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.DATA, MediaStore.Audio.Media.YEAR, MediaStore.Audio.Media.DATE_ADDED, MediaStore.Audio.Media.ALBUM_ID, MediaStore.Audio.Media.ARTIST_ID, MediaStore.Audio.Media.TRACK }; private static final String[] ARTIST_PROJECTION = new String[]{ MediaStore.Audio.Artists._ID, MediaStore.Audio.Artists.ARTIST, }; private static final String[] ALBUM_PROJECTION = new String[]{ MediaStore.Audio.Albums._ID, MediaStore.Audio.Albums.ALBUM, MediaStore.Audio.Media.ARTIST_ID, MediaStore.Audio.Albums.ARTIST, MediaStore.Audio.Albums.LAST_YEAR, MediaStore.Audio.Albums.ALBUM_ART }; private static final String[] PLAYLIST_PROJECTION = new String[]{ MediaStore.Audio.Playlists._ID, MediaStore.Audio.Playlists.NAME }; private static final String[] GENRE_PROJECTION = new String[]{ MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME }; private static final String[] PLAYLIST_ENTRY_PROJECTION = new String[]{ MediaStore.Audio.Playlists.Members.TITLE, MediaStore.Audio.Playlists.Members.AUDIO_ID, MediaStore.Audio.Playlists.Members.ARTIST, MediaStore.Audio.Playlists.Members.ALBUM, MediaStore.Audio.Playlists.Members.DURATION, MediaStore.Audio.Playlists.Members.DATA, MediaStore.Audio.Playlists.Members.YEAR, MediaStore.Audio.Playlists.Members.DATE_ADDED, MediaStore.Audio.Playlists.Members.ALBUM_ID, MediaStore.Audio.Playlists.Members.ARTIST_ID, MediaStore.Audio.Playlists.Members.TRACK }; private static BehaviorSubject<Boolean> sPermissionObservable; private static Map<Uri, BehaviorSubject<Boolean>> sContentObservers; private static Map<Uri, Integer> sSelfPendingUpdates; static { sContentObservers = new ArrayMap<>(); sSelfPendingUpdates = new ArrayMap<>(); } /** * This class is never instantiated */ private MediaStoreUtil() { } @TargetApi(Build.VERSION_CODES.M) public static boolean hasPermission(Context context) { return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Util.hasPermissions(context, READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE); } public static Observable<Boolean> waitForPermission() { if (sPermissionObservable == null) { sPermissionObservable = BehaviorSubject.create(); } return sPermissionObservable.filter(hasPermission -> hasPermission).take(1); } public static Observable<Boolean> getPermission(Context context) { if (sPermissionObservable != null && sPermissionObservable.hasValue()) { return sPermissionObservable.asObservable(); } else { return promptPermission(context); } } @TargetApi(Build.VERSION_CODES.M) public static Observable<Boolean> promptPermission(Context context) { if (sPermissionObservable == null) { sPermissionObservable = BehaviorSubject.create(); } if (hasPermission(context)) { if (!sPermissionObservable.hasValue() || !sPermissionObservable.getValue()) { sPermissionObservable.onNext(true); } return sPermissionObservable; } RxPermissions.getInstance(context).request(READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE) .subscribe(sPermissionObservable::onNext, throwable -> { Timber.i(throwable, "Failed to get storage permission"); }); return sPermissionObservable.asObservable(); } public static Observable<Boolean> getContentObserver(Context context, Uri uri) { BehaviorSubject<Boolean> observer; if (!sContentObservers.containsKey(uri)) { observer = BehaviorSubject.create(); sContentObservers.put(uri, observer); sSelfPendingUpdates.put(uri, 0); context.getContentResolver().registerContentObserver(uri, true, new ContentObserver(null) { @Override public void onChange(boolean selfChange) { observer.onNext(selfChange); } }); } else { observer = sContentObservers.get(uri); } return observer.filter(selfChange -> { int pendingUpdates = sSelfPendingUpdates.get(uri); if (pendingUpdates > 0) { pendingUpdates--; sSelfPendingUpdates.put(uri, pendingUpdates); return false; } else { return true; } }); } private static void ignoreSingleContentUpdate() { for (Map.Entry<Uri, Integer> count : sSelfPendingUpdates.entrySet()) { count.setValue(count.getValue() + 1); } } public static List<Song> getSongs(Context context, Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { String musicSelection = MediaStore.Audio.Media.IS_MUSIC + " != 0"; if (selection != null) { musicSelection += " AND " + selection; } Cursor cur = context.getContentResolver().query( uri, SONG_PROJECTION, musicSelection, selectionArgs, null); if (cur == null) { return Collections.emptyList(); } List<Song> songs = Song.buildSongList(cur, context.getResources()); Collections.sort(songs); cur.close(); return Collections.unmodifiableList(songs); } public static List<Song> getSongs(Context context, @Nullable String selection, @Nullable String[] selectionArgs) { return getSongs(context, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, selection, selectionArgs); } public static List<Album> getAlbums(Context context, @Nullable String selection, @Nullable String[] selectionArgs) { Cursor cur = context.getContentResolver().query( MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, ALBUM_PROJECTION, selection, selectionArgs, null); if (cur == null) { return Collections.emptyList(); } List<Album> albums = Album.buildAlbumList(cur, context.getResources()); Collections.sort(albums); cur.close(); return Collections.unmodifiableList(albums); } public static List<Artist> getArtists(Context context, @Nullable String selection, @Nullable String[] selectionArgs) { Cursor cur = context.getContentResolver().query( MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, ARTIST_PROJECTION, selection, selectionArgs, null); if (cur == null) { return Collections.emptyList(); } List<Artist> artists = Artist.buildArtistList(cur, context.getResources()); Collections.sort(artists); cur.close(); return Collections.unmodifiableList(artists); } public static List<Genre> getGenres(Context context, @Nullable String selection, @Nullable String[] selectionArgs) { Cursor cur = context.getContentResolver().query( MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, GENRE_PROJECTION, selection, selectionArgs, null); if (cur == null) { return Collections.emptyList(); } List<Genre> genres = Genre.buildGenreList(context, cur); Collections.sort(genres); cur.close(); return Collections.unmodifiableList(genres); } public static List<Playlist> getAllPlaylists(Context context) { List<Playlist> playlists = getPlaylists(context, null, null); for (Playlist p : getAutoPlaylists(context)) { if (playlists.remove(p)) { playlists.add(p); } else { // If AutoPlaylists have been deleted outside of Jockey, delete its configuration //noinspection ResultOfMethodCallIgnored new File(context.getExternalFilesDir(null) + "/" + p.getPlaylistName() + AUTO_PLAYLIST_EXTENSION) .delete(); } } Collections.sort(playlists); return Collections.unmodifiableList(playlists); } private static List<Playlist> getPlaylists(Context context, @Nullable String selection, @Nullable String[] selectionArgs) { Cursor cur = context.getContentResolver().query( MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, PLAYLIST_PROJECTION, selection, selectionArgs, null); if (cur == null) { return Collections.emptyList(); } List<Playlist> playlists = Playlist.buildPlaylistList(cur); Collections.sort(playlists); cur.close(); return playlists; } private static List<AutoPlaylist> getAutoPlaylists(Context context) { List<AutoPlaylist> autoPlaylists = new ArrayList<>(); Gson gson = new GsonBuilder() .registerTypeAdapter(AutoPlaylistRule.class, new AutoPlaylistRule.RuleTypeAdapter()) .create(); try { File externalFiles = new File(context.getExternalFilesDir(null) + "/"); if (externalFiles.exists() || externalFiles.mkdirs()) { String[] files = externalFiles.list(); for (String file : files) { if (file.endsWith(AUTO_PLAYLIST_EXTENSION)) { String filePath = externalFiles + File.separator + file; autoPlaylists.add(readAutoPlaylist(gson, filePath)); } } } } catch (IOException e) { Timber.e(e, "Failed to read AutoPlaylist"); } Collections.sort(autoPlaylists); return autoPlaylists; } private static AutoPlaylist readAutoPlaylist(Gson gson, String path) throws IOException { FileReader reader = new FileReader(path); try { return gson.fromJson(reader, AutoPlaylist.class); } finally { reader.close(); } } public static List<Album> getArtistAlbums(Context context, Artist artist) { return getArtistAlbums(context, artist.getArtistId()); } public static List<Album> getArtistAlbums(Context context, long artistId) { String selection = MediaStore.Audio.AudioColumns.ARTIST_ID + " = ?"; String[] selectionArgs = {Long.toString(artistId)}; return getAlbums(context, selection, selectionArgs); } public static List<Song> getPlaylistSongs(Context context, Playlist playlist) { return getPlaylistSongs(context, playlist.getPlaylistId()); } public static List<Song> getPlaylistSongs(Context context, long playlistId) { Cursor cur = context.getContentResolver().query( MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId), PLAYLIST_ENTRY_PROJECTION, null, null, null); if (cur == null) { return Collections.emptyList(); } List<Song> songs = Song.buildSongList(cur, context.getResources()); cur.close(); return Collections.unmodifiableList(songs); } public static List<Song> getGenreSongs(Context context, Genre genre, @Nullable String selection, @Nullable String[] selectionArgs) { return getGenreSongs(context, genre.getGenreId(), selection, selectionArgs); } public static List<Song> getGenreSongs(Context context, long genreId, @Nullable String selection, @Nullable String[] selectionArgs) { return getSongs(context, MediaStore.Audio.Genres.Members.getContentUri("external", genreId), selection, selectionArgs); } public static Artist findArtistByName(Context context, String artistName) { Cursor cur = context.getContentResolver().query( MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, ARTIST_PROJECTION, "UPPER(" + MediaStore.Audio.Artists.ARTIST + ") = ?", new String[]{artistName.toUpperCase()}, null); if (cur == null) { return null; } Artist found = (cur.moveToFirst()) ? new Artist(context, cur) : null; cur.close(); return found; } public static Artist findArtistById(Context context, long artistId) { Cursor cur = context.getContentResolver().query( MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI, ARTIST_PROJECTION, MediaStore.Audio.Artists._ID + " = ?", new String[]{Long.toString(artistId)}, null); if (cur == null) { return null; } Artist found = (cur.moveToFirst()) ? new Artist(context, cur) : null; cur.close(); return found; } public static Album findAlbumById(Context context, long albumId) { Cursor cur = context.getContentResolver().query( MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI, ALBUM_PROJECTION, MediaStore.Audio.Albums._ID + " = ?", new String[]{Long.toString(albumId)}, null); if (cur == null) { return null; } Album found = (cur.moveToFirst()) ? new Album(context, cur) : null; cur.close(); return found; } public static Playlist findPlaylistByName(Context context, String playlistName) { Cursor cur = context.getContentResolver().query( MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, PLAYLIST_PROJECTION, "UPPER(" + MediaStore.Audio.Playlists.NAME + ") = ?", new String[]{playlistName.toUpperCase()}, null); if (cur == null) { return null; } Playlist found = (cur.moveToFirst()) ? new Playlist(cur) : null; cur.close(); return found; } public static Playlist createPlaylist(Context context, String playlistName, @Nullable List<Song> songs) { ignoreSingleContentUpdate(); String name = playlistName.trim(); // Add the playlist to the MediaStore ContentValues mInserts = new ContentValues(); mInserts.put(MediaStore.Audio.Playlists.NAME, name); mInserts.put(MediaStore.Audio.Playlists.DATE_ADDED, System.currentTimeMillis()); mInserts.put(MediaStore.Audio.Playlists.DATE_MODIFIED, System.currentTimeMillis()); Uri newPlaylistUri = context.getContentResolver() .insert(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, mInserts); if (newPlaylistUri == null) { throw new RuntimeException("Content resolver insert returned null"); } // Get the id of the new playlist Cursor cursor = context.getContentResolver().query( newPlaylistUri, PLAYLIST_PROJECTION, null, null, null); if (cursor == null) { throw new RuntimeException("Content resolver query returned null"); } cursor.moveToFirst(); Playlist playlist = new Playlist(cursor); cursor.close(); // If we have a list of songs, associate it with the playlist if (songs != null) { ContentValues[] values = new ContentValues[songs.size()]; for (int i = 0; i < songs.size(); i++) { values[i] = new ContentValues(); values[i].put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, i); values[i].put( MediaStore.Audio.Playlists.Members.AUDIO_ID, songs.get(i).getSongId()); } Uri uri = MediaStore.Audio.Playlists.Members .getContentUri("external", playlist.getPlaylistId()); ContentResolver resolver = context.getContentResolver(); ignoreSingleContentUpdate(); resolver.bulkInsert(uri, values); ignoreSingleContentUpdate(); resolver.notifyChange(Uri.parse("content://media"), null); } return playlist; } public static void deletePlaylist(Context context, Playlist playlist) { ignoreSingleContentUpdate(); // Remove the playlist from the MediaStore context.getContentResolver().delete( MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, MediaStore.Audio.Playlists._ID + "=?", new String[]{playlist.getPlaylistId() + ""}); } public static void editPlaylist(Context context, Playlist playlist, @Nullable List<Song> songs) { ignoreSingleContentUpdate(); // Clear the playlist... Uri uri = MediaStore.Audio.Playlists.Members .getContentUri("external", playlist.getPlaylistId()); ContentResolver resolver = context.getContentResolver(); resolver.delete(uri, null, null); if (songs != null) { // ... Then add all of the songs to it ContentValues[] values = new ContentValues[songs.size()]; for (int i = 0; i < songs.size(); i++) { values[i] = new ContentValues(); values[i].put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, i + 1); values[i].put( MediaStore.Audio.Playlists.Members.AUDIO_ID, songs.get(i).getSongId()); } ignoreSingleContentUpdate(); resolver.bulkInsert(uri, values); ignoreSingleContentUpdate(); resolver.notifyChange(Uri.parse("content://media"), null); } } private static int getPlaylistSize(Context context, long playlistId) { Cursor cur = context.getContentResolver().query( MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId), new String[]{MediaStore.Audio.Playlists.Members.AUDIO_ID}, null, null, null); if (cur == null) { throw new RuntimeException("Couldn\'t open Cursor"); } int count = cur.getCount(); cur.close(); return count; } public static void appendToPlaylist(Context context, Playlist playlist, Song song) { Uri uri = MediaStore.Audio.Playlists.Members .getContentUri("external", playlist.getPlaylistId()); ContentResolver resolver = context.getContentResolver(); ContentValues values = new ContentValues(); values.put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, getPlaylistSize(context, playlist.getPlaylistId())); values.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, song.getSongId()); ignoreSingleContentUpdate(); resolver.insert(uri, values); ignoreSingleContentUpdate(); resolver.notifyChange(Uri.parse("content://media"), null); } public static void appendToPlaylist(Context context, Playlist playlist, List<Song> songs) { Uri uri = MediaStore.Audio.Playlists.Members .getContentUri("external", playlist.getPlaylistId()); ContentResolver resolver = context.getContentResolver(); int startingCount = getPlaylistSize(context, playlist.getPlaylistId()); ContentValues[] values = new ContentValues[songs.size()]; for (int i = 0; i < songs.size(); i++) { values[i] = new ContentValues(); values[i].put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, startingCount + i); values[i].put( MediaStore.Audio.Playlists.Members.AUDIO_ID, songs.get(i).getSongId()); } ignoreSingleContentUpdate(); resolver.bulkInsert(uri, values); ignoreSingleContentUpdate(); resolver.notifyChange(Uri.parse("content://media"), null); } /** * Get a list of songs to play for a certain input file. If a song is passed as the file, then * the list will include other songs in the same directory. If a playlist is passed as the file, * then the playlist will be opened as a regular playlist. * * @param context A {@link Context} used to resolve media paths * @param file The {@link File} which the list will be built around * @param type The MIME type of the file being opened * @return A list of songs that are in the same directory as the file */ public static List<Song> buildSongListFromFile(Context context, File file, String type) { if (MediaStore.Audio.Playlists.CONTENT_TYPE.equals(type)) { // If a playlist was opened, try to find and play its entry from the MediaStore Cursor cur = context.getContentResolver().query( MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, null, MediaStore.Audio.Playlists.DATA + "=?", new String[] {file.getPath()}, MediaStore.Audio.Playlists.NAME + " ASC"); if (cur == null) { return null; } List<Song> songs = getPlaylistSongs(context, new Playlist(cur)); cur.close(); return songs; } else { // If the file isn't a playlist, use a content resolver to find the song and play it // Find all songs in the directory Cursor cur = context.getContentResolver().query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, null, MediaStore.Audio.Media.DATA + " like ?", new String[] {"%" + file.getParent() + "/%"}, MediaStore.Audio.Media.DATA + " ASC"); if (cur == null) { return null; } List<Song> songs = Song.buildSongList(cur, context.getResources()); cur.close(); return songs; } } /** * Build an {@link ArrayList} of {@link Song}s from a list of id's. Doesn't require the * library to be loaded * @param songIDs The list of song ids to convert to {@link Song}s * @param context The {@link Context} used to open a {@link Cursor} * @return An {@link ArrayList} of {@link Song}s with ids matching those of the * songIDs parameter */ public static List<Song> buildSongListFromIds(long[] songIDs, Context context) { List<Song> contents = new ArrayList<>(); // Split this request into batches of size SQL_MAX_VARS for (int i = 0; i < songIDs.length / SQL_MAX_VARS; i++) { contents.addAll(buildSongListFromIds(songIDs, context, i * SQL_MAX_VARS, (i + 1) * SQL_MAX_VARS)); } // Load the remaining songs (the last section that's not divisible by SQL_MAX_VARS) contents.addAll(buildSongListFromIds(songIDs, context, SQL_MAX_VARS * (songIDs.length / SQL_MAX_VARS), songIDs.length)); // Sort the contents of the list so that it matches the order of the array List<Song> songs = new ArrayList<>(); for (long i : songIDs) { for (Song s : contents) { if (s.getSongId() == i) { songs.add(s); break; } } } return songs; } /** * Implementation of {@link MediaStoreUtil#buildSongListFromIds(long[], Context)}. This method * takes upper and lower bounds into account when looking at the song ids so that it can be * partitioned into sections of size {@link #SQL_MAX_VARS} without the overhead of making array * copies. * @param songIDs The song ids build the list from * @param context A Context to open a {@link Cursor} to query the {@link MediaStore} * @param lowerBound The first index in the array to get IDs from * @param upperBound The last index in the array to get IDs from * @return An unsorted list of {@link Song Songs} with the same IDs as the ids that were passed * into {@code songIDs} */ private static List<Song> buildSongListFromIds(long[] songIDs, Context context, int lowerBound, int upperBound) { List<Song> contents = new ArrayList<>(); if (songIDs.length == 0) { return contents; } String query = MediaStore.Audio.Media._ID + " IN(?"; String[] ids = new String[upperBound - lowerBound]; ids[0] = Long.toString(songIDs[lowerBound]); for (int i = 1; i < ids.length; i++) { query += ",?"; ids[i] = Long.toString(songIDs[i + lowerBound]); } query += ")"; Cursor cur = context.getContentResolver().query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, SONG_PROJECTION, query, ids, MediaStore.Audio.Media.TITLE + " ASC"); if (cur == null) { return contents; } contents = Song.buildSongList(cur, context.getResources()); cur.close(); return contents; } }