ch.blinkenlights.android.vanilla.MediaUtils.java Source code

Java tutorial

Introduction

Here is the source code for ch.blinkenlights.android.vanilla.MediaUtils.java

Source

/*
 * Copyright (C) 2010, 2011 Christopher Eby <kreed@kreed.org>
 * Copyright (C) 2017-2018 Adrian Ulrich <adrian@blinkenlights.ch>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

package ch.blinkenlights.android.vanilla;

import ch.blinkenlights.android.medialibrary.MediaLibrary;
import ch.blinkenlights.android.medialibrary.MediaMetadataExtractor;

import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;

import android.util.Log;

import junit.framework.Assert;

import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Environment;
import android.provider.MediaStore;
import android.support.v4.content.FileProvider;
import android.text.TextUtils;
import android.database.MatrixCursor;
import android.util.Log;
import android.widget.Toast;

/**
 * Provides some static Song/MediaStore-related utility functions.
 */
public class MediaUtils {
    /**
     * A special invalid media type.
     */
    public static final int TYPE_INVALID = -1;
    /**
     * Type indicating an id represents an artist.
     */
    public static final int TYPE_ARTIST = 0;
    /**
     * Type indicating an id represents an album.
     */
    public static final int TYPE_ALBUM = 1;
    /**
     * Type indicating an id represents a song.
     */
    public static final int TYPE_SONG = 2;
    /**
     * Type indicating an id represents a playlist.
     */
    public static final int TYPE_PLAYLIST = 3;
    /**
     * Type indicating ids represent genres.
     */
    public static final int TYPE_GENRE = 4;
    /**
     * Type indicating an id represents an albumartist
     */
    public static final int TYPE_ALBARTIST = 5;
    /**
     * Type indicating an id represents a composer
     */
    public static final int TYPE_COMPOSER = 6;
    /**
     * Special type for files and folders. Most methods do not accept this type
     * since files have no MediaStore id and require special handling.
     */
    public static final int TYPE_FILE = 7;
    /**
     * The number of different valid media types.
     */
    public static final int TYPE_COUNT = 8;

    /**
     * The default sort order for media queries. First artist, then album, then
     * song number.
     */
    private static final String DEFAULT_SORT = "artist_sort,album_sort,disc_num,song_num";

    /**
     * The default sort order for albums. First the album, then songnumber
     */
    private static final String ALBUM_SORT = "album_sort,disc_num,song_num";

    /**
     * The default sort order for files. Simply use the path
     */
    private static final String FILE_SORT = "path";

    /**
     * Cached random instance.
     */
    private static Random sRandom;

    /**
     * Shuffled list of all songs in the library.
     */
    private static ArrayList<Song> sAllSongs = new ArrayList<Song>();
    /**
     * True if sAllSongs was shuffled by album.
     */
    private static boolean sAllSongsAS;

    /**
     * Total number of songs in the music library, or -1 for uninitialized.
     */
    private static int sSongCount = -1;

    /**
     * Returns a cached random instanced, creating it if necessary.
     */
    public static Random getRandom() {
        if (sRandom == null)
            sRandom = new Random();
        return sRandom;
    }

    /**
     * Builds a query that will return all the songs represented by the given
     * parameters.
     *
     * @param type MediaUtils.TYPE_ARTIST, TYPE_ALBUM, or TYPE_SONG.
     * @param id The MediaStore id of the song, artist, or album.
     * @param projection The columns to query.
     * @param select An extra selection to pass to the query, or null.
     * @return The initialized query.
     */
    private static QueryTask buildMediaQuery(int type, long id, String[] projection, String select) {
        StringBuilder selection = new StringBuilder();
        String sort = DEFAULT_SORT;

        if (select != null) {
            selection.append(select);
            selection.append(" AND ");
        }

        switch (type) {
        case TYPE_SONG:
            selection.append(MediaLibrary.SongColumns._ID);
            break;
        case TYPE_ARTIST:
            selection.append(MediaLibrary.ContributorColumns.ARTIST_ID);
            break;
        case TYPE_ALBARTIST:
            selection.append(MediaLibrary.ContributorColumns.ALBUMARTIST_ID);
            break;
        case TYPE_COMPOSER:
            selection.append(MediaLibrary.ContributorColumns.COMPOSER_ID);
            break;
        case TYPE_ALBUM:
            selection.append(MediaLibrary.SongColumns.ALBUM_ID);
            sort = ALBUM_SORT;
            break;
        case TYPE_GENRE:
            selection.append(MediaLibrary.GenreSongColumns._GENRE_ID);
            break;
        default:
            throw new IllegalArgumentException("Invalid type specified: " + type);
        }

        selection.append('=');
        selection.append(id);

        QueryTask result = new QueryTask(MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS, projection, selection.toString(),
                null, sort);
        result.type = type;
        return result;
    }

    /**
     * Builds a query that will return all the songs in the playlist with the
     * given id.
     *
     * @param id The id of the playlist in MediaStore.Audio.Playlists.
     * @param projection The columns to query.
     * @return The initialized query.
     */
    public static QueryTask buildPlaylistQuery(long id, String[] projection) {
        String sort = MediaLibrary.PlaylistSongColumns.POSITION;
        String selection = MediaLibrary.PlaylistSongColumns.PLAYLIST_ID + "=" + id;
        QueryTask result = new QueryTask(MediaLibrary.VIEW_PLAYLIST_SONGS, projection, selection, null, sort);
        result.type = TYPE_PLAYLIST;
        return result;
    }

    /**
     * Builds a query with the given information.
     *
     * @param type Type the id represents. Must be one of the Song.TYPE_*
     * constants.
     * @param id The id of the element in the MediaStore content provider for
     * the given type.
     * @param selection An extra selection to be passed to the query. May be
     * null. Must not be used with type == TYPE_SONG or type == TYPE_PLAYLIST
     */
    public static QueryTask buildQuery(int type, long id, String[] projection, String selection) {
        switch (type) {
        case TYPE_ARTIST:
        case TYPE_ALBARTIST:
        case TYPE_COMPOSER:
        case TYPE_ALBUM:
        case TYPE_SONG:
        case TYPE_GENRE:
            return buildMediaQuery(type, id, projection, selection);
        case TYPE_PLAYLIST:
            return buildPlaylistQuery(id, projection);
        default:
            throw new IllegalArgumentException("Specified type not valid: " + type);
        }
    }

    /**
     * Query the MediaStore to determine the id of the genre the song belongs
     * to.
     *
     * @param context The context to use
     * @param id The id of the song to query the genre for.
     */
    public static long queryGenreForSong(Context context, long id) {
        String[] projection = { MediaLibrary.GenreSongColumns._GENRE_ID };
        String query = MediaLibrary.GenreSongColumns.SONG_ID + "=?";
        String[] queryArgs = new String[] { Long.toString(id) };

        Cursor cursor = MediaLibrary.queryLibrary(context, MediaLibrary.TABLE_GENRES_SONGS, projection, query,
                queryArgs, null);
        if (cursor != null) {
            if (cursor.moveToNext())
                return cursor.getLong(0);
            cursor.close();
        }
        return 0;
    }

    /**
     * Shuffle a Song list using Collections.shuffle().
     *
     * @param albumShuffle If true, preserve the order of songs inside albums.
     */
    public static void shuffle(List<Song> list, boolean albumShuffle) {
        int size = list.size();
        if (size < 2)
            return;

        Random random = getRandom();
        if (albumShuffle) {
            List<Song> tempList = new ArrayList<Song>(list);
            Collections.sort(tempList);

            // Build map of albumId to start index in sorted list
            Map<Long, Integer> albumStartIndices = new HashMap<Long, Integer>();
            int index = 0;
            for (Song song : tempList) {
                if (!albumStartIndices.containsKey(song.albumId)) {
                    albumStartIndices.put(song.albumId, index);
                }
                index++;
            }

            //Extract album list and shuffle
            List<Long> shuffledAlbums = new ArrayList<Long>(albumStartIndices.keySet());
            Collections.shuffle(shuffledAlbums, random);

            //Build Song list from album list
            list.clear();
            for (Long albumId : shuffledAlbums) {
                int songIndex = albumStartIndices.get(albumId);
                Song song = tempList.get(songIndex);
                do {
                    list.add(song);
                    songIndex++;
                    if (songIndex < size) {
                        song = tempList.get(songIndex);
                    } else {
                        break;
                    }
                } while (albumId == song.albumId);
            }
        } else {
            Collections.shuffle(list, random);
        }
    }

    /**
     * Determine if any songs are available from the library.
     *
     * @param context The Context to use
     * @return True if it's possible to retrieve any songs, false otherwise. For
     * example, false could be returned if there are no songs in the library.
     */
    public static boolean isSongAvailable(Context context) {
        if (sSongCount == -1) {
            sSongCount = MediaLibrary.getLibrarySize(context);
        }

        return sSongCount != 0;
    }

    /**
     * Returns a list containing all the songs found on the
     * device's library.
     *
     * @param context The Context to use
     */
    private static ArrayList<Song> getAllSongs(Context context) {
        QueryTask query = new QueryTask(MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS, Song.FILLED_PROJECTION, null, null,
                null);
        Cursor cursor = query.runQuery(context);
        ArrayList<Song> list = new ArrayList<Song>();

        if (cursor == null)
            return list;

        while (cursor.moveToNext()) {
            Song song = new Song(-1);
            song.populate(cursor);
            list.add(song);
        }
        cursor.close();
        return list;
    }

    /**
     * Called if we detected a medium change
     * This flushes some cached data
     */
    public static void onMediaChange() {
        sSongCount = -1;
        sAllSongs.clear();
    }

    /**
     * Creates and sends a share intent across the system.
     * @param ctx context to execute resolving on.
     * @param song the song to share.
     */
    public static void shareMedia(Context ctx, Song song) {
        if (song == null || song.path == null)
            return;

        Uri uri = null;
        try {
            uri = FileProvider.getUriForFile(ctx, ctx.getApplicationContext().getPackageName() + ".fileprovider",
                    new File(song.path));
        } catch (IllegalArgumentException e) {
            Toast.makeText(ctx, R.string.share_failed, Toast.LENGTH_SHORT).show();
        }

        if (uri == null)
            return; // Fileprovider failed, we can not continue.

        Intent intent = new Intent(Intent.ACTION_SEND);
        intent.setType("audio/*");
        intent.putExtra(Intent.EXTRA_STREAM, uri);
        try {
            ctx.startActivity(Intent.createChooser(intent, ctx.getString(R.string.sendto)));
        } catch (ActivityNotFoundException e) {
            Toast.makeText(ctx, R.string.no_receiving_apps, Toast.LENGTH_SHORT).show();
        }
    }

    /**
     * Returns the first matching song (or NULL) of given type + id combination
     *
     * @param context A Context to use.
     * @param type The MediaTye to query
     * @param id The id of given type to query
     */
    public static Song getSongByTypeId(Context context, int type, long id) {
        Song song = new Song(-1);
        QueryTask query = buildQuery(type, id, Song.FILLED_PROJECTION, null);
        Cursor cursor = query.runQuery(context);
        if (cursor != null) {
            if (cursor.getCount() > 0) {
                cursor.moveToPosition(0);
                song.populate(cursor);
            }
            cursor.close();
        }
        return song.isFilled() ? song : null;
    }

    /**
     * Returns a list of songs randomly selected from all the songs in the Android
     * MediaStore. When albumShuffle is specified, the returned list may contain all the songs
     * for that album, in order. Otherwise, only one song will be returned. If no songs are
     * available, the list will be empty.
     *
     * @param context The Context to use
     * @param albumShuffle Whether or not we should shuffle by album
     */
    public static List<Song> getRandomSongs(Context context, boolean albumShuffle) {
        ArrayList<Song> songs = sAllSongs;

        if (songs.size() == 0 || sAllSongsAS != albumShuffle) {
            sAllSongs = getAllSongs(context);
            sAllSongsAS = albumShuffle;
            shuffle(sAllSongs, albumShuffle);
            songs = sAllSongs;
            // We don't need it but know the value, we can fill the cache for free.
            sSongCount = songs.size();
        }

        final List<Song> results = new ArrayList<>();

        if (songs.size() > 0) {

            long firstAlbumId = songs.get(0).albumId;

            // if we're in album shuffle mode, we'll want to add in the entire album in one go,
            // so loop through the upcoming songs and add all those that have the same album id
            // as the song we initially got
            boolean addMore;
            do {
                final Song song = songs.remove(0);

                // when in album shuffle mode, we don't want to flag any of the added songs
                // as random, since manually enqueuing or changing random mode will remove every album track.
                if (!albumShuffle) {
                    song.flags |= Song.FLAG_RANDOM;
                }

                results.add(song);
                addMore = albumShuffle && songs.size() > 0 && songs.get(0).albumId == firstAlbumId;
            } while (addMore);
        }

        return results;
    }

    /**
     * Delete the given file or directory recursively.
     *
     * @return True if successful; false otherwise.
     */
    public static boolean deleteFile(File file) {
        File[] children = file.listFiles();
        if (children != null) {
            for (File child : children) {
                deleteFile(child);
            }
        }
        return file.delete();
    }

    /**
    * This is an ugly hack: The tries to 'guess' if given path
    * is also accessible using a fuse mount
    */
    private static String sanitizeMediaPath(String path) {

        String exPath = Environment.getExternalStorageDirectory().getAbsolutePath();
        File exStorage = new File(exPath + "/Android");
        long exLastmod = exStorage.lastModified();

        if (exLastmod > 0 && path != null) {
            String pfx = path;
            while (true) {
                if ((new File(pfx + "/Android")).lastModified() == exLastmod) {
                    String guessPath = exPath + path.substring(pfx.length());
                    if ((new File(guessPath)).exists()) {
                        path = guessPath;
                        break;
                    }
                }

                pfx = (new File(pfx)).getParent();
                if (pfx == null)
                    break; /* hit root */
            }
        }

        return path;
    }

    /**
    * Adds a final slash if the path points to an existing directory
    */
    private static String addDirEndSlash(String path) {
        if (path.length() > 0 && path.charAt(path.length() - 1) != '/') {
            if ((new File(path)).isDirectory()) {
                path += "/";
            }
        }
        return path;
    }

    /**
     * Build a query that will contain all the media under the given path.
     *
     * @param path The path, e.g. /mnt/sdcard/music/
     * @param projection The columns to query
     * @return The initialized query.
     */
    public static QueryTask buildFileQuery(String path, String[] projection) {
        /* make sure that the path is:
           -> fixed-up to point to the real mountpoint if user browsed to the mediadir symlink
           -> terminated with a / if it is a directory
           -> ended with a % for the LIKE query
        */
        path = addDirEndSlash(sanitizeMediaPath(path)) + "%";
        final String query = MediaLibrary.SongColumns.PATH + " LIKE ?";
        String[] qargs = { path };

        QueryTask result = new QueryTask(MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS, projection, query, qargs,
                FILE_SORT);
        result.type = TYPE_FILE;
        return result;
    }

    /**
     * Returns a (possibly empty) Cursor for given file path
     * @param path The path to the file to be queried
     * @return A new Cursor object
     * */
    public static Cursor getCursorForFileQuery(String path) {
        MatrixCursor matrixCursor = new MatrixCursor(Song.FILLED_PROJECTION);
        MediaMetadataExtractor tags = new MediaMetadataExtractor(path);
        String title = tags.getFirst(MediaMetadataExtractor.TITLE);
        String album = tags.getFirst(MediaMetadataExtractor.ALBUM);
        String artist = tags.getFirst(MediaMetadataExtractor.ARTIST);
        String duration = tags.getFirst(MediaMetadataExtractor.DURATION);

        if (duration != null) { // looks like we will be able to play this file
            // Vanilla requires each file to be identified by its unique id in the media database.
            // However: This file is not in the database, so we are going to roll our own
            // using the negative crc32 sum of the path value. While this is not perfect
            // (the same file may be accessed using various paths) it's the fastest method
            // and far good enough.
            long songId = MediaLibrary.hash63(path) * -1;
            if (songId > -2)
                songId = -2; // must be less than -1 (-1 defines an empty song object)

            // Build minimal fake-database entry for this file
            Object[] objData = new Object[] { songId, path, "", "", "", 0, 0, 0, 0, 0 };

            if (title != null)
                objData[2] = title;
            if (album != null)
                objData[3] = album;
            if (artist != null)
                objData[4] = artist;
            if (duration != null)
                objData[7] = Long.parseLong(duration, 10);

            matrixCursor.addRow(objData);
        }

        return matrixCursor;
    }

    /**
     * Returns the id's used by Androids native media database for given song
     *
     * @param context the context to use
     * @param song the song to query
     * @return long { song_id, album_id, artist_id } - all set to -1 on error
     */
    public static long[] getAndroidMediaIds(Context context, Song song) {
        long[] result = { -1, -1, -1 };
        String[] projection = new String[] { MediaStore.Audio.Media._ID, MediaStore.Audio.Media.ALBUM_ID,
                MediaStore.Audio.Media.ARTIST_ID };
        try {
            Cursor cursor = context.getContentResolver().query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
                    projection, MediaStore.Audio.Media.DATA + "=?", new String[] { song.path }, null);
            if (cursor != null) {
                if (cursor.moveToFirst()) {
                    for (int i = 0; i < result.length; i++)
                        result[i] = cursor.getLong(i);
                }
                cursor.close();
            }
        } catch (SecurityException e) {
            Log.e("VanillaMusic",
                    "Wowies: No permission to read EXTERNAL_CONTENT_URI for song " + song.path + ": " + e);
        }
        return result;
    }

    /**
     * Retrieve ID of specified media type for requested song. This works only for
     * media-oriented types: {@link #TYPE_ARTIST}, {@link #TYPE_ALBUM}, {@link #TYPE_SONG}
     * @param song requested song
     * @param mType media type e.g. {@link #TYPE_ARTIST}
      * @return ID of media type, {@link #TYPE_INVALID} if unsupported
      */
    public static long getCurrentIdForType(Song song, int mType) {
        if (song == null)
            return TYPE_INVALID;

        switch (mType) {
        case TYPE_ARTIST:
            return song.artistId;
        case TYPE_ALBUM:
            return song.albumId;
        case TYPE_SONG:
            return song.id;
        default:
            return TYPE_INVALID;
        }
    }
}