Merge branch 'medialibrary'

This commit is contained in:
Adrian Ulrich 2016-12-26 19:33:08 +01:00
commit 6c85a11743
31 changed files with 2234 additions and 1552 deletions

View File

@ -158,7 +158,6 @@ function show(n) {
<li>Ferenc Nagy (icon)</li>
<li>Jean-Baptiste Lab (notication color invert)</li>
<li>Jean-Fran&ccedil;ois Im (cover art loading)</li>
<li>Jeremy Erickson (SD-Scanner code)</li>
<li>Jerry Liao (Chinese (Taiwan) translation)</li>
<li>Jiri Gr&ouml;nroos (Finnish translation)</li>
<li>Magnus Anderssen (headset button)</li>

View File

@ -38,24 +38,10 @@ Copied from SD Scanner's layout/main.xml with minor changes
android:text="@string/button_start">
<requestFocus />
</Button>
<ProgressBar
style="?android:attr/progressBarStyleHorizontal"
android:max="100"
android:id="@+id/progress_bar"
android:layout_width="match_parent"
android:layout_height="48dp"
android:gravity="center_vertical"
android:progress="0" />
<TextView
android:id="@+id/progress_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="10000"
android:text="@string/progress_unstarted_label" />
<TextView
android:id="@+id/debug_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:text="@string/empty" />
</LinearLayout>

View File

@ -0,0 +1,588 @@
/*
* Copyright (C) 2016 Adrian Ulrich <adrian@blinkenlights.ch>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package ch.blinkenlights.android.medialibrary;
import android.content.Context;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.ContentObserver;
import android.provider.MediaStore;
import android.os.Environment;
import java.util.ArrayList;
import java.io.File;
public class MediaLibrary {
public static final String TABLE_SONGS = "songs";
public static final String TABLE_ALBUMS = "albums";
public static final String TABLE_CONTRIBUTORS = "contributors";
public static final String TABLE_CONTRIBUTORS_SONGS = "contributors_songs";
public static final String TABLE_GENRES = "genres";
public static final String TABLE_GENRES_SONGS = "genres_songs";
public static final String TABLE_PLAYLISTS = "playlists";
public static final String TABLE_PLAYLISTS_SONGS = "playlists_songs";
public static final String VIEW_ARTISTS = "_artists";
public static final String VIEW_ALBUMS_ARTISTS = "_albums_artists";
public static final String VIEW_SONGS_ALBUMS_ARTISTS = "_songs_albums_artists";
public static final String VIEW_PLAYLIST_SONGS = "_playlists_songs";
public static final int ROLE_ARTIST = 0;
public static final int ROLE_COMPOSER = 1;
/**
* Our static backend instance
*/
private static MediaLibraryBackend sBackend;
/**
* An instance to the created scanner thread during our own creation
*/
private static MediaScanner sScanner;
/**
* The observer to call-back during database changes
*/
private static ContentObserver sContentObserver;
/**
* The lock we are using during object creation
*/
private static final Object[] sWait = new Object[0];
private static MediaLibraryBackend getBackend(Context context) {
if (sBackend == null) {
// -> unlikely
synchronized(sWait) {
if (sBackend == null) {
sBackend = new MediaLibraryBackend(context);
sScanner = new MediaScanner(context, sBackend);
sScanner.startQuickScan();
}
}
}
return sBackend;
}
/**
* Triggers a rescan of the library
*
* @param context the context to use
* @param forceFull starts a full / slow scan if true
* @param drop drop the existing library if true
*/
public static void scanLibrary(Context context, boolean forceFull, boolean drop) {
MediaLibraryBackend backend = getBackend(context); // also initialized sScanner
if (drop) {
backend.execSQL("DELETE FROM "+MediaLibrary.TABLE_SONGS);
forceFull = true;
// fixme: should clean orphaned AFTER scan finished
}
if (forceFull) {
sScanner.startFullScan();
} else {
sScanner.startNormalScan();
}
}
/**
* Whacky function to get the current scan progress
*
* @param context the context to use
* @return a description of the progress, null if no scan is running
*/
public static String describeScanProgress(Context context) {
MediaLibraryBackend backend = getBackend(context); // also initialized sScanner
MediaScanner.MediaScanPlan.Statistics stats = sScanner.getScanStatistics();
String msg = null;
if (stats.lastFile != null)
msg = "seen files = "+stats.seen+", changes made = "+stats.changed+", currently scanning = "+stats.lastFile;
return msg;
}
/**
* Registers a new content observer for the media library
*
* @param observer the content observer we are going to call on changes
*/
public static void registerContentObserver(ContentObserver observer) {
if (sContentObserver == null) {
sContentObserver = observer;
} else {
throw new IllegalStateException("ContentObserver was already registered");
}
}
/**
* Broadcasts a change to the observer, which will queue and dispatch
* the event to any registered observer
*/
static void notifyObserver() {
if (sContentObserver != null)
sContentObserver.onChange(true);
}
/**
* Perform a media query on the database, returns a cursor
*
* @param context the context to use
* @param table the table to query, one of MediaLibrary.TABLE_*
* @param projection the columns to returns in this query
* @param selection the selection (WHERE) to use
* @param selectionArgs arguments for the selection
* @param orderBy how the result should be sorted
*/
public static Cursor queryLibrary(Context context, String table, String[] projection, String selection, String[] selectionArgs, String orderBy) {
return getBackend(context).query(false, table, projection, selection, selectionArgs, null, null, orderBy, null);
}
/**
* Removes a single song from the database
*
* @param context the context to use
* @param id the song id to delete
* @return the number of affected rows
*/
public static int removeSong(Context context, long id) {
int rows = getBackend(context).delete(TABLE_SONGS, SongColumns._ID+"="+id, null);
if (rows > 0) {
getBackend(context).cleanOrphanedEntries();
notifyObserver();
}
return rows;
}
/**
* Updates the play or skipcount of a song
*
* @param context the context to use
* @param id the song id to update
*/
public static void updateSongPlayCounts(Context context, long id, boolean played) {
final String column = played ? MediaLibrary.SongColumns.PLAYCOUNT : MediaLibrary.SongColumns.SKIPCOUNT;
String selection = MediaLibrary.SongColumns._ID+"="+id;
getBackend(context).execSQL("UPDATE "+MediaLibrary.TABLE_SONGS+" SET "+column+"="+column+"+1 WHERE "+selection);
}
/**
* Creates a new empty playlist
*
* @param context the context to use
* @param name the name of the new playlist
* @return long the id of the created playlist, -1 on error
*/
public static long createPlaylist(Context context, String name) {
ContentValues v = new ContentValues();
v.put(MediaLibrary.PlaylistColumns._ID, hash63(name));
v.put(MediaLibrary.PlaylistColumns.NAME, name);
long id = getBackend(context).insert(MediaLibrary.TABLE_PLAYLISTS, null, v);
if (id != -1)
notifyObserver();
return id;
}
/**
* Deletes a playlist and all of its child elements
*
* @param context the context to use
* @param id the playlist id to delete
* @return boolean true if the playlist was deleted
*/
public static boolean removePlaylist(Context context, long id) {
// first, wipe all songs
removeFromPlaylist(context, MediaLibrary.PlaylistSongColumns.PLAYLIST_ID+"="+id, null);
int rows = getBackend(context).delete(MediaLibrary.TABLE_PLAYLISTS, MediaLibrary.PlaylistColumns._ID+"="+id, null);
boolean removed = (rows > 0);
if (removed)
notifyObserver();
return removed;
}
/**
* Adds a batch of songs to a playlist
*
* @param context the context to use
* @param playlistId the id of the playlist parent
* @param ids an array list with the song ids to insert
* @return the number of added items
*/
public static int addToPlaylist(Context context, long playlistId, ArrayList<Long> ids) {
long pos = 0;
// First we need to get the position of the last item
String[] projection = { MediaLibrary.PlaylistSongColumns.POSITION };
String selection = MediaLibrary.PlaylistSongColumns.PLAYLIST_ID+"="+playlistId;
String order = MediaLibrary.PlaylistSongColumns.POSITION+" DESC";
Cursor cursor = queryLibrary(context, MediaLibrary.TABLE_PLAYLISTS_SONGS, projection, selection, null, order);
if (cursor.moveToFirst())
pos = cursor.getLong(0) + 1;
cursor.close();
ArrayList<ContentValues> bulk = new ArrayList<>();
for (Long id : ids) {
if (getBackend(context).getSongMtime(id) == 0)
continue;
ContentValues v = new ContentValues();
v.put(MediaLibrary.PlaylistSongColumns.PLAYLIST_ID, playlistId);
v.put(MediaLibrary.PlaylistSongColumns.SONG_ID, id);
v.put(MediaLibrary.PlaylistSongColumns.POSITION, pos);
bulk.add(v);
pos++;
}
int rows = getBackend(context).bulkInsert(MediaLibrary.TABLE_PLAYLISTS_SONGS, null, bulk);
if (rows > 0)
notifyObserver();
return rows;
}
/**
* Removes a set of items from a playlist
*
* @param context the context to use
* @param selection the selection for the items to drop
* @param selectionArgs arguments for `selection'
* @return the number of deleted rows, -1 on error
*/
public static int removeFromPlaylist(Context context, String selection, String[] selectionArgs) {
int rows = getBackend(context).delete(MediaLibrary.TABLE_PLAYLISTS_SONGS, selection, selectionArgs);
if (rows > 0)
notifyObserver();
return rows;
}
/**
* Renames an existing playlist
*
* @param context the context to use
* @param playlistId the id of the playlist to rename
* @param newName the new name of the playlist
* @return the id of the new playlist, -1 on error
*/
public static long renamePlaylist(Context context, long playlistId, String newName) {
long newId = createPlaylist(context, newName);
if (newId >= 0) {
String selection = MediaLibrary.PlaylistSongColumns.PLAYLIST_ID+"="+playlistId;
ContentValues v = new ContentValues();
v.put(MediaLibrary.PlaylistSongColumns.PLAYLIST_ID, newId);
getBackend(context).update(MediaLibrary.TABLE_PLAYLISTS_SONGS, v, selection, null);
removePlaylist(context, playlistId);
}
if (newId != -1)
notifyObserver();
return newId;
}
/**
* Moves an item in a playlist. Note: both items should be in the
* same playlist - 'fun things' will happen otherwise.
*
* @param context the context to use
* @param from the _id of the 'dragged' element
* @param to the _id of the 'repressed' element
*/
public static void movePlaylistItem(Context context, long from, long to) {
long fromPos, toPos, playlistId;
String[] projection = { MediaLibrary.PlaylistSongColumns.POSITION, MediaLibrary.PlaylistSongColumns.PLAYLIST_ID };
String selection = MediaLibrary.PlaylistSongColumns._ID+"=";
// Get playlist id and position of the 'from' item
Cursor cursor = queryLibrary(context, MediaLibrary.TABLE_PLAYLISTS_SONGS, projection, selection+Long.toString(from), null, null);
cursor.moveToFirst();
fromPos = cursor.getLong(0);
playlistId = cursor.getLong(1);
cursor.close();
// Get position of the target item
cursor = queryLibrary(context, MediaLibrary.TABLE_PLAYLISTS_SONGS, projection, selection+Long.toString(to), null, null);
cursor.moveToFirst();
toPos = cursor.getLong(0);
cursor.close();
// Moving down -> We actually want to be below the target
if (toPos > fromPos)
toPos++;
// shift all rows +1
String setArg = MediaLibrary.PlaylistSongColumns.POSITION+"="+MediaLibrary.PlaylistSongColumns.POSITION+"+1";
selection = MediaLibrary.PlaylistSongColumns.PLAYLIST_ID+"="+playlistId+" AND "+MediaLibrary.PlaylistSongColumns.POSITION+" >= "+toPos;
getBackend(context).execSQL("UPDATE "+MediaLibrary.TABLE_PLAYLISTS_SONGS+" SET "+setArg+" WHERE "+selection);
ContentValues v = new ContentValues();
v.put(MediaLibrary.PlaylistSongColumns.POSITION, toPos);
selection = MediaLibrary.PlaylistSongColumns._ID+"="+from;
getBackend(context).update(MediaLibrary.TABLE_PLAYLISTS_SONGS, v, selection, null);
notifyObserver();
}
/**
* Returns the number of songs in the music library
*
* @param context the context to use
* @return the number of songs
*/
public static int getLibrarySize(Context context) {
int count = 0;
Cursor cursor = queryLibrary(context, TABLE_SONGS, new String[]{"count(*)"}, null, null, null);
if (cursor.moveToFirst())
count = cursor.getInt(0);
cursor.close();
return count;
}
/**
* Returns the 'key' of given string used for sorting and searching
*
* @param name the string to convert
* @return the the key of given name
*/
public static String keyFor(String name) {
return MediaStore.Audio.keyFor(name);
}
/**
* Simple 63 bit hash function for strings
*
* @param str the string to hash
* @return a positive long
*/
public static long hash63(String str) {
if (str == null)
return 0;
long hash = 0;
int len = str.length();
for (int i = 0; i < len ; i++) {
hash = 31*hash + str.charAt(i);
}
return (hash < 0 ? hash*-1 : hash);
}
/**
* Returns the guessed media paths for this device
*
* @return array with guessed directories
*/
public static File[] discoverMediaPaths() {
ArrayList<File> scanTargets = new ArrayList<>();
// this should always exist
scanTargets.add(Environment.getExternalStorageDirectory());
// this *may* exist
File sdCard = new File("/storage/sdcard1");
if (sdCard.isDirectory())
scanTargets.add(sdCard);
return scanTargets.toArray(new File[scanTargets.size()]);
}
// Columns of Song entries
public interface SongColumns {
/**
* The id of this song in the database
*/
String _ID = "_id";
/**
* The title of this song
*/
String TITLE = "title";
/**
* The sortable title of this song
*/
String TITLE_SORT = "title_sort";
/**
* The position in the album of this song
*/
String SONG_NUMBER = "song_num";
/**
* The album where this song belongs to
*/
String ALBUM_ID = "album_id";
/**
* The year of this song
*/
String YEAR = "year";
/**
* How often the song was played
*/
String PLAYCOUNT = "playcount";
/**
* How often the song was skipped
*/
String SKIPCOUNT = "skipcount";
/**
* The duration of this song
*/
String DURATION = "duration";
/**
* The path to the music file
*/
String PATH = "path";
/**
* The mtime of this item
*/
String MTIME = "mtime";
}
// Columns of Album entries
public interface AlbumColumns {
/**
* The id of this album in the database
*/
String _ID = SongColumns._ID;
/**
* The title of this album
*/
String ALBUM = "album";
/**
* The sortable title of this album
*/
String ALBUM_SORT = "album_sort";
/**
* The disc number of this album
*/
String DISC_NUMBER = "disc_num";
/**
* The primary contributor / artist reference for this album
*/
String PRIMARY_ARTIST_ID = "primary_artist_id";
/**
* The year of this album
*/
String PRIMARY_ALBUM_YEAR = "primary_album_year";
/**
* The mtime of this item
*/
String MTIME = "mtime";
}
// Columns of Contributors entries
public interface ContributorColumns {
/**
* The id of this contributor
*/
String _ID = SongColumns._ID;
/**
* The name of this contributor
*/
String _CONTRIBUTOR = "_contributor";
/**
* The sortable title of this contributor
*/
String _CONTRIBUTOR_SORT = "_contributor_sort";
/**
* The mtime of this item
*/
String MTIME = "mtime";
/**
* ONLY IN VIEWS - the artist
*/
String ARTIST = "artist";
/**
* ONLY IN VIEWS - the artist_sort key
*/
String ARTIST_SORT = "artist_sort";
/**
* ONLY IN VIEWS - the artist id
*/
String ARTIST_ID = "artist_id";
}
// Songs <-> Contributor mapping
public interface ContributorSongColumns {
/**
* The role of this entry
*/
String ROLE = "role";
/**
* the contirbutor id this maps to
*/
String _CONTRIBUTOR_ID = "_contributor_id";
/**
* the song this maps to
*/
String SONG_ID = "song_id";
}
// Columns of Genres entries
public interface GenreColumns {
/**
* The id of this genre
*/
String _ID = SongColumns._ID;
/**
* The name of this genre
*/
String _GENRE = "_genre";
/**
* The sortable title of this genre
*/
String _GENRE_SORT = "_genre_sort";
}
// Songs <-> Contributor mapping
public interface GenreSongColumns {
/**
* the genre id this maps to
*/
String _GENRE_ID = "_genre_id";
/**
* the song this maps to
*/
String SONG_ID = "song_id";
}
// Playlists
public interface PlaylistColumns {
/**
* The id of this playlist
*/
String _ID = SongColumns._ID;
/**
* The name of this playlist
*/
String NAME = "name";
}
// Song <-> Playlist mapping
public interface PlaylistSongColumns {
/**
* The ID of this entry
*/
String _ID = SongColumns._ID;
/**
* The playlist this entry belongs to
*/
String PLAYLIST_ID = "playlist_id";
/**
* The song this entry references to
*/
String SONG_ID = "song_id";
/**
* The order attribute
*/
String POSITION = "position";
}
}

View File

@ -0,0 +1,309 @@
/*
* Copyright (C) 2016 Adrian Ulrich <adrian@blinkenlights.ch>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package ch.blinkenlights.android.medialibrary;
import android.content.Context;
import android.content.ContentValues;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.Cursor;
import android.util.Log;
import java.util.ArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MediaLibraryBackend extends SQLiteOpenHelper {
/**
* Enables or disables debugging
*/
private static final boolean DEBUG = true;
/**
* The database version we are using
*/
private static final int DATABASE_VERSION = 1;
/**
* on-disk file to store the database
*/
private static final String DATABASE_NAME = "media-library.db";
/**
* Regexp to detect genre queries which we can optimize
*/
private static final Pattern sQueryMatchGenreSearch = Pattern.compile("(^|.+ )"+MediaLibrary.GenreSongColumns._GENRE_ID+"=(\\d+)$");
/**
* Regexp to detect costy artist_id queries which we can optimize
*/
private static final Pattern sQueryMatchArtistSearch = Pattern.compile("(^|.+ )"+MediaLibrary.ContributorColumns.ARTIST_ID+"=(\\d+)$");
/**
* Constructor for the MediaLibraryBackend helper
*
* @param context the context to use
*/
MediaLibraryBackend(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
/**
* Called when database does not exist
*
* @param dbh the writeable database handle
*/
@Override
public void onCreate(SQLiteDatabase dbh) {
MediaSchema.createDatabaseSchema(dbh);
}
/**
* Called when the existing database
* schema is outdated
*
* @param dbh the writeable database handle
* @param oldVersion the current version in use
* @param newVersion the target version
*/
@Override
public void onUpgrade(SQLiteDatabase dbh, int oldVersion, int newVersion) {
}
/**
* Returns the modification time of a song, 0 if the song does not exist
*
* @param id the song id to query
* @return the modification time of this song
*/
long getSongMtime(long id) {
long mtime = 0;
Cursor cursor = query(false, MediaLibrary.TABLE_SONGS, new String[]{ MediaLibrary.SongColumns.MTIME }, MediaLibrary.SongColumns._ID+"="+Long.toString(id), null, null, null, null, "1");
if (cursor.moveToFirst())
mtime = cursor.getLong(0);
cursor.close();
return mtime;
}
/**
* Wrapper for SQLiteDatabse.delete() function
*
* @param table the table to delete data from
* @param whereClause the selection
* @param whereArgs arguments to selection
* @return the number of affected rows
*/
int delete(String table, String whereClause, String[] whereArgs) {
SQLiteDatabase dbh = getWritableDatabase();
return dbh.delete(table, whereClause, whereArgs);
}
/**
* Wrapper for SQLiteDatabase.update() function
*
* @param table the table to update
* @param values the data to set / modify
* @param whereClause the selection
* @param whereArgs arguments to selection
* @return the number of affected rows
*/
int update (String table, ContentValues values, String whereClause, String[] whereArgs) {
SQLiteDatabase dbh = getWritableDatabase();
return dbh.update(table, values, whereClause, whereArgs);
}
/**
* Wrapper for SQLiteDatabase.execSQL() function
*
* @param sql the raw sql string
*/
void execSQL(String sql) {
getWritableDatabase().execSQL(sql);
}
/**
* Wrapper for SQLiteDatabase.insert() function
*
* @param table the table to insert data to
* @param nullColumnHack android hackery (see SQLiteDatabase documentation)
* @param values the values to insert
*/
long insert (String table, String nullColumnHack, ContentValues values) {
long result = -1;
try {
result = getWritableDatabase().insertOrThrow(table, nullColumnHack, values);
} catch (Exception e) {
// avoid logspam as done by insert()
}
return result;
}
/**
* Purges orphaned entries from the media library
*/
void cleanOrphanedEntries() {
SQLiteDatabase dbh = getWritableDatabase();
dbh.execSQL("DELETE FROM "+MediaLibrary.TABLE_ALBUMS+" WHERE "+MediaLibrary.AlbumColumns._ID+" NOT IN (SELECT "+MediaLibrary.SongColumns.ALBUM_ID+" FROM "+MediaLibrary.TABLE_SONGS+");");
dbh.execSQL("DELETE FROM "+MediaLibrary.TABLE_GENRES_SONGS+" WHERE "+MediaLibrary.GenreSongColumns.SONG_ID+" NOT IN (SELECT "+MediaLibrary.SongColumns._ID+" FROM "+MediaLibrary.TABLE_SONGS+");");
dbh.execSQL("DELETE FROM "+MediaLibrary.TABLE_GENRES+" WHERE "+MediaLibrary.GenreColumns._ID+" NOT IN (SELECT "+MediaLibrary.GenreSongColumns._GENRE_ID+" FROM "+MediaLibrary.TABLE_GENRES_SONGS+");");
dbh.execSQL("DELETE FROM "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS+" WHERE "+MediaLibrary.ContributorSongColumns.SONG_ID+" NOT IN (SELECT "+MediaLibrary.SongColumns._ID+" FROM "+MediaLibrary.TABLE_SONGS+");");
dbh.execSQL("DELETE FROM "+MediaLibrary.TABLE_CONTRIBUTORS+" WHERE "+MediaLibrary.ContributorColumns._ID+" NOT IN (SELECT "+MediaLibrary.ContributorSongColumns._CONTRIBUTOR_ID+" FROM "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS+");");
dbh.execSQL("DELETE FROM "+MediaLibrary.TABLE_PLAYLISTS_SONGS+" WHERE "+MediaLibrary.PlaylistSongColumns.SONG_ID+" NOT IN (SELECT "+MediaLibrary.SongColumns._ID+" FROM "+MediaLibrary.TABLE_SONGS+");");
}
/**
* Wrapper for SQLiteDatabase.insert() function working in one transaction
*
* @param table the table to insert data to
* @param nullColumnHack android hackery (see SQLiteDatabase documentation)
* @param valuesList an array list of ContentValues to insert
* @return the number of inserted rows
*/
int bulkInsert (String table, String nullColumnHack, ArrayList<ContentValues> valuesList) {
SQLiteDatabase dbh = getWritableDatabase();
int count = 0;
dbh.beginTransactionNonExclusive();
try {
for(ContentValues values : valuesList) {
try {
long result = dbh.insertOrThrow(table, nullColumnHack, values);
if (result > 0)
count++;
} catch (Exception e) {
// avoid logspam
}
}
dbh.setTransactionSuccessful();
} finally {
dbh.endTransaction();
}
return count;
}
/**
* Wrappr for SQLiteDatabase.query() function
*/
Cursor query (boolean distinct, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) {
if (selection != null) {
if (MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS.equals(table)) {
// artist matches in the song-view are costy: try to give sqlite a hint
Matcher artistMatch = sQueryMatchArtistSearch.matcher(selection);
if (artistMatch.matches()) {
selection = artistMatch.group(1);
final String artistId = artistMatch.group(2);
selection += MediaLibrary.SongColumns._ID+" IN (SELECT "+MediaLibrary.ContributorSongColumns.SONG_ID+" FROM "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS+" WHERE "
+ MediaLibrary.ContributorSongColumns.ROLE+"=0 AND "+MediaLibrary.ContributorSongColumns._CONTRIBUTOR_ID+"="+artistId+")";
}
}
// Genre queries are a special beast: 'optimize' all of them
Matcher genreMatch = sQueryMatchGenreSearch.matcher(selection);
if (genreMatch.matches()) {
selection = genreMatch.group(1); // keep the non-genre search part of the query
final String genreId = genreMatch.group(2); // and extract the searched genre id
final String songsQuery = buildSongIdFromGenreSelect(genreId);
if(table.equals(MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS)) {
selection += MediaLibrary.SongColumns._ID+" IN ("+songsQuery+") ";
}
if (table.equals(MediaLibrary.VIEW_ARTISTS)) {
selection += MediaLibrary.ContributorColumns.ARTIST_ID+" IN ("+ buildSongIdFromGenreSelect(MediaLibrary.ContributorColumns.ARTIST_ID, songsQuery)+") ";
}
if (table.equals(MediaLibrary.VIEW_ALBUMS_ARTISTS)) {
selection += MediaLibrary.AlbumColumns._ID+" IN ("+ buildSongIdFromGenreSelect(MediaLibrary.SongColumns.ALBUM_ID, songsQuery)+") ";
}
}
}
if (DEBUG)
debugQuery(distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit);
Cursor cursor = getReadableDatabase().query(distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit);
if (cursor != null) {
// Hold on! This is not some kind of black magic - it makes '''sense''':
// SQLites count() performance is pretty poor, but most queries will call getCount() during their
// lifetime anyway - unfortunately this might happen in the main thread, causing some lag.
// Androids SQLite class caches the result of getCount() calls, so we are going to run it
// here as we are (hopefully!) in a background thread anyway.
cursor.getCount();
}
return cursor;
}
/**
* Returns a select query to get all songs from a genre
*
* @param genreId the id to query as a string
* @return an SQL string which should return song id's for the queried genre
*/
private String buildSongIdFromGenreSelect(String genreId) {
return "SELECT "+MediaLibrary.GenreSongColumns.SONG_ID+" FROM "+MediaLibrary.TABLE_GENRES_SONGS+" WHERE "
+MediaLibrary.GenreSongColumns._GENRE_ID+"="+genreId+" GROUP BY "+MediaLibrary.GenreSongColumns.SONG_ID;
}
/**
* Returns a select query to get artists or albums from a genre
*
* @param target the target to query
* @param genreSelect the select string generated by buildSongIdFromGenreSelect
* @return an SQL string
*/
private String buildSongIdFromGenreSelect(String target, String genreSelect) {
return "SELECT "+target+" FROM "+MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS+" WHERE "
+MediaLibrary.SongColumns._ID+" IN ("+genreSelect+") GROUP BY "+target;
}
/**
* Debug function to print and benchmark queries
*/
private void debugQuery(boolean distinct, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) {
final String LT = "VanillaMusicSQL";
Log.v(LT, "---- start query ---");
Log.v(LT, "SELECT");
for (String c : columns) {
Log.v(LT, " "+c);
}
Log.v(LT, "FROM "+table+" WHERE "+selection+" ");
if (selectionArgs != null) {
Log.v(LT, " /* with args: ");
for (String a : selectionArgs) {
Log.v(LT, a+", ");
}
Log.v(LT, " */");
}
Log.v(LT, " GROUP BY "+groupBy+" HAVING "+having+" ORDER BY "+orderBy+" LIMIT "+limit);
Log.v(LT, "DBH = "+getReadableDatabase());
Cursor dryRun = getReadableDatabase().query(distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit);
long results = 0;
long startAt = System.currentTimeMillis();
if (dryRun != null) {
while(dryRun.moveToNext()) {
results++;
}
dryRun.close();
}
long tookMs = System.currentTimeMillis() - startAt;
Log.v(LT, "--- finished in "+tookMs+" ms with count="+results);
}
}

View File

@ -0,0 +1,217 @@
/*
* Copyright (C) 2016 Adrian Ulrich <adrian@blinkenlights.ch>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package ch.blinkenlights.android.medialibrary;
import ch.blinkenlights.bastp.Bastp;
import android.media.MediaMetadataRetriever;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class MediaMetadataExtractor extends HashMap<String, ArrayList<String>> {
// Well known tags
public final static String ALBUM = "ALBUM";
public final static String ALBUMARTIST = "ALBUM_ARTIST";
public final static String ARTIST = "ARTIST";
public final static String BITRATE = "BITRATE";
public final static String COMPOSER = "COMPOSER";
public final static String DISC_COUNT = "DISC_COUNT";
public final static String DISC_NUMBER = "DISC_NUMBER";
public final static String DURATION = "DURATION";
public final static String GENRE = "GENRE";
public final static String MIME_TYPE = "MIME";
public final static String TRACK_COUNT = "TRACK_COUNT";
public final static String TRACK_NUMBER = "TRACK_NUM";
public final static String TITLE = "TITLE";
public final static String YEAR = "YEAR";
/**
* Regexp used to match a year in a date field
*/
private static final Pattern sFilterYear = Pattern.compile("(\\d{4})");
/**
* Regexp matching the first lefthand integer
*/
private static final Pattern sFilterLeftInt = Pattern.compile("^0*(\\d+)");
/**
* Regexp matching anything
*/
private static final Pattern sFilterAny = Pattern.compile("^(.*)$");
/**
* Constructor for MediaMetadataExtractor
*
* @param path the path to scan
*/
public MediaMetadataExtractor(String path) {
extractMetadata(path);
}
/**
* Returns the first element matching this key, null on if not found
*
* @param key the key to look up
* @return the value of the first entry, null if the key was not found
*/
public String getFirst(String key) {
String result = null;
if (containsKey(key))
result = get(key).get(0);
return result;
}
/**
* Returns true if this file contains any (interesting) tags
* @return true if file is considered to be tagged
*/
public boolean isTagged() {
return (containsKey(TITLE) || containsKey(ALBUM) || containsKey(ARTIST));
}
/**
* Attempts to populate this instance with tags found in given path
*
* @param path the path to parse
*/
private void extractMetadata(String path) {
if (!isEmpty())
throw new IllegalStateException("Expected to be called on a clean HashMap");
HashMap bastpTags = (new Bastp()).getTags(path);
MediaMetadataRetriever mediaTags = new MediaMetadataRetriever();
try {
mediaTags.setDataSource(path);
} catch (Exception e) { /* we will later just check the contents of mediaTags */ }
// Check if this is an useable audio file
if (mediaTags.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == null ||
mediaTags.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO) != null ||
mediaTags.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) == null) {
mediaTags.release();
return;
}
// Bastp can not read the duration and bitrates, so we always get it from the system
ArrayList<String> duration = new ArrayList<>(1);
duration.add(mediaTags.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION));
this.put(DURATION, duration);
ArrayList<String> bitrate = new ArrayList<>(1);
bitrate.add(mediaTags.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE));
this.put(BITRATE, bitrate);
ArrayList<String> mime = new ArrayList<>(1);
mime.add(mediaTags.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE));
this.put(MIME_TYPE, mime);
// ...but we are using bastp for FLAC, OGG and OPUS as it handles them well
// Everything else goes to the framework (such as pcm, m4a and mp3)
String bastpType = (bastpTags.containsKey("type") ? (String)bastpTags.get("type") : "");
switch (bastpType) {
case "FLAC":
case "OGG":
case "OPUS":
populateSelf(bastpTags);
break;
default:
populateSelf(mediaTags);
}
mediaTags.release();
}
/**
* Populates `this' with tags read from bastp
*
* @param bastp A hashmap as returned by bastp
*/
private void populateSelf(HashMap bastp) {
// mapping between vorbiscomment -> constant
String[] map = new String[]{ "TITLE", TITLE, "ARTIST", ARTIST, "ALBUM", ALBUM, "ALBUMARTIST", ALBUMARTIST, "COMPOSER", COMPOSER, "GENRE", GENRE,
"TRACKNUMBER", TRACK_NUMBER, "TRACKTOTAL", TRACK_COUNT, "DISCNUMBER", DISC_NUMBER, "DISCTOTAL", DISC_COUNT,
"YEAR", YEAR };
// switch to integer filter if i >= x
int filterByIntAt = 12;
// the filter we are normally using
Pattern filter = sFilterAny;
for (int i=0; i<map.length; i+=2) {
if (i >= filterByIntAt)
filter = sFilterLeftInt;
if (bastp.containsKey(map[i])) {
addFiltered(filter, map[i+1], (ArrayList<String>)bastp.get(map[i]));
}
}
// Try to guess YEAR from date field if only DATE was specified
// We expect it to match \d{4}
if (!containsKey(YEAR) && bastp.containsKey("DATE")) {
addFiltered(sFilterYear, YEAR, (ArrayList<String>)bastp.get("DATE"));
}
}
/**
* Populates `this' with tags read from the MediaMetadataRetriever
*
* @param tags a MediaMetadataRetriever object
*/
private void populateSelf(MediaMetadataRetriever tags) {
int[] mediaMap = new int[] { MediaMetadataRetriever.METADATA_KEY_TITLE, MediaMetadataRetriever.METADATA_KEY_ARTIST, MediaMetadataRetriever.METADATA_KEY_ALBUM,
MediaMetadataRetriever.METADATA_KEY_ALBUMARTIST, MediaMetadataRetriever.METADATA_KEY_COMPOSER, MediaMetadataRetriever.METADATA_KEY_GENRE,
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER, MediaMetadataRetriever.METADATA_KEY_YEAR };
String[] selfMap = new String[]{ TITLE, ARTIST, ALBUM, ALBUMARTIST, COMPOSER, GENRE, TRACK_NUMBER, YEAR };
int filterByIntAt = 6;
Pattern filter = sFilterAny;
for (int i=0; i<selfMap.length; i++) {
String data = tags.extractMetadata(mediaMap[i]);
if (i >= filterByIntAt)
filter = sFilterLeftInt;
if (data != null) {
ArrayList<String> md = new ArrayList<String>(1);
md.add(data);
addFiltered(filter, selfMap[i], md);
}
}
}
/**
* Matches all elements of `data' with `filter' and adds the result as `key'
*
* @param filter the pattern to use, result is expected to be in capture group 1
* @param key the key to use for the data to put
* @param data the array list to inspect
*/
private void addFiltered(Pattern filter, String key, ArrayList<String> data) {
ArrayList<String> list = new ArrayList<>();
for (String s : data) {
Matcher matcher = filter.matcher(s);
if (matcher.matches()) {
list.add(matcher.group(1));
}
}
if (list.size() > 0)
put(key, list);
}
}

View File

@ -0,0 +1,511 @@
/*
* Copyright (C) 2016 Adrian Ulrich <adrian@blinkenlights.ch>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package ch.blinkenlights.android.medialibrary;
import android.content.Context;
import android.content.ContentValues;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.database.ContentObserver;
import android.util.Log;
import android.provider.MediaStore;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Message;
import android.os.Process;
import java.io.File;
import java.util.ArrayList;
import java.util.regex.Pattern;
public class MediaScanner implements Handler.Callback {
/**
* Our scan plan
*/
private MediaScanPlan mScanPlan;
/**
* Our message handler
*/
private Handler mHandler;
/**
* The context to use for native library queries
*/
private Context mContext;
/**
* Instance of a media backend
*/
private MediaLibraryBackend mBackend;
MediaScanner(Context context, MediaLibraryBackend backend) {
mContext = context;
mBackend = backend;
mScanPlan = new MediaScanPlan();
HandlerThread handlerThread = new HandlerThread("MediaScannerThread", Process.THREAD_PRIORITY_LOWEST);
handlerThread.start();
mHandler = new Handler(handlerThread.getLooper(), this);
// the content observer to use
ContentObserver mObserver = new ContentObserver(null) {
@Override
public void onChange(boolean self) {
startQuickScan();
}
};
context.getContentResolver().registerContentObserver(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, false, mObserver);
}
/**
* Performs a 'fast' scan by checking the native and our own
* library for new and changed files
*/
public void startNormalScan() {
mScanPlan.addNextStep(RPC_NATIVE_VRFY, null)
.addNextStep(RPC_LIBRARY_VRFY, null);
}
/**
* Performs a 'slow' scan by inspecting all files on the device
*/
public void startFullScan() {
for (File dir : MediaLibrary.discoverMediaPaths()) {
mScanPlan.addNextStep(RPC_READ_DIR, dir);
}
mScanPlan.addNextStep(RPC_LIBRARY_VRFY, null);
mScanPlan.addNextStep(RPC_NATIVE_VRFY, null);
mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, RPC_NOOP, 0));
}
/**
* Called by the content observer if a change in the media library
* has been detected
*/
public void startQuickScan() {
if (!mHandler.hasMessages(MSG_SCAN_RPC)) {
mScanPlan.addNextStep(RPC_NATIVE_VRFY, null)
.addOptionalStep(RPC_LIBRARY_VRFY, null); // only runs if previous scan found no change
mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_SCAN_RPC, RPC_NOOP, 0), 1400);
}
}
/**
* Returns some scan statistics
*
* @return a stats object
*/
MediaScanPlan.Statistics getScanStatistics() {
return mScanPlan.getStatistics();
}
private static final int MSG_SCAN_RPC = 0;
private static final int MSG_NOTIFY_CHANGE = 1;
private static final int RPC_NOOP = 100;
private static final int RPC_READ_DIR = 101;
private static final int RPC_INSPECT_FILE = 102;
private static final int RPC_LIBRARY_VRFY = 103;
private static final int RPC_NATIVE_VRFY = 104;
@Override
public boolean handleMessage(Message message) {
int rpc = (message.what == MSG_SCAN_RPC ? message.arg1 : message.what);
switch (rpc) {
case MSG_NOTIFY_CHANGE: {
MediaLibrary.notifyObserver();
break;
}
case RPC_NOOP: {
// just used to trigger the initial scan
break;
}
case RPC_INSPECT_FILE: {
final File file = (File)message.obj;
boolean changed = rpcInspectFile(file);
mScanPlan.registerProgress(file.toString(), changed);
if (changed && !mHandler.hasMessages(MSG_NOTIFY_CHANGE)) {
mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_NOTIFY_CHANGE), 500);
}
break;
}
case RPC_READ_DIR: {
rpcReadDirectory((File)message.obj);
break;
}
case RPC_LIBRARY_VRFY: {
rpcLibraryVerify((Cursor)message.obj);
break;
}
case RPC_NATIVE_VRFY: {
rpcNativeVerify((Cursor)message.obj, message.arg2);
break;
}
default: {
throw new IllegalArgumentException();
}
}
if (message.what == MSG_SCAN_RPC && !mHandler.hasMessages(MSG_SCAN_RPC)) {
MediaScanPlan.Step step = mScanPlan.getNextStep();
if (step == null) {
Log.v("VanillaMusic", "--- all scanners finished ---");
} else {
Log.v("VanillaMusic", "--- starting scan of type "+step.msg);
mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, step.msg, 0, step.arg));
}
}
return true;
}
/**
* Scans the android library, inspecting every found file
*
* @param cursor the cursor we are using
* @param mtime the mtime to carry over, ignored if cursor is null
*/
private void rpcNativeVerify(Cursor cursor, int mtime) {
if (cursor == null) {
mtime = getSetScanMark(-1); // starting a new scan -> read stored mtime from preferences
String selection = MediaStore.Audio.Media.IS_MUSIC + "!= 0 AND "+ MediaStore.MediaColumns.DATE_MODIFIED +" > " + mtime;
String sort = MediaStore.MediaColumns.DATE_MODIFIED;
String[] projection = { MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.DATE_MODIFIED };
try {
cursor = mContext.getContentResolver().query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection, null, sort);
} catch(SecurityException e) {
Log.e("VanillaMusic", "rpcNativeVerify failed: "+e);
}
}
if (cursor == null)
return; // still null.. fixme: handle me better
if (cursor.moveToNext()) {
String path = cursor.getString(0);
mtime = cursor.getInt(1);
if (path != null) { // this seems to be a thing...
File entry = new File(path);
mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, RPC_INSPECT_FILE, 0, entry));
mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, RPC_NATIVE_VRFY, mtime, cursor));
}
} else {
cursor.close();
getSetScanMark(mtime);
Log.v("VanillaMusic", "NativeLibraryScanner finished, mtime mark is now at "+mtime);
}
}
/**
* Scans every file in our own library and checks for changes
*
* @param cursor the cursor we are using
*/
private void rpcLibraryVerify(Cursor cursor) {
if (cursor == null)
cursor = mBackend.query(false, MediaLibrary.TABLE_SONGS, new String[]{MediaLibrary.SongColumns.PATH}, null, null, null, null, null, null);
if (cursor.moveToNext()) {
File entry = new File(cursor.getString(0));
mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, RPC_INSPECT_FILE, 0, entry));
mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, RPC_LIBRARY_VRFY, 0, cursor));
} else {
cursor.close();
}
}
/**
* Loops trough given directory and adds all found
* files to the scan queue
*
* @param dir the directory to scan
*/
private void rpcReadDirectory(File dir) {
if (!dir.isDirectory())
return;
if (new File(dir, ".nomedia").exists())
return;
File[] dirents = dir.listFiles();
if (dirents == null)
return;
for (File file : dirents) {
int rpc = (file.isFile() ? RPC_INSPECT_FILE : RPC_READ_DIR);
mHandler.sendMessage(mHandler.obtainMessage(MSG_SCAN_RPC, rpc, 0, file));
}
}
/**
* Inspects a single file and adds it to the database or removes it. maybe.
*
* @param file the file to add
* @return true if we modified the database
*/
private boolean rpcInspectFile(File file) {
String path = file.getAbsolutePath();
long songId = MediaLibrary.hash63(path);
if (isBlacklisted(file))
return false;
long dbEntryMtime = mBackend.getSongMtime(songId) * 1000; // this is in unixtime -> convert to 'ms'
long fileMtime = file.lastModified();
boolean needsInsert = true;
boolean needsCleanup = false;
if (fileMtime > 0 && dbEntryMtime >= fileMtime) {
return false; // on-disk mtime is older than db mtime and it still exists -> nothing to do
}
if (dbEntryMtime != 0) {
// DB entry exists but is outdated - drop current entry and maybe re-insert it
// fixme: drops play counts :-(
mBackend.delete(MediaLibrary.TABLE_SONGS, MediaLibrary.SongColumns._ID+"="+songId, null);
needsCleanup = true;
}
MediaMetadataExtractor tags = new MediaMetadataExtractor(path);
if (!tags.isTagged()) {
needsInsert = false; // does not have any useable metadata: wont insert even if it is a playable file
}
if (needsInsert) {
// Get tags which always must be set
String title = tags.getFirst(MediaMetadataExtractor.TITLE);
if (title == null)
title = "Untitled";
String album = tags.getFirst(MediaMetadataExtractor.ALBUM);
if (album == null)
album = "No Album";
String artist = tags.getFirst(MediaMetadataExtractor.ARTIST);
if (artist == null)
artist = "No Artist";
long albumId = MediaLibrary.hash63(album);
long artistId = MediaLibrary.hash63(artist);
ContentValues v = new ContentValues();
v.put(MediaLibrary.SongColumns._ID, songId);
v.put(MediaLibrary.SongColumns.TITLE, title);
v.put(MediaLibrary.SongColumns.TITLE_SORT, MediaLibrary.keyFor(title));
v.put(MediaLibrary.SongColumns.ALBUM_ID, albumId);
v.put(MediaLibrary.SongColumns.DURATION, tags.getFirst(MediaMetadataExtractor.DURATION));
v.put(MediaLibrary.SongColumns.SONG_NUMBER, tags.getFirst(MediaMetadataExtractor.TRACK_NUMBER));
v.put(MediaLibrary.SongColumns.YEAR, tags.getFirst(MediaMetadataExtractor.YEAR));
v.put(MediaLibrary.SongColumns.PATH, path);
mBackend.insert(MediaLibrary.TABLE_SONGS, null, v);
v.clear();
v.put(MediaLibrary.AlbumColumns._ID, albumId);
v.put(MediaLibrary.AlbumColumns.ALBUM, album);
v.put(MediaLibrary.AlbumColumns.ALBUM_SORT, MediaLibrary.keyFor(album));
v.put(MediaLibrary.AlbumColumns.PRIMARY_ARTIST_ID, artistId);
v.put(MediaLibrary.AlbumColumns.PRIMARY_ALBUM_YEAR,tags.getFirst(MediaMetadataExtractor.YEAR));
v.put(MediaLibrary.AlbumColumns.DISC_NUMBER, tags.getFirst(MediaMetadataExtractor.DISC_NUMBER));
mBackend.insert(MediaLibrary.TABLE_ALBUMS, null, v);
v.clear();
v.put(MediaLibrary.ContributorColumns._ID, artistId);
v.put(MediaLibrary.ContributorColumns._CONTRIBUTOR, artist);
v.put(MediaLibrary.ContributorColumns._CONTRIBUTOR_SORT, MediaLibrary.keyFor(artist));
mBackend.insert(MediaLibrary.TABLE_CONTRIBUTORS, null, v);
v.clear();
v.put(MediaLibrary.ContributorSongColumns._CONTRIBUTOR_ID, artistId);
v.put(MediaLibrary.ContributorSongColumns.SONG_ID, songId);
v.put(MediaLibrary.ContributorSongColumns.ROLE, MediaLibrary.ROLE_ARTIST);
mBackend.insert(MediaLibrary.TABLE_CONTRIBUTORS_SONGS, null, v);
// Composers are optional: only add if we found it
String composer = tags.getFirst(MediaMetadataExtractor.COMPOSER);
if (composer != null) {
long composerId = MediaLibrary.hash63(composer);
v.clear();
v.put(MediaLibrary.ContributorColumns._ID, composerId);
v.put(MediaLibrary.ContributorColumns._CONTRIBUTOR, composer);
v.put(MediaLibrary.ContributorColumns._CONTRIBUTOR_SORT, MediaLibrary.keyFor(composer));
mBackend.insert(MediaLibrary.TABLE_CONTRIBUTORS, null, v);
v.clear();
v.put(MediaLibrary.ContributorSongColumns._CONTRIBUTOR_ID, composerId);
v.put(MediaLibrary.ContributorSongColumns.SONG_ID, songId);
v.put(MediaLibrary.ContributorSongColumns.ROLE, MediaLibrary.ROLE_COMPOSER);
mBackend.insert(MediaLibrary.TABLE_CONTRIBUTORS_SONGS, null, v);
}
// A song might be in multiple genres
if (tags.containsKey(MediaMetadataExtractor.GENRE)) {
ArrayList<String> genres = tags.get(MediaMetadataExtractor.GENRE);
for (String genre : genres) {
long genreId = MediaLibrary.hash63(genre);
v.clear();
v.put(MediaLibrary.GenreColumns._ID, genreId);
v.put(MediaLibrary.GenreColumns._GENRE, genre);
v.put(MediaLibrary.GenreColumns._GENRE_SORT, MediaLibrary.keyFor(genre));
mBackend.insert(MediaLibrary.TABLE_GENRES, null, v);
v.clear();
v.put(MediaLibrary.GenreSongColumns._GENRE_ID, genreId);
v.put(MediaLibrary.GenreSongColumns.SONG_ID, songId);
mBackend.insert(MediaLibrary.TABLE_GENRES_SONGS, null, v);
}
}
} // end if (needsInsert)
if (needsCleanup)
mBackend.cleanOrphanedEntries();
Log.v("VanillaMusic", "MediaScanner: inserted "+path);
return (needsInsert || needsCleanup);
}
private static final Pattern sIgnoredNames = Pattern.compile("^([^\\.]+|.+\\.(jpe?g|gif|png|bmp|webm|txt|pdf|avi|mp4|mkv|zip|tgz|xml))$", Pattern.CASE_INSENSITIVE);
/**
* Returns true if the file should not be scanned
*
* @param file the file to inspect
* @return boolean
*/
private boolean isBlacklisted(File file) {
return sIgnoredNames.matcher(file.getName()).matches();
}
/**
* Clunky shortcut to preferences editor
*
* @param newVal the new value to store, ignored if < 0
* @return the value previously set, or 0 as a default
*/
private int getSetScanMark(int newVal) {
final String prefKey = "native_last_mtime";
SharedPreferences sharedPref = mContext.getSharedPreferences("scanner_preferences", Context.MODE_PRIVATE);
int oldVal = sharedPref.getInt(prefKey, 0);
if (newVal >= 0) {
SharedPreferences.Editor editor = sharedPref.edit();
editor.putInt(prefKey, newVal);
editor.apply();
}
return oldVal;
}
// MediaScanPlan describes how we are going to perform the media scan
class MediaScanPlan {
class Step {
int msg;
Object arg;
boolean optional;
Step (int msg, Object arg, boolean optional) {
this.msg = msg;
this.arg = arg;
this.optional = optional;
}
}
class Statistics {
String lastFile;
int seen = 0;
int changed = 0;
void reset() {
this.seen = 0;
this.changed = 0;
this.lastFile = null;
}
}
/**
* All steps in this plan
*/
private ArrayList<Step> mSteps;
/**
* Statistics of the currently running step
*/
private Statistics mStats;
MediaScanPlan() {
mSteps = new ArrayList<>();
mStats = new Statistics();
}
Statistics getStatistics() {
return mStats;
}
/**
* Called by the scanner to signal that a file was handled
*
* @param path the file we scanned
* @param changed true if this triggered a database update
*/
void registerProgress(String path, boolean changed) {
mStats.lastFile = path;
mStats.seen++;
if (changed) {
mStats.changed++;
}
}
/**
* Adds the next step in our plan
*
* @param msg the message to add
* @param arg the argument to msg
*/
MediaScanPlan addNextStep(int msg, Object arg) {
mSteps.add(new Step(msg, arg, false));
return this;
}
/**
* Adds an optional step to our plan. This will NOT
* run if the previous step caused database changes
*
* @param msg the message to add
* @param arg the argument to msg
*/
MediaScanPlan addOptionalStep(int msg, Object arg) {
mSteps.add(new Step(msg, arg, true));
return this;
}
/**
* Returns the next step of our scan plan
*
* @return a new step object, null if we hit the end
*/
Step getNextStep() {
Step next = (mSteps.size() != 0 ? mSteps.remove(0) : null);
if (next != null) {
if (next.optional && mStats.changed != 0) {
next = null;
mSteps.clear();
}
}
mStats.reset();
return next;
}
}
}

View File

@ -0,0 +1,205 @@
/*
* Copyright (C) 2016 Adrian Ulrich <adrian@blinkenlights.ch>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package ch.blinkenlights.android.medialibrary;
import android.database.sqlite.SQLiteDatabase;
public class MediaSchema {
/**
* SQL Schema of `songs' table
*/
private static final String DATABASE_CREATE_SONGS = "CREATE TABLE "+ MediaLibrary.TABLE_SONGS + " ("
+ MediaLibrary.SongColumns._ID +" INTEGER PRIMARY KEY, "
+ MediaLibrary.SongColumns.TITLE +" TEXT NOT NULL, "
+ MediaLibrary.SongColumns.TITLE_SORT +" VARCHAR(64) NOT NULL, "
+ MediaLibrary.SongColumns.SONG_NUMBER +" INTEGER, "
+ MediaLibrary.SongColumns.YEAR +" INTEGER, "
+ MediaLibrary.SongColumns.ALBUM_ID +" INTEGER NOT NULL, "
+ MediaLibrary.SongColumns.PLAYCOUNT +" INTEGER NOT NULL DEFAULT 0, "
+ MediaLibrary.SongColumns.SKIPCOUNT +" INTEGER NOT NULL DEFAULT 0, "
+ MediaLibrary.SongColumns.MTIME +" TIMESTAMP DEFAULT (strftime('%s', CURRENT_TIMESTAMP)), "
+ MediaLibrary.SongColumns.DURATION +" INTEGER NOT NULL, "
+ MediaLibrary.SongColumns.PATH +" VARCHAR(4096) NOT NULL "
+ ");";
/**
* SQL Schema of `albums' table
*/
private static final String DATABASE_CREATE_ALBUMS = "CREATE TABLE "+ MediaLibrary.TABLE_ALBUMS + " ("
+ MediaLibrary.AlbumColumns._ID +" INTEGER PRIMARY KEY, "
+ MediaLibrary.AlbumColumns.ALBUM +" TEXT NOT NULL, "
+ MediaLibrary.AlbumColumns.ALBUM_SORT +" VARCHAR(64) NOT NULL, "
+ MediaLibrary.AlbumColumns.DISC_NUMBER +" INTEGER, "
+ MediaLibrary.AlbumColumns.PRIMARY_ALBUM_YEAR+" INTEGER, "
+ MediaLibrary.AlbumColumns.PRIMARY_ARTIST_ID +" INTEGER NOT NULL DEFAULT 0, "
+ MediaLibrary.AlbumColumns.MTIME +" TIMESTAMP DEFAULT CURRENT_TIMESTAMP "
+ ");";
/**
* SQL Schema of `contributors' table
*/
private static final String DATABASE_CREATE_CONTRIBUTORS = "CREATE TABLE "+ MediaLibrary.TABLE_CONTRIBUTORS + " ("
+ MediaLibrary.ContributorColumns._ID +" INTEGER PRIMARY KEY, "
+ MediaLibrary.ContributorColumns._CONTRIBUTOR +" TEXT NOT NULL, "
+ MediaLibrary.ContributorColumns._CONTRIBUTOR_SORT +" TEXT NOT NULL, "
+ MediaLibrary.ContributorColumns.MTIME +" TIMESTAMP DEFAULT CURRENT_TIMESTAMP "
+ ");";
/**
* SQL Schema of 'contributors<->songs' table
*/
private static final String DATABASE_CREATE_CONTRIBUTORS_SONGS = "CREATE TABLE "+ MediaLibrary.TABLE_CONTRIBUTORS_SONGS+ " ("
+ MediaLibrary.ContributorSongColumns.ROLE +" INTEGER, "
+ MediaLibrary.ContributorSongColumns._CONTRIBUTOR_ID +" INTEGER, "
+ MediaLibrary.ContributorSongColumns.SONG_ID +" INTEGER, "
+ "PRIMARY KEY("+MediaLibrary.ContributorSongColumns.ROLE+","
+MediaLibrary.ContributorSongColumns._CONTRIBUTOR_ID+","
+MediaLibrary.ContributorSongColumns.SONG_ID+") "
+ ");";
/**
* song, role index on contributors_songs table
*/
private static final String INDEX_IDX_CONTRIBUTORS_SONGS = "CREATE INDEX idx_contributors_songs ON "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS
+" ("+MediaLibrary.ContributorSongColumns.SONG_ID+", "+MediaLibrary.ContributorSongColumns.ROLE+")"
+";";
/**
* SQL Schema of `genres' table
*/
private static final String DATABASE_CREATE_GENRES = "CREATE TABLE "+ MediaLibrary.TABLE_GENRES + " ("
+ MediaLibrary.GenreColumns._ID +" INTEGER PRIMARY KEY, "
+ MediaLibrary.GenreColumns._GENRE +" TEXT NOT NULL, "
+ MediaLibrary.GenreColumns._GENRE_SORT +" TEXT NOT NULL "
+ ");";
/**
* SQL Schema of 'genres<->songs' table
*/
private static final String DATABASE_CREATE_GENRES_SONGS = "CREATE TABLE "+ MediaLibrary.TABLE_GENRES_SONGS + " ("
+ MediaLibrary.GenreSongColumns._GENRE_ID +" INTEGER, "
+ MediaLibrary.GenreSongColumns.SONG_ID +" INTEGER, "
+ "PRIMARY KEY("+MediaLibrary.GenreSongColumns._GENRE_ID+","
+MediaLibrary.GenreSongColumns.SONG_ID+") "
+ ");";
/**
* SQL Schema for the playlists table
*/
private static final String DATABASE_CREATE_PLAYLISTS = "CREATE TABLE "+ MediaLibrary.TABLE_PLAYLISTS +" ("
+ MediaLibrary.PlaylistColumns._ID +" INTEGER PRIMARY KEY, "
+ MediaLibrary.PlaylistColumns.NAME +" TEXT NOT NULL "
+ ");";
/**
* SQL Schema of 'songs<->playlists' table
*/
private static final String DATABASE_CREATE_PLAYLISTS_SONGS = "CREATE TABLE "+ MediaLibrary.TABLE_PLAYLISTS_SONGS + " ("
+ MediaLibrary.PlaylistSongColumns._ID +" INTEGER PRIMARY KEY, "
+ MediaLibrary.PlaylistSongColumns.PLAYLIST_ID +" INTEGER NOT NULL, "
+ MediaLibrary.PlaylistSongColumns.SONG_ID +" INTEGER NOT NULL, "
+ MediaLibrary.PlaylistSongColumns.POSITION +" INTEGER NOT NULL "
+ ");";
/**
* Index to select a playlist quickly
*/
private static final String INDEX_IDX_PLAYLIST_ID = "CREATE INDEX idx_playlist_id ON "+MediaLibrary.TABLE_PLAYLISTS_SONGS
+" ("+MediaLibrary.PlaylistSongColumns.PLAYLIST_ID+")"
+";";
/**
* Index to select a song from a playlist quickly
*/
private static final String INDEX_IDX_PLAYLIST_ID_SONG = "CREATE INDEX idx_playlist_id_song ON "+MediaLibrary.TABLE_PLAYLISTS_SONGS
+" ("+MediaLibrary.PlaylistSongColumns.PLAYLIST_ID+", "+MediaLibrary.PlaylistSongColumns.SONG_ID+")"
+";";
/**
* Additional columns to select for artist info
*/
private static final String VIEW_ARTIST_SELECT = "_artist."+MediaLibrary.ContributorColumns._CONTRIBUTOR+" AS "+MediaLibrary.ContributorColumns.ARTIST
+",_artist."+MediaLibrary.ContributorColumns._CONTRIBUTOR_SORT+" AS "+MediaLibrary.ContributorColumns.ARTIST_SORT
+",_artist."+MediaLibrary.ContributorColumns._ID+" AS "+MediaLibrary.ContributorColumns.ARTIST_ID;
/**
* View which includes song, album and artist information
*/
private static final String VIEW_CREATE_SONGS_ALBUMS_ARTISTS = "CREATE VIEW "+ MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS+ " AS "
+ "SELECT *, " + VIEW_ARTIST_SELECT + " FROM " + MediaLibrary.TABLE_SONGS
+" LEFT JOIN "+MediaLibrary.TABLE_ALBUMS+" ON "+MediaLibrary.TABLE_SONGS+"."+MediaLibrary.SongColumns.ALBUM_ID+" = "+MediaLibrary.TABLE_ALBUMS+"."+MediaLibrary.AlbumColumns._ID
+" LEFT JOIN "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS+" ON "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS+"."+MediaLibrary.ContributorSongColumns.ROLE+"="+MediaLibrary.ROLE_ARTIST
+" AND "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS+"."+MediaLibrary.ContributorSongColumns.SONG_ID+" = "+MediaLibrary.TABLE_SONGS+"."+MediaLibrary.SongColumns._ID
+" LEFT JOIN "+MediaLibrary.TABLE_CONTRIBUTORS+" AS _artist ON _artist."+MediaLibrary.ContributorColumns._ID+" = "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS+"."+MediaLibrary.ContributorSongColumns._CONTRIBUTOR_ID
+" ;";
/**
* View which includes album and artist information
*/
private static final String VIEW_CREATE_ALBUMS_ARTISTS = "CREATE VIEW "+ MediaLibrary.VIEW_ALBUMS_ARTISTS+ " AS "
+ "SELECT *, " + VIEW_ARTIST_SELECT + " FROM " + MediaLibrary.TABLE_ALBUMS
+" LEFT JOIN "+MediaLibrary.TABLE_CONTRIBUTORS+" AS _artist"
+" ON _artist."+MediaLibrary.ContributorColumns._ID+" = "+MediaLibrary.TABLE_ALBUMS+"."+MediaLibrary.AlbumColumns.PRIMARY_ARTIST_ID
+" ;";
/**
* View which includes artist information
*/
private static final String VIEW_CREATE_ARTISTS = "CREATE VIEW "+ MediaLibrary.VIEW_ARTISTS+ " AS "
+ "SELECT *, " + VIEW_ARTIST_SELECT + " FROM "+MediaLibrary.TABLE_CONTRIBUTORS+" AS _artist WHERE "+MediaLibrary.ContributorColumns._ID+" IN "
+" (SELECT "+MediaLibrary.ContributorSongColumns._CONTRIBUTOR_ID+" FROM "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS
+" WHERE "+MediaLibrary.ContributorSongColumns.ROLE+"="+MediaLibrary.ROLE_ARTIST+" GROUP BY "+MediaLibrary.ContributorSongColumns._CONTRIBUTOR_ID+")"
+" ;";
/**
* View like VIEW_CREATE_ARTISTS but includes playlist information
*/
private static final String VIEW_CREATE_PLAYLIST_SONGS = "CREATE VIEW "+ MediaLibrary.VIEW_PLAYLIST_SONGS+" AS "
+ "SELECT *, " + VIEW_ARTIST_SELECT + " FROM " + MediaLibrary.TABLE_PLAYLISTS_SONGS
+" LEFT JOIN "+MediaLibrary.TABLE_SONGS+" ON "+MediaLibrary.TABLE_PLAYLISTS_SONGS+"."+MediaLibrary.PlaylistSongColumns.SONG_ID+"="+MediaLibrary.TABLE_SONGS+"."+MediaLibrary.SongColumns._ID
// -> same sql as VIEW_CREATE_SONGS_ALBUMS_ARTISTS follows:
+" LEFT JOIN "+MediaLibrary.TABLE_ALBUMS+" ON "+MediaLibrary.TABLE_SONGS+"."+MediaLibrary.SongColumns.ALBUM_ID+" = "+MediaLibrary.TABLE_ALBUMS+"."+MediaLibrary.AlbumColumns._ID
+" LEFT JOIN "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS+" ON "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS+"."+MediaLibrary.ContributorSongColumns.ROLE+"="+MediaLibrary.ROLE_ARTIST
+" AND "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS+"."+MediaLibrary.ContributorSongColumns.SONG_ID+" = "+MediaLibrary.TABLE_SONGS+"."+MediaLibrary.SongColumns._ID
+" LEFT JOIN "+MediaLibrary.TABLE_CONTRIBUTORS+" AS _artist ON _artist."+MediaLibrary.ContributorColumns._ID+" = "+MediaLibrary.TABLE_CONTRIBUTORS_SONGS+"."+MediaLibrary.ContributorSongColumns._CONTRIBUTOR_ID
+" ;";
/**
* Creates a new database schema on dbh
*
* @param dbh the writeable dbh to act on
*/
public static void createDatabaseSchema(SQLiteDatabase dbh) {
dbh.execSQL(DATABASE_CREATE_SONGS);
dbh.execSQL(DATABASE_CREATE_ALBUMS);
dbh.execSQL(DATABASE_CREATE_CONTRIBUTORS);
dbh.execSQL(DATABASE_CREATE_CONTRIBUTORS_SONGS);
dbh.execSQL(INDEX_IDX_CONTRIBUTORS_SONGS);
dbh.execSQL(DATABASE_CREATE_GENRES);
dbh.execSQL(DATABASE_CREATE_GENRES_SONGS);
dbh.execSQL(DATABASE_CREATE_PLAYLISTS);
dbh.execSQL(DATABASE_CREATE_PLAYLISTS_SONGS);
dbh.execSQL(INDEX_IDX_PLAYLIST_ID);
dbh.execSQL(INDEX_IDX_PLAYLIST_ID_SONG);
dbh.execSQL(VIEW_CREATE_SONGS_ALBUMS_ARTISTS);
dbh.execSQL(VIEW_CREATE_ALBUMS_ARTISTS);
dbh.execSQL(VIEW_CREATE_ARTISTS);
dbh.execSQL(VIEW_CREATE_PLAYLIST_SONGS);
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2014-2015 Adrian Ulrich <adrian@blinkenlights.ch>
* Copyright (C) 2014-2016 Adrian Ulrich <adrian@blinkenlights.ch>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -22,6 +22,7 @@ import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.provider.MediaStore;
import android.view.View;
import android.view.Window;
import android.widget.TextView;
@ -124,10 +125,24 @@ public class AudioPickerActivity extends PlaybackActivity {
Song song = new Song(-1);
Cursor cursor = null;
if (uri.getScheme().equals("content"))
cursor = MediaUtils.queryResolver(getContentResolver(), uri, Song.FILLED_PROJECTION, null, null, null);
if (uri.getScheme().equals("file"))
if (uri.getScheme().equals("content")) {
// check if the native content resolver has a path for this
Cursor pathCursor = getContentResolver().query(uri, new String[]{ MediaStore.Audio.Media.DATA }, null, null, null);
if (pathCursor != null) {
if (pathCursor.moveToNext()) {
String mediaPath = pathCursor.getString(0);
if (mediaPath != null) { // this happens on android 4.x sometimes?!
QueryTask query = MediaUtils.buildFileQuery(mediaPath, Song.FILLED_PROJECTION);
cursor = query.runQuery(this);
}
}
pathCursor.close();
}
}
if (uri.getScheme().equals("file")) {
cursor = MediaUtils.getCursorForFileQuery(uri.getPath());
}
if (cursor != null) {
if (cursor.moveToNext()) {

View File

@ -28,6 +28,7 @@ import android.graphics.Bitmap.CompressFormat;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.os.Environment;
import android.provider.MediaStore;
import android.util.Log;
import java.io.ByteArrayOutputStream;
@ -255,7 +256,7 @@ public class CoverCache {
dbh.delete(TABLE_NAME, "1", null);
} else if (availableSpace < 0) {
// Try to evict all expired entries first
int affected = dbh.delete(TABLE_NAME, "expires < ?", new String[] {""+getUnixTime()});
int affected = dbh.delete(TABLE_NAME, "expires < ?", new String[] { Long.toString(getUnixTime())});
if (affected > 0)
availableSpace = maxCacheSize - getUsedSpace();
@ -266,7 +267,7 @@ public class CoverCache {
while (cursor.moveToNext() && availableSpace < 0) {
int id = cursor.getInt(0);
int size = cursor.getInt(1);
dbh.delete(TABLE_NAME, "id=?", new String[] {""+id});
dbh.delete(TABLE_NAME, "id=?", new String[] { Long.toString(id) });
availableSpace += size;
}
cursor.close();
@ -361,7 +362,7 @@ public class CoverCache {
SQLiteDatabase dbh = getWritableDatabase(); // may also delete
String selection = "id=?";
String[] selectionArgs = { ""+key.hashCode() };
String[] selectionArgs = { Long.toString(key.hashCode()) };
Cursor cursor = dbh.query(TABLE_NAME, FULL_PROJECTION, selection, selectionArgs, null, null, null);
if (cursor != null) {
if (cursor.moveToFirst()) {
@ -444,12 +445,24 @@ public class CoverCache {
}
if (inputStream == null && (CoverCache.mCoverLoadMode & CoverCache.COVER_MODE_ANDROID) != 0) {
Uri uri = Uri.parse("content://media/external/audio/albumart/"+song.albumId);
long albumId = -1;
ContentResolver res = mContext.getContentResolver();
sampleInputStream = res.openInputStream(uri);
if (sampleInputStream != null) // cache misses are VERY expensive here, so we check if the first open worked
inputStream = res.openInputStream(uri);
Uri contentUri = MediaStore.Audio.Media.getContentUriForPath(song.path);
// Lookup the album id assigned to this path in the android media store
Cursor cursor = res.query(contentUri, new String[]{ MediaStore.Audio.Media.ALBUM_ID }, MediaStore.Audio.Media.DATA+"=?", new String[] { song.path }, null);
if (cursor.moveToFirst()) {
albumId = cursor.getLong(0);
}
cursor.close();
if (albumId != -1) {
// now we can query for the album art path if we found an album id
Uri uri = Uri.parse("content://media/external/audio/albumart/"+albumId);
sampleInputStream = res.openInputStream(uri);
if (sampleInputStream != null) // cache misses are VERY expensive here, so we check if the first open worked
inputStream = res.openInputStream(uri);
}
}
if (inputStream != null) {

View File

@ -23,17 +23,17 @@
package ch.blinkenlights.android.vanilla;
import ch.blinkenlights.android.medialibrary.MediaMetadataExtractor;
import java.util.ArrayList;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Color;
import android.media.MediaMetadataRetriever;
import android.os.Build;
import android.os.Bundle;
import android.os.Message;
import android.util.Log;
import android.content.ContentResolver;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.Menu;
@ -340,7 +340,7 @@ public class FullPlaybackActivity extends SlidingPlaybackActivity
PlaylistTask playlistTask = new PlaylistTask(playlistId, getString(R.string.playlist_favorites));
playlistTask.audioIds = new ArrayList<Long>();
playlistTask.audioIds.add(song.id);
int action = Playlist.isInPlaylist(getContentResolver(), playlistId, song) ? MSG_REMOVE_FROM_PLAYLIST : MSG_ADD_TO_PLAYLIST;
int action = Playlist.isInPlaylist(this, playlistId, song) ? MSG_REMOVE_FROM_PLAYLIST : MSG_ADD_TO_PLAYLIST;
mHandler.sendMessage(mHandler.obtainMessage(action, playlistTask));
}
break;
@ -496,36 +496,17 @@ public class FullPlaybackActivity extends SlidingPlaybackActivity
mReplayGain = null;
if(song != null) {
MediaMetadataExtractor data = new MediaMetadataExtractor(song.path);
MediaMetadataRetriever data = new MediaMetadataRetriever();
try {
data.setDataSource(song.path);
} catch (Exception e) {
Log.w("VanillaMusic", "Failed to extract metadata from " + song.path);
}
mGenre = data.extractMetadata(MediaMetadataRetriever.METADATA_KEY_GENRE);
mTrack = data.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER);
String composer = data.extractMetadata(MediaMetadataRetriever.METADATA_KEY_COMPOSER);
if (composer == null)
composer = data.extractMetadata(MediaMetadataRetriever.METADATA_KEY_WRITER);
mComposer = composer;
String year = data.extractMetadata(MediaMetadataRetriever.METADATA_KEY_YEAR);
if (year == null || "0".equals(year)) {
year = null;
} else {
int dash = year.indexOf('-');
if (dash != -1)
year = year.substring(0, dash);
}
mYear = year;
mGenre = data.getFirst(MediaMetadataExtractor.GENRE);
mTrack = data.getFirst(MediaMetadataExtractor.TRACK_NUMBER);
mComposer = data.getFirst(MediaMetadataExtractor.COMPOSER);
mYear = data.getFirst(MediaMetadataExtractor.YEAR);
mPath = song.path;
StringBuilder sb = new StringBuilder(12);
sb.append(decodeMimeType(data.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE)));
String bitrate = data.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE);
sb.append(decodeMimeType(data.getFirst(MediaMetadataExtractor.MIME_TYPE)));
String bitrate = data.getFirst(MediaMetadataExtractor.BITRATE);
if (bitrate != null && bitrate.length() > 3) {
sb.append(' ');
sb.append(bitrate.substring(0, bitrate.length() - 3));
@ -535,8 +516,6 @@ public class FullPlaybackActivity extends SlidingPlaybackActivity
BastpUtil.GainValues rg = PlaybackService.get(this).getReplayGainValues(song.path);
mReplayGain = String.format("base=%.2f, track=%.2f, album=%.2f", rg.base, rg.track, rg.album);
data.release();
}
mUiHandler.sendEmptyMessage(MSG_COMMIT_INFO);
@ -616,7 +595,7 @@ public class FullPlaybackActivity extends SlidingPlaybackActivity
case MSG_NOTIFY_PLAYLIST_CHANGED: // triggers a fav-refresh
case MSG_LOAD_FAVOURITE_INFO:
if (mCurrentSong != null) {
boolean found = Playlist.isInPlaylist(getContentResolver(), Playlist.getFavoritesId(this, false), mCurrentSong);
boolean found = Playlist.isInPlaylist(this, Playlist.getFavoritesId(this, false), mCurrentSong);
mUiHandler.sendMessage(mUiHandler.obtainMessage(MSG_COMMIT_FAVOURITE_INFO, found));
}
break;

View File

@ -137,7 +137,7 @@ public class LazyCoverView extends ImageView
if (bitmap == null) {
if (payload.key.mediaType == MediaUtils.TYPE_ALBUM) {
// We only display real covers for queries using the album id as key
Song song = MediaUtils.getSongByTypeId(mContext.getContentResolver(), payload.key.mediaType, payload.key.mediaId);
Song song = MediaUtils.getSongByTypeId(mContext, payload.key.mediaType, payload.key.mediaId);
if (song != null) {
bitmap = song.getSmallCover(mContext);
}

View File

@ -23,8 +23,9 @@
package ch.blinkenlights.android.vanilla;
import ch.blinkenlights.android.medialibrary.MediaLibrary;
import android.app.AlertDialog;
import android.content.ContentResolver;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
@ -547,7 +548,7 @@ public class LibraryActivity
/**
* Set a new limiter of the given type built from the first
* MediaStore.Audio.Media row that matches the selection.
* MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS row that matches the selection.
*
* @param limiterType The type of limiter to create. Must be either
* MediaUtils.TYPE_ARTIST or MediaUtils.TYPE_ALBUM.
@ -555,10 +556,10 @@ public class LibraryActivity
*/
private void setLimiter(int limiterType, String selection)
{
ContentResolver resolver = getContentResolver();
Uri uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
String[] projection = new String[] { MediaStore.Audio.Media.ARTIST_ID, MediaStore.Audio.Media.ALBUM_ID, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ALBUM };
Cursor cursor = MediaUtils.queryResolver(resolver, uri, projection, selection, null, null);
String[] projection = new String[] { MediaLibrary.ContributorColumns.ARTIST_ID, MediaLibrary.SongColumns.ALBUM_ID, MediaLibrary.ContributorColumns.ARTIST, MediaLibrary.AlbumColumns.ALBUM };
QueryTask query = new QueryTask(MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS, projection, selection, null, null);
Cursor cursor = query.runQuery(getApplicationContext());
if (cursor != null) {
if (cursor.moveToNext()) {
String[] fields;
@ -566,11 +567,11 @@ public class LibraryActivity
switch (limiterType) {
case MediaUtils.TYPE_ARTIST:
fields = new String[] { cursor.getString(2) };
data = String.format("artist_id=%d", cursor.getLong(0));
data = String.format(MediaLibrary.ContributorColumns.ARTIST_ID+"=%d", cursor.getLong(0));
break;
case MediaUtils.TYPE_ALBUM:
fields = new String[] { cursor.getString(2), cursor.getString(3) };
data = String.format("album_id=%d", cursor.getLong(1));
data = String.format(MediaLibrary.SongColumns.ALBUM_ID+"=%d", cursor.getLong(1));
break;
default:
throw new IllegalArgumentException("setLimiter() does not support limiter type " + limiterType);

View File

@ -214,7 +214,6 @@ public class LibraryPagerAdapter
mUiHandler = new Handler(this);
mWorkerHandler = new Handler(workerLooper, this);
mCurrentPage = -1;
activity.getContentResolver().registerContentObserver(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, true, mPlaylistObserver);
}
/**

View File

@ -23,6 +23,8 @@
package ch.blinkenlights.android.vanilla;
import ch.blinkenlights.android.medialibrary.MediaLibrary;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
@ -48,6 +50,7 @@ import java.util.regex.Pattern;
import java.util.ArrayList;
import java.lang.StringBuilder;
import android.util.Log;
/**
* MediaAdapter provides an adapter backed by a MediaStore content provider.
* It generates simple one- or two-line text views to display each media
@ -66,9 +69,6 @@ public class MediaAdapter
, SectionIndexer
{
private static final Pattern SPACE_SPLIT = Pattern.compile("\\s+");
private static final String SORT_MAGIC_PLAYCOUNT = "__PLAYCOUNT_SORT";
/**
* The string to use for length==0 db fields
*/
@ -96,9 +96,9 @@ public class MediaAdapter
*/
private final int mType;
/**
* The URI of the content provider backing this adapter.
* The table / view to use for this query
*/
private Uri mStore;
private String mSource;
/**
* The fields to use from the content provider. The last field will be
* displayed in the MediaView, as will the first field if there are
@ -133,11 +133,6 @@ public class MediaAdapter
* ASC or DESC as appropriate before being passed to the query.
*/
private String[] mAdapterSortValues;
/**
* Same as mAdapterSortValues, but describes the query to do if we
* are returning songs for a `foreign' adapter (which migt have different column names)
*/
private String[] mSongSortValues;
/**
* The index of the current of the current sort mode in mSortValues, or
* the inverse of the index (in which case sort should be descending
@ -189,53 +184,51 @@ public class MediaAdapter
switch (type) {
case MediaUtils.TYPE_ARTIST:
mStore = MediaStore.Audio.Artists.EXTERNAL_CONTENT_URI;
mFields = new String[] { MediaStore.Audio.Artists.ARTIST };
mFieldKeys = new String[] { MediaStore.Audio.Artists.ARTIST_KEY };
mSortEntries = new int[] { R.string.name, R.string.number_of_tracks };
mAdapterSortValues = new String[] { "artist_key %1$s", "number_of_tracks %1$s,artist_key %1$s" };
mSongSortValues = new String[] { "artist_key %1$s,track", "artist_key %1$s,track" /* cannot sort by number_of_tracks */ };
mSource = MediaLibrary.VIEW_ARTISTS;
mFields = new String[] { MediaLibrary.ContributorColumns.ARTIST };
mFieldKeys = new String[] { MediaLibrary.ContributorColumns.ARTIST_SORT };
mSortEntries = new int[] { R.string.name, R.string.date_added };
mAdapterSortValues = new String[] { MediaLibrary.ContributorColumns.ARTIST_SORT+" %1$s", MediaLibrary.ContributorColumns.MTIME+" %1$s" };
break;
case MediaUtils.TYPE_ALBUM:
mStore = MediaStore.Audio.Albums.EXTERNAL_CONTENT_URI;
mFields = new String[] { MediaStore.Audio.Albums.ALBUM, MediaStore.Audio.Albums.ARTIST };
// Why is there no artist_key column constant in the album MediaStore? The column does seem to exist.
mFieldKeys = new String[] { MediaStore.Audio.Albums.ALBUM_KEY, "artist_key" };
mSortEntries = new int[] { R.string.name, R.string.artist_album, R.string.artist_year_album, R.string.number_of_tracks, R.string.date_added };
mAdapterSortValues = new String[] { "album_key %1$s", "artist_key %1$s,album_key %1$s", "artist_key %1$s,minyear %1$s,album_key %1$s", "numsongs %1$s,album_key %1$s", "_id %1$s" };
mSongSortValues = new String[] { "album_key %1$s,track", "artist_key %1$s,album_key %1$s,track", "artist_key %1$s,year %1$s,album_key %1$s,track", "album_key %1$s,track", "album_id %1$s,track" };
mSource = MediaLibrary.VIEW_ALBUMS_ARTISTS;
mFields = new String[] { MediaLibrary.AlbumColumns.ALBUM, MediaLibrary.ContributorColumns.ARTIST };
mFieldKeys = new String[] { MediaLibrary.AlbumColumns.ALBUM_SORT, MediaLibrary.ContributorColumns.ARTIST_SORT };
mSortEntries = new int[] { R.string.name, R.string.artist_album, R.string.year, R.string.date_added };
mAdapterSortValues = new String[] { MediaLibrary.AlbumColumns.ALBUM_SORT+" %1$s", MediaLibrary.ContributorColumns.ARTIST_SORT+" %1$s,"+MediaLibrary.AlbumColumns.ALBUM_SORT+" %1$s",
MediaLibrary.AlbumColumns.PRIMARY_ALBUM_YEAR+" %1$s", MediaLibrary.AlbumColumns.MTIME+" %1$s" };
break;
case MediaUtils.TYPE_SONG:
mStore = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
mFields = new String[] { MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.ARTIST };
mFieldKeys = new String[] { MediaStore.Audio.Media.TITLE_KEY, MediaStore.Audio.Media.ALBUM_KEY, MediaStore.Audio.Media.ARTIST_KEY };
mSortEntries = new int[] { R.string.name, R.string.artist_album_track, R.string.artist_album_title,
R.string.artist_year_album, R.string.album_track,
R.string.year, R.string.date_added, R.string.song_playcount };
mAdapterSortValues = new String[] { "title_key %1$s", "artist_key %1$s,album_key %1$s,track", "artist_key %1$s,album_key %1$s,title_key %1$s",
"artist_key %1$s,year %1$s,album_key %1$s,track", "album_key %1$s,track",
"year %1$s,title_key %1$s","_id %1$s", SORT_MAGIC_PLAYCOUNT };
mSongSortValues = mAdapterSortValues;
mSource = MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS;
mFields = new String[] { MediaLibrary.SongColumns.TITLE, MediaLibrary.AlbumColumns.ALBUM, MediaLibrary.ContributorColumns.ARTIST };
mFieldKeys = new String[] { MediaLibrary.SongColumns.TITLE_SORT, MediaLibrary.AlbumColumns.ALBUM_SORT, MediaLibrary.ContributorColumns.ARTIST_SORT };
mSortEntries = new int[] { R.string.name, R.string.artist_album_track, R.string.artist_album_title, R.string.album_track, R.string.year, R.string.date_added, R.string.song_playcount };
mAdapterSortValues = new String[] { MediaLibrary.SongColumns.TITLE_SORT+" %1$s",
MediaLibrary.ContributorColumns.ARTIST_SORT+" %1$s,"+MediaLibrary.AlbumColumns.ALBUM_SORT+" %1$s,"+MediaLibrary.AlbumColumns.DISC_NUMBER+","+MediaLibrary.SongColumns.SONG_NUMBER,
MediaLibrary.ContributorColumns.ARTIST_SORT+" %1$s,"+MediaLibrary.AlbumColumns.ALBUM_SORT+" %1$s,"+MediaLibrary.SongColumns.TITLE_SORT+" %1$s",
MediaLibrary.AlbumColumns.ALBUM_SORT+" %1$s,"+MediaLibrary.AlbumColumns.DISC_NUMBER+","+MediaLibrary.SongColumns.SONG_NUMBER,
MediaLibrary.SongColumns.YEAR+" %1$s,"+MediaLibrary.AlbumColumns.ALBUM_SORT+" %1$s,"+MediaLibrary.AlbumColumns.DISC_NUMBER+","+MediaLibrary.SongColumns.SONG_NUMBER,
MediaLibrary.SongColumns.MTIME+" %1$s",
MediaLibrary.SongColumns.PLAYCOUNT+" %1$s",
};
// Songs covers are cached per-album
mCoverCacheType = MediaUtils.TYPE_ALBUM;
coverCacheKey = MediaStore.Audio.Albums.ALBUM_ID;
break;
case MediaUtils.TYPE_PLAYLIST:
mStore = MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI;
mFields = new String[] { MediaStore.Audio.Playlists.NAME };
mSource = MediaLibrary.TABLE_PLAYLISTS;
mFields = new String[] { MediaLibrary.PlaylistColumns.NAME };
mFieldKeys = null;
mSortEntries = new int[] { R.string.name, R.string.date_added };
mAdapterSortValues = new String[] { "name %1$s", "date_added %1$s" };
mSongSortValues = null;
mAdapterSortValues = new String[] { MediaLibrary.PlaylistColumns.NAME+" %1$s", MediaLibrary.PlaylistColumns._ID+" %1$s" };
mExpandable = true;
break;
case MediaUtils.TYPE_GENRE:
mStore = MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI;
mFields = new String[] { MediaStore.Audio.Genres.NAME };
mFieldKeys = null;
mSource = MediaLibrary.TABLE_GENRES;
mFields = new String[] { MediaLibrary.GenreColumns._GENRE };
mFieldKeys = new String[] { MediaLibrary.GenreColumns._GENRE_SORT };
mSortEntries = new int[] { R.string.name };
mAdapterSortValues = new String[] { "name %1$s" };
mSongSortValues = null;
mAdapterSortValues = new String[] { MediaLibrary.GenreColumns._GENRE_SORT+" %1$s" };
break;
default:
throw new IllegalArgumentException("Invalid value for type: " + type);
@ -260,8 +253,8 @@ public class MediaAdapter
private String getFirstSortColumn() {
int mode = mSortMode < 0 ? ~mSortMode : mSortMode; // get current sort mode
String column = SPACE_SPLIT.split(mAdapterSortValues[mode])[0];
if(column.endsWith("_key")) { // we want human-readable string, not machine-composed
column = column.substring(0, column.length() - 4);
if(column.endsWith("_sort")) { // we want human-readable string, not machine-composed
column = column.substring(0, column.length() - 5);
}
return column;
@ -291,17 +284,23 @@ public class MediaAdapter
* Build the query to be run with runQuery().
*
* @param projection The columns to query.
* @param forceMusicCheck Force the is_music check to be added to the
* selection.
* @param returnSongs return songs instead of mType if true.
*/
private QueryTask buildQuery(String[] projection, boolean returnSongs)
{
private QueryTask buildQuery(String[] projection, boolean returnSongs) {
Log.v("VanillaMusic", "constrain = "+mConstraint);
Log.v("VanillaMusic", "limiter = "+ (mLimiter == null ? "NULL" : mLimiter.data));
Log.v("VanillaMusic", "sortMode = "+mSortMode);
String source = mSource;
String constraint = mConstraint;
Limiter limiter = mLimiter;
StringBuilder selection = new StringBuilder();
String[] selectionArgs = null;
String[] enrichedProjection = projection;
// Assemble the sort string as requested by the user
int mode = mSortMode;
String sortDir;
if (mode < 0) {
@ -311,47 +310,31 @@ public class MediaAdapter
sortDir = "ASC";
}
// Use the song-sort mapping if we are returning songs
String sortStringRaw = (returnSongs ? mSongSortValues[mode] : mAdapterSortValues[mode]);
String[] enrichedProjection = projection;
// Magic sort mode: sort by playcount
if (sortStringRaw == SORT_MAGIC_PLAYCOUNT) {
ArrayList<Long> topSongs = (new PlayCountsHelper(mContext)).getTopSongs(4096);
int sortWeight = -1 * topSongs.size(); // Sort mode is actually reversed (default: mostplayed -> leastplayed)
StringBuilder sb = new StringBuilder("CASE WHEN _id=0 THEN 0"); // include dummy statement in initial string -> topSongs may be empty
for (Long id : topSongs) {
sb.append(" WHEN _id="+id+" THEN "+sortWeight);
sortWeight++;
}
sb.append(" ELSE 0 END %1s");
sortStringRaw = sb.toString();
} else if (returnSongs == false) {
// This is an 'adapter native' query: include the first sorting column
// in the projection to make it useable for the fast-scroller
enrichedProjection = Arrays.copyOf(projection, projection.length + 1);
enrichedProjection[projection.length] = getFirstSortColumn();
// Fetch current sorting mode and sort by disc+track if we are going to look up the songs table
String sortRaw = mAdapterSortValues[mode];
if (returnSongs) {
// songs returned from the artist tab should also sort by album
if (mType == MediaUtils.TYPE_ARTIST)
sortRaw += ", "+MediaLibrary.AlbumColumns.ALBUM_SORT+" %1$s";
// and this is for all types:
sortRaw += ", "+MediaLibrary.AlbumColumns.DISC_NUMBER+", "+MediaLibrary.SongColumns.SONG_NUMBER;
}
String sort = String.format(sortStringRaw, sortDir);
if (returnSongs || mType == MediaUtils.TYPE_SONG)
selection.append(MediaStore.Audio.Media.IS_MUSIC+" AND length(_data)");
// ...and assemble the SQL string we are really going to use
String sort = String.format(sortRaw, sortDir);
// include the constraint (aka: search string) if any
if (constraint != null && constraint.length() != 0) {
String[] needles;
String[] keySource;
// If we are using sorting keys, we need to change our constraint
// into a list of collation keys. Otherwise, just split the
// constraint with no modification.
if (mFieldKeys != null) {
String colKey = MediaStore.Audio.keyFor(constraint);
String colKey = MediaLibrary.keyFor(constraint);
String spaceColKey = DatabaseUtils.getCollationKey(" ");
needles = colKey.split(spaceColKey);
keySource = mFieldKeys;
} else {
// only used for playlists, maybe we should just update the schema ?
needles = SPACE_SPLIT.split(constraint);
keySource = mFields;
}
@ -379,31 +362,28 @@ public class MediaAdapter
}
}
QueryTask query;
if(mType == MediaUtils.TYPE_GENRE && !returnSongs) {
query = MediaUtils.buildGenreExcludeEmptyQuery(enrichedProjection, selection.toString(),
selectionArgs, sort);
} else if (limiter != null && limiter.type == MediaUtils.TYPE_GENRE) {
// Genre is not standard metadata for MediaStore.Audio.Media.
// We have to query it through a separate provider. : /
query = MediaUtils.buildGenreQuery((Long)limiter.data, enrichedProjection, selection.toString(), selectionArgs, sort, mType, returnSongs);
} else {
if (limiter != null) {
if (selection.length() != 0)
selection.append(" AND ");
selection.append(limiter.data);
if (limiter != null) {
if (selection.length() != 0) {
selection.append(" AND ");
}
query = new QueryTask(mStore, enrichedProjection, selection.toString(), selectionArgs, sort);
if (returnSongs) // force query on song provider as we are requested to return songs
query.uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
selection.append(limiter.data);
}
if (returnSongs == true) {
source = MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS;
} else {
enrichedProjection = Arrays.copyOf(projection, projection.length + 1);
enrichedProjection[projection.length] = getFirstSortColumn();
}
QueryTask query = new QueryTask(source, enrichedProjection, selection.toString(), selectionArgs, sort);
return query;
}
@Override
public Cursor query()
{
return buildQuery(mProjection, false).runQuery(mContext.getContentResolver());
return buildQuery(mProjection, false).runQuery(mContext);
}
@Override
@ -467,15 +447,15 @@ public class MediaAdapter
switch (mType) {
case MediaUtils.TYPE_ARTIST:
fields = new String[] { cursor.getString(2) };
data = String.format("%s=%d", MediaStore.Audio.Media.ARTIST_ID, id);
data = String.format("%s=%d", MediaLibrary.ContributorColumns.ARTIST_ID, id);
break;
case MediaUtils.TYPE_ALBUM:
fields = new String[] { cursor.getString(3), cursor.getString(2) };
data = String.format("%s=%d", MediaStore.Audio.Media.ALBUM_ID, id);
data = String.format("%s=%d", MediaLibrary.SongColumns.ALBUM_ID, id);
break;
case MediaUtils.TYPE_GENRE:
fields = new String[] { cursor.getString(2) };
data = id;
data = String.format("%s=%d", MediaLibrary.GenreSongColumns._GENRE_ID, id);
break;
default:
throw new IllegalStateException("getLimiter() is not supported for media type: " + mType);
@ -588,7 +568,7 @@ public class MediaAdapter
*/
public void setSortMode(int i)
{
mSortMode = i;
mSortMode = (i < mSortEntries.length ? i : 0);
}
/**

View File

@ -22,6 +22,9 @@
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;
@ -29,12 +32,12 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.zip.CRC32;
import android.util.Log;
import junit.framework.Assert;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
@ -43,7 +46,6 @@ import android.os.Environment;
import android.provider.MediaStore;
import android.text.TextUtils;
import android.database.MatrixCursor;
import android.media.MediaMetadataRetriever;
import android.util.Log;
import android.widget.Toast;
@ -88,19 +90,19 @@ public class MediaUtils {
/**
* The default sort order for media queries. First artist, then album, then
* track number.
* song number.
*/
private static final String DEFAULT_SORT = "artist_key,album_key,track";
private static final String DEFAULT_SORT = "artist_sort,album_sort,disc_num,song_num";
/**
* The default sort order for albums. First the album, then tracknumber
* The default sort order for albums. First the album, then songnumber
*/
private static final String ALBUM_SORT = "album_key,track";
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 = "_data";
private static final String FILE_SORT = "path";
/**
* Cached random instance.
@ -140,35 +142,36 @@ public class MediaUtils {
*/
private static QueryTask buildMediaQuery(int type, long id, String[] projection, String select)
{
Uri media = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
StringBuilder selection = new StringBuilder();
String sort = DEFAULT_SORT;
if (select != null) {
selection.append(select);
selection.append(" AND ");
}
switch (type) {
case TYPE_SONG:
selection.append(MediaStore.Audio.Media._ID);
selection.append(MediaLibrary.SongColumns._ID);
break;
case TYPE_ARTIST:
selection.append(MediaStore.Audio.Media.ARTIST_ID);
selection.append(MediaLibrary.ContributorColumns.ARTIST_ID);
break;
case TYPE_ALBUM:
selection.append(MediaStore.Audio.Media.ALBUM_ID);
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);
selection.append(" AND length(_data) AND "+MediaStore.Audio.Media.IS_MUSIC);
if (select != null) {
selection.append(" AND ");
selection.append(select);
}
QueryTask result = new QueryTask(media, projection, selection.toString(), null, sort);
QueryTask result = new QueryTask(MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS, projection, selection.toString(), null, sort);
result.type = type;
return result;
}
@ -179,139 +182,16 @@ public class MediaUtils {
*
* @param id The id of the playlist in MediaStore.Audio.Playlists.
* @param projection The columns to query.
* @param selection The selection to pass to the query, or null.
* @return The initialized query.
*/
public static QueryTask buildPlaylistQuery(long id, String[] projection, String selection)
{
Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", id);
String sort = MediaStore.Audio.Playlists.Members.PLAY_ORDER;
QueryTask result = new QueryTask(uri, projection, selection, null, sort);
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 that will return all the songs in the genre with the
* given id.
*
* @param id The id of the genre in MediaStore.Audio.Genres.
* @param projection The columns to query.
* @param selection The selection to pass to the query, or null.
* @param selectionArgs The arguments to substitute into the selection.
* @param sort The sort order.
* @param type The media type to query and return
* @param returnSongs returns matching songs instead of `type' if true
*/
public static QueryTask buildGenreQuery(long id, String[] projection, String selection, String[] selectionArgs, String sort, int type, boolean returnSongs)
{
// Note: This function works on a raw sql query with way too much internal
// knowledge about the mediaProvider SQL table layout. Yes: it's ugly.
// The reason for this mess is that android has a very crippled genre implementation
// and does, for example, not allow us to query the albumbs beloging to a genre.
Uri uri = MediaStore.Audio.Genres.Members.getContentUri("external", id);
String[] clonedProjection = projection.clone(); // we modify the projection, but this should not be visible to the caller
String sql = "";
String authority = "audio";
if (type == TYPE_ARTIST)
authority = "artist_info";
if (type == TYPE_ALBUM)
authority = "album_info";
// Our raw SQL query includes the album_info table (well: it's actually a view)
// which shares some columns with audio.
// This regexp should matche duplicate column names and forces them to use
// the audio table as a source
final String _FORCE_AUDIO_SRC = "(^|[ |,\\(])(_id|album(_\\w+)?|artist(_\\w+)?)";
// Prefix the SELECTed rows with the current table authority name
for (int i=0 ;i<clonedProjection.length; i++) {
if (clonedProjection[i].equals("0") == false) // do not prefix fake rows
clonedProjection[i] = (returnSongs ? "audio" : authority)+"."+clonedProjection[i];
}
sql += TextUtils.join(", ", clonedProjection);
sql += " FROM audio_genres_map_noid, audio" + (authority.equals("audio") ? "" : ", "+authority);
sql += " WHERE(audio._id = audio_id AND genre_id=?)";
if (selection != null && selection.length() > 0)
sql += " AND("+selection.replaceAll(_FORCE_AUDIO_SRC, "$1audio.$2")+")";
if (type == TYPE_ARTIST)
sql += " AND(artist_info._id = audio.artist_id)" + (returnSongs ? "" : " GROUP BY artist_info._id");
if (type == TYPE_ALBUM)
sql += " AND(album_info._id = audio.album_id)" + (returnSongs ? "" : " GROUP BY album_info._id");
if (sort != null && sort.length() > 0)
sql += " ORDER BY "+sort.replaceAll(_FORCE_AUDIO_SRC, "$1audio.$2");
// We are now turning this into an sql injection. Fun times.
clonedProjection[0] = sql +" --";
QueryTask result = new QueryTask(uri, clonedProjection, selection, selectionArgs, sort);
result.type = TYPE_GENRE;
return result;
}
/**
* Creates a {@link QueryTask} for genres. The query will select only genres that have at least
* one song associated with them.
*
* @param projection The fields of the genre table that should be returned.
* @param selection Additional constraints for the query (added to the WHERE section). '?'s
* will be replaced by values in {@code selectionArgs}. Can be null.
* @param selectionArgs Arguments for {@code selection}. Can be null. See
* {@link android.content.ContentProvider#query(Uri, String[], String, String[], String)}
* @param sort How the returned genres should be sorted (added to the ORDER BY section)
* @return The QueryTask for the genres
*/
public static QueryTask buildGenreExcludeEmptyQuery(String[] projection, String selection, String[] selectionArgs, String sort) {
/*
* An example SQLite query that we're building in this function
SELECT DISTINCT _id, name
FROM audio_genres
WHERE
EXISTS(
SELECT audio_id, genre_id, audio._id
FROM audio_genres_map, audio
WHERE (genre_id == audio_genres._id)
AND (audio_id == audio._id))
ORDER BY name DESC
*/
Uri uri = MediaStore.Audio.Genres.getContentUri("external");
StringBuilder sql = new StringBuilder();
// Don't want multiple identical genres
sql.append("DISTINCT ");
// Add the projection fields to the query
sql.append(TextUtils.join(", ", projection)).append(' ');
sql.append("FROM audio_genres ");
// Limit to genres that contain at least one valid song
sql.append("WHERE EXISTS( ")
.append("SELECT audio_id, genre_id, audio._id ")
.append("FROM audio_genres_map, audio ")
.append("WHERE (genre_id == audio_genres._id) AND (audio_id == audio._id) ")
.append(") ");
if (!TextUtils.isEmpty(selection))
sql.append(" AND(" + selection + ") ");
if(!TextUtils.isEmpty(sort))
sql.append(" ORDER BY ").append(sort);
// Ignore the framework generated query
sql.append(" -- ");
String[] injectedProjection = new String[1];
injectedProjection[0] = sql.toString();
// Don't pass the selection/sort as we've already added it to the query
return new QueryTask(uri, injectedProjection, null, selectionArgs, null);
}
/**
* Builds a query with the given information.
*
@ -328,11 +208,10 @@ public class MediaUtils {
case TYPE_ARTIST:
case TYPE_ALBUM:
case TYPE_SONG:
case TYPE_GENRE:
return buildMediaQuery(type, id, projection, selection);
case TYPE_PLAYLIST:
return buildPlaylistQuery(id, projection, selection);
case TYPE_GENRE:
return buildGenreQuery(id, projection, selection, null, MediaStore.Audio.Genres.Members.TITLE_KEY, TYPE_SONG, true);
return buildPlaylistQuery(id, projection);
default:
throw new IllegalArgumentException("Specified type not valid: " + type);
}
@ -342,15 +221,15 @@ public class MediaUtils {
* Query the MediaStore to determine the id of the genre the song belongs
* to.
*
* @param resolver A ContentResolver to use.
* @param context The context to use
* @param id The id of the song to query the genre for.
*/
public static long queryGenreForSong(ContentResolver resolver, long id)
{
String[] projection = { "_id" };
Uri uri = MediaStore.Audio.Genres.getContentUriForAudioId("external", (int)id);
Cursor cursor = queryResolver(resolver, uri, projection, null, null, null);
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);
@ -378,7 +257,7 @@ public class MediaUtils {
/**
* Shuffle a Song list using Collections.shuffle().
*
* @param albumShuffle If true, preserve the order of tracks inside albums.
* @param albumShuffle If true, preserve the order of songs inside albums.
*/
public static void shuffle(List<Song> list, boolean albumShuffle)
{
@ -428,24 +307,13 @@ public class MediaUtils {
/**
* Determine if any songs are available from the library.
*
* @param resolver A ContentResolver to use.
* @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(ContentResolver resolver)
{
public static boolean isSongAvailable(Context context) {
if (sSongCount == -1) {
Uri media = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
String selection = MediaStore.Audio.Media.IS_MUSIC;
selection += " AND length(_data)";
Cursor cursor = queryResolver(resolver, media, new String[]{"count(_id)"}, selection, null, null);
if (cursor == null) {
sSongCount = 0;
} else {
cursor.moveToFirst();
sSongCount = cursor.getInt(0);
cursor.close();
}
sSongCount = MediaLibrary.getLibrarySize(context);
}
return sSongCount != 0;
@ -455,14 +323,11 @@ public class MediaUtils {
* Returns a shuffled array contaning the ids of all the songs on the
* device's library.
*
* @param resolver A ContentResolver to use.
* @param context The Context to use
*/
private static long[] queryAllSongs(ContentResolver resolver)
{
Uri media = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
String selection = MediaStore.Audio.Media.IS_MUSIC;
selection += " AND length(_data)";
Cursor cursor = queryResolver(resolver, media, Song.EMPTY_PROJECTION, selection, null, null);
private static long[] queryAllSongs(Context context) {
QueryTask query = new QueryTask(MediaLibrary.TABLE_SONGS, Song.EMPTY_PROJECTION, null, null, null);
Cursor cursor = query.runQuery(context);
if (cursor == null || cursor.getCount() == 0) {
sSongCount = 0;
return null;
@ -484,29 +349,9 @@ public class MediaUtils {
}
/**
* Runs a query on the passed content resolver.
* Catches (and returns null on) SecurityException (= user revoked read permission)
*
* @param resolver The content resolver to use
* @param uri the uri to query
* @param projection the projection to use
* @param selection the selection to use
* @param selectionArgs arguments for the selection
* @param sortOrder sort order of the returned result
*
* @return a cursor or null
* Called if we detected a medium change
* This flushes some cached data
*/
public static Cursor queryResolver(ContentResolver resolver, Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)
{
Cursor cursor = null;
try {
cursor = resolver.query(uri, projection, selection, selectionArgs, sortOrder);
} catch(java.lang.SecurityException e) {
// we do not have read permission - just return a null cursor
}
return cursor;
}
public static void onMediaChange()
{
sSongCount = -1;
@ -525,9 +370,8 @@ public class MediaUtils {
return;
}
ContentResolver resolver = ctx.getContentResolver();
String[] projection = new String [] { MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DATA };
Cursor cursor = buildQuery(type, id, projection, null).runQuery(resolver);
String[] projection = new String [] { MediaLibrary.SongColumns._ID, MediaLibrary.SongColumns.PATH };
Cursor cursor = buildQuery(type, id, projection, null).runQuery(ctx);
if(cursor == null) {
return;
}
@ -550,14 +394,14 @@ public class MediaUtils {
/**
* Returns the first matching song (or NULL) of given type + id combination
*
* @param resolver A ContentResolver to use.
* @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(ContentResolver resolver, int type, long id) {
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(resolver);
Cursor cursor = query.runQuery(context);
if (cursor != null) {
if (cursor.getCount() > 0) {
cursor.moveToPosition(0);
@ -572,14 +416,14 @@ public class MediaUtils {
* Returns a song randomly selected from all the songs in the Android
* MediaStore.
*
* @param resolver A ContentResolver to use.
* @param context The Context to use
*/
public static Song getRandomSong(ContentResolver resolver)
public static Song getRandomSong(Context context)
{
long[] songs = sAllSongs;
if (songs == null) {
songs = queryAllSongs(resolver);
songs = queryAllSongs(context);
if (songs == null)
return null;
sAllSongs = songs;
@ -589,7 +433,7 @@ public class MediaUtils {
shuffle(sAllSongs);
}
Song result = getSongByTypeId(resolver, MediaUtils.TYPE_SONG, sAllSongs[sAllSongsIdx]);
Song result = getSongByTypeId(context, MediaUtils.TYPE_SONG, sAllSongs[sAllSongsIdx]);
result.flags |= Song.FLAG_RANDOM;
sAllSongsIdx++;
return result;
@ -669,11 +513,10 @@ public class MediaUtils {
-> ended with a % for the LIKE query
*/
path = addDirEndSlash(sanitizeMediaPath(path)) + "%";
final String query = "_data LIKE ? AND "+MediaStore.Audio.Media.IS_MUSIC;
final String query = MediaLibrary.SongColumns.PATH+" LIKE ?";
String[] qargs = { path };
Uri media = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
QueryTask result = new QueryTask(media, projection, query, qargs, FILE_SORT);
QueryTask result = new QueryTask(MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS, projection, query, qargs, FILE_SORT);
result.type = TYPE_FILE;
return result;
}
@ -685,18 +528,11 @@ public class MediaUtils {
* */
public static Cursor getCursorForFileQuery(String path) {
MatrixCursor matrixCursor = new MatrixCursor(Song.FILLED_PROJECTION);
MediaMetadataRetriever data = new MediaMetadataRetriever();
try {
data.setDataSource(path);
} catch (Exception e) {
Log.w("VanillaMusic", "Failed to extract metadata from " + path);
}
String title = data.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE);
String album = data.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM);
String artist = data.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST);
String duration = data.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
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.
@ -704,9 +540,9 @@ public class MediaUtils {
// 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.
CRC32 crc = new CRC32();
crc.update(path.getBytes());
Long songId = (Long)(2+crc.getValue())*-1; // must at least be -2 (-1 defines Song-Object to be empty)
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 };

View File

@ -422,7 +422,6 @@ public class MirrorLinkMediaBrowserService extends MediaBrowserService
try {
Cursor cursor = adapter.query();
Context context = getApplicationContext();
ContentResolver resolver = context.getContentResolver();
if (cursor == null) {
return;
@ -436,7 +435,7 @@ public class MirrorLinkMediaBrowserService extends MediaBrowserService
final String label = cursor.getString(2);
long mediaId = Long.parseLong(id);
Song song = MediaUtils.getSongByTypeId(resolver, mediaType, mediaId);
Song song = MediaUtils.getSongByTypeId(context, mediaType, mediaId);
MediaBrowser.MediaItem item = new MediaBrowser.MediaItem(
new MediaDescription.Builder()
.setMediaId(MediaID.toString(mediaType, mediaId, label))

View File

@ -17,68 +17,23 @@
package ch.blinkenlights.android.vanilla;
import ch.blinkenlights.android.medialibrary.MediaLibrary;
import android.content.Context;
import android.content.ContentResolver;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.Cursor;
import android.util.Log;
import java.util.ArrayList;
public class PlayCountsHelper extends SQLiteOpenHelper {
public class PlayCountsHelper {
/**
* SQL constants and CREATE TABLE statements used by
* this java class
*/
private static final int DATABASE_VERSION = 2;
private static final String DATABASE_NAME = "playcounts.db";
private static final String TABLE_PLAYCOUNTS = "playcounts";
private static final String DATABASE_CREATE = "CREATE TABLE "+TABLE_PLAYCOUNTS + " ("
+ "type INTEGER, "
+ "type_id BIGINT, "
+ "playcount INTEGER, "
+ "skipcount INTEGER);";
private static final String INDEX_UNIQUE_CREATE = "CREATE UNIQUE INDEX idx_uniq ON "+TABLE_PLAYCOUNTS
+ " (type, type_id);";
private static final String INDEX_TYPE_CREATE = "CREATE INDEX idx_type ON "+TABLE_PLAYCOUNTS
+ " (type);";
private Context ctx;
public PlayCountsHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
ctx = context;
}
@Override
public void onCreate(SQLiteDatabase dbh) {
dbh.execSQL(DATABASE_CREATE);
dbh.execSQL(INDEX_UNIQUE_CREATE);
dbh.execSQL(INDEX_TYPE_CREATE);
}
@Override
public void onUpgrade(SQLiteDatabase dbh, int oldVersion, int newVersion) {
if (oldVersion < 2) {
dbh.execSQL("ALTER TABLE "+TABLE_PLAYCOUNTS+" ADD COLUMN skipcount INTEGER");
dbh.execSQL("UPDATE "+TABLE_PLAYCOUNTS+" SET skipcount=0");
}
public PlayCountsHelper() {
}
/**
* Counts this song object as 'played' or 'skipped'
*/
public void countSong(Song song, boolean played) {
long id = Song.getId(song);
final String column = played ? "playcount" : "skipcount";
SQLiteDatabase dbh = getWritableDatabase();
dbh.execSQL("INSERT OR IGNORE INTO "+TABLE_PLAYCOUNTS+" (type, type_id, playcount, skipcount) VALUES ("+MediaUtils.TYPE_SONG+", "+id+", 0, 0);"); // Creates row if not exists
dbh.execSQL("UPDATE "+TABLE_PLAYCOUNTS+" SET "+column+"="+column+"+1 WHERE type="+MediaUtils.TYPE_SONG+" AND type_id="+id+";");
dbh.close();
performGC(MediaUtils.TYPE_SONG);
public static void countSong(Context context, Song song, boolean played) {
final long id = Song.getId(song);
MediaLibrary.updateSongPlayCounts(context, id, played);
}
@ -86,52 +41,15 @@ public class PlayCountsHelper extends SQLiteOpenHelper {
/**
* Returns a sorted array list of most often listen song ids
*/
public ArrayList<Long> getTopSongs(int limit) {
public static ArrayList<Long> getTopSongs(Context context, int limit) {
ArrayList<Long> payload = new ArrayList<Long>();
SQLiteDatabase dbh = getReadableDatabase();
Cursor cursor = dbh.rawQuery("SELECT type_id FROM "+TABLE_PLAYCOUNTS+" WHERE type="+MediaUtils.TYPE_SONG+" AND playcount != 0 ORDER BY playcount DESC limit "+limit, null);
while (cursor.moveToNext()) {
Cursor cursor = MediaLibrary.queryLibrary(context, MediaLibrary.TABLE_SONGS, new String[]{ MediaLibrary.SongColumns._ID }, MediaLibrary.SongColumns.PLAYCOUNT+" > 0", null, MediaLibrary.SongColumns.PLAYCOUNT+" DESC");
while (cursor.moveToNext() && limit > 0) {
payload.add(cursor.getLong(0));
limit--;
}
cursor.close();
dbh.close();
return payload;
}
/**
* Picks a random amount of 'type' items from the provided DBH
* and checks them against Androids media database.
* Items not found in the media library are removed from the DBH's database
*/
private int performGC(int type) {
SQLiteDatabase dbh = getWritableDatabase();
ArrayList<Long> toCheck = new ArrayList<Long>(); // List of songs we are going to check
QueryTask query; // Reused query object
Cursor cursor; // recycled cursor
int removed = 0; // Amount of removed items
// We are just grabbing a bunch of random IDs
cursor = dbh.rawQuery("SELECT type_id FROM "+TABLE_PLAYCOUNTS+" WHERE type="+type+" ORDER BY RANDOM() LIMIT 10", null);
while (cursor.moveToNext()) {
toCheck.add(cursor.getLong(0));
}
cursor.close();
for (Long id : toCheck) {
query = MediaUtils.buildQuery(type, id, null, null);
cursor = query.runQuery(ctx.getContentResolver());
if(cursor.getCount() == 0) {
dbh.execSQL("DELETE FROM "+TABLE_PLAYCOUNTS+" WHERE type="+type+" AND type_id="+id);
removed++;
}
cursor.close();
}
Log.v("VanillaMusic", "performGC: items removed="+removed);
dbh.close();
return removed;
}
}

View File

@ -440,7 +440,7 @@ public abstract class PlaybackActivity extends Activity
case MSG_CREATE_PLAYLIST: {
PlaylistTask playlistTask = (PlaylistTask)message.obj;
int nextAction = message.arg1;
long playlistId = Playlist.createPlaylist(getContentResolver(), playlistTask.name);
long playlistId = Playlist.createPlaylist(this, playlistTask.name);
playlistTask.playlistId = playlistId;
mHandler.sendMessage(mHandler.obtainMessage(nextAction, playlistTask));
break;
@ -471,7 +471,7 @@ public abstract class PlaybackActivity extends Activity
}
case MSG_RENAME_PLAYLIST: {
PlaylistTask playlistTask = (PlaylistTask)message.obj;
Playlist.renamePlaylist(getContentResolver(), playlistTask.playlistId, playlistTask.name);
Playlist.renamePlaylist(getApplicationContext(), playlistTask.playlistId, playlistTask.name);
break;
}
case MSG_DELETE: {
@ -498,11 +498,11 @@ public abstract class PlaybackActivity extends Activity
int count = 0;
if (playlistTask.query != null) {
count += Playlist.addToPlaylist(getContentResolver(), playlistTask.playlistId, playlistTask.query);
count += Playlist.addToPlaylist(this, playlistTask.playlistId, playlistTask.query);
}
if (playlistTask.audioIds != null) {
count += Playlist.addToPlaylist(getContentResolver(), playlistTask.playlistId, playlistTask.audioIds);
count += Playlist.addToPlaylist(this, playlistTask.playlistId, playlistTask.audioIds);
}
String message = getResources().getQuantityString(R.plurals.added_to_playlist, count, count, playlistTask.name);
@ -524,7 +524,7 @@ public abstract class PlaybackActivity extends Activity
}
if (playlistTask.audioIds != null) {
count += Playlist.removeFromPlaylist(getContentResolver(), playlistTask.playlistId, playlistTask.audioIds);
count += Playlist.removeFromPlaylist(getApplicationContext(), playlistTask.playlistId, playlistTask.audioIds);
}
String message = getResources().getQuantityString(R.plurals.removed_from_playlist, count, count, playlistTask.name);
@ -553,7 +553,7 @@ public abstract class PlaybackActivity extends Activity
message = res.getString(R.string.delete_file_failed, file);
}
} else if (type == MediaUtils.TYPE_PLAYLIST) {
Playlist.deletePlaylist(getContentResolver(), id);
Playlist.deletePlaylist(this, id);
} else {
int count = PlaybackService.get(this).deleteMedia(type, id);
message = res.getQuantityString(R.plurals.deleted, count, count);

View File

@ -23,6 +23,8 @@
package ch.blinkenlights.android.vanilla;
import ch.blinkenlights.android.medialibrary.MediaLibrary;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
@ -31,7 +33,6 @@ import android.app.backup.BackupManager;
import android.appwidget.AppWidgetManager;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
@ -420,10 +421,6 @@ public final class PlaybackService extends Service
* Reference to precreated BASTP Object
*/
private BastpUtil mBastpUtil;
/**
* Reference to Playcounts helper class
*/
private PlayCountsHelper mPlayCounts;
@Override
public void onCreate()
@ -435,8 +432,6 @@ public final class PlaybackService extends Service
mTimeline.setCallback(this);
int state = loadState();
mPlayCounts = new PlayCountsHelper(this);
mMediaPlayer = getNewMediaPlayer();
mPreparedMediaPlayer = getNewMediaPlayer();
// We only have a single audio session
@ -488,7 +483,7 @@ public final class PlaybackService extends Service
filter.addAction(Intent.ACTION_SCREEN_ON);
registerReceiver(mReceiver, filter);
getContentResolver().registerContentObserver(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, true, mObserver);
MediaLibrary.registerContentObserver(mObserver);
mRemoteControlClient = new RemoteControl().getClient(this);
mRemoteControlClient.initializeRemote();
@ -1297,7 +1292,7 @@ public final class PlaybackService extends Service
Song song = mTimeline.shiftCurrentSong(delta);
mCurrentSong = song;
if (song == null) {
if (MediaUtils.isSongAvailable(getContentResolver())) {
if (MediaUtils.isSongAvailable(getApplicationContext())) {
int flag = finishAction(mState) == SongTimeline.FINISH_RANDOM ? FLAG_ERROR : FLAG_EMPTY_QUEUE;
synchronized (mStateLock) {
updateState((mState | flag) & ~FLAG_NO_MEDIA);
@ -1447,7 +1442,7 @@ public final class PlaybackService extends Service
public void onMediaChange()
{
if (MediaUtils.isSongAvailable(getContentResolver())) {
if (MediaUtils.isSongAvailable(getApplicationContext())) {
if ((mState & FLAG_NO_MEDIA) != 0)
setCurrentSong(0);
} else {
@ -1568,15 +1563,15 @@ public final class PlaybackService extends Service
case MSG_UPDATE_PLAYCOUNTS:
Song song = (Song)message.obj;
boolean played = message.arg1 == 1;
mPlayCounts.countSong(song, played);
PlayCountsHelper.countSong(getApplicationContext(), song, played);
// Update the playcounts playlist in ~20% of all cases if enabled
if (mAutoPlPlaycounts > 0 && Math.random() > 0.8) {
ContentResolver resolver = getContentResolver();
Context context = getApplicationContext();
// Add an invisible whitespace to adjust our sorting
String playlistName = "\u200B"+getString(R.string.autoplaylist_playcounts_name, mAutoPlPlaycounts);
long id = Playlist.createPlaylist(resolver, playlistName);
ArrayList<Long> items = mPlayCounts.getTopSongs(mAutoPlPlaycounts);
Playlist.addToPlaylist(resolver, id, items);
String playlistName = getString(R.string.autoplaylist_playcounts_name, mAutoPlPlaycounts);
long id = Playlist.createPlaylist(context, playlistName);
ArrayList<Long> items = PlayCountsHelper.getTopSongs(context, mAutoPlPlaycounts);
Playlist.addToPlaylist(context, id, items);
}
@ -1669,25 +1664,20 @@ public final class PlaybackService extends Service
public int deleteMedia(int type, long id)
{
int count = 0;
ContentResolver resolver = getContentResolver();
String[] projection = new String [] { MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DATA };
Cursor cursor = MediaUtils.buildQuery(type, id, projection, null).runQuery(resolver);
String[] projection = new String [] { MediaLibrary.SongColumns._ID, MediaLibrary.SongColumns.PATH };
Cursor cursor = MediaUtils.buildQuery(type, id, projection, null).runQuery(getApplicationContext());
if (cursor != null) {
while (cursor.moveToNext()) {
if (new File(cursor.getString(1)).delete()) {
long songId = cursor.getLong(0);
String where = MediaStore.Audio.Media._ID + '=' + songId;
resolver.delete(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, where, null);
MediaLibrary.removeSong(getApplicationContext(), songId);
mTimeline.removeSong(songId);
++count;
}
}
cursor.close();
}
return count;
}
@ -1787,6 +1777,7 @@ public final class PlaybackService extends Service
mHandler.sendMessage(mHandler.obtainMessage(MSG_QUERY, query));
}
/**
* Enqueues all the songs with the same album/artist/genre as the passed
* song.
@ -1812,7 +1803,7 @@ public final class PlaybackService extends Service
id = song.albumId;
break;
case MediaUtils.TYPE_GENRE:
id = MediaUtils.queryGenreForSong(getContentResolver(), song.id);
id = MediaUtils.queryGenreForSong(getApplicationContext(), song.id);
break;
default:
throw new IllegalArgumentException("Unsupported media type: " + type);

View File

@ -23,50 +23,45 @@
package ch.blinkenlights.android.vanilla;
import java.util.ArrayList;
import ch.blinkenlights.android.medialibrary.MediaLibrary;
import android.content.Context;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.provider.MediaStore;
import android.text.TextUtils;
import java.util.ArrayList;
/**
* Provides various playlist-related utility functions.
*/
public class Playlist {
/**
* Queries all the playlists known to the MediaStore.
* Queries all the playlists known to the MediaLibrary.
*
* @param resolver A ContentResolver to use.
* @param context the context to use
* @return The queried cursor.
*/
public static Cursor queryPlaylists(ContentResolver resolver)
{
Uri media = MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI;
String[] projection = { MediaStore.Audio.Playlists._ID, MediaStore.Audio.Playlists.NAME };
String sort = MediaStore.Audio.Playlists.NAME;
return MediaUtils.queryResolver(resolver, media, projection, null, null, sort);
public static Cursor queryPlaylists(Context context) {
final String[] projection = { MediaLibrary.PlaylistColumns._ID, MediaLibrary.PlaylistColumns.NAME };
final String sort = MediaLibrary.PlaylistColumns.NAME;
return MediaLibrary.queryLibrary(context, MediaLibrary.TABLE_PLAYLISTS, projection, null, null, sort);
}
/**
* Retrieves the id for a playlist with the given name.
*
* @param resolver A ContentResolver to use.
* @param context the context to use
* @param name The name of the playlist.
* @return The id of the playlist, or -1 if there is no playlist with the
* given name.
*/
public static long getPlaylist(ContentResolver resolver, String name)
public static long getPlaylist(Context context, String name)
{
long id = -1;
Cursor cursor = MediaUtils.queryResolver(resolver, MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI,
new String[] { MediaStore.Audio.Playlists._ID },
MediaStore.Audio.Playlists.NAME + "=?",
new String[] { name }, null);
final String[] projection = { MediaLibrary.PlaylistColumns._ID };
final String selection = MediaLibrary.PlaylistColumns.NAME+"=?";
final String[] selectionArgs = { name };
Cursor cursor = MediaLibrary.queryLibrary(context, MediaLibrary.TABLE_PLAYLISTS, projection, selection, selectionArgs, null);
if (cursor != null) {
if (cursor.moveToNext())
@ -81,31 +76,17 @@ public class Playlist {
* Create a new playlist with the given name. If a playlist with the given
* name already exists, it will be overwritten.
*
* @param resolver A ContentResolver to use.
* @param context the context to use
* @param name The name of the playlist.
* @return The id of the new playlist.
*/
public static long createPlaylist(ContentResolver resolver, String name)
public static long createPlaylist(Context context, String name)
{
long id = getPlaylist(resolver, name);
if (id == -1) {
// We need to create a new playlist.
ContentValues values = new ContentValues(1);
values.put(MediaStore.Audio.Playlists.NAME, name);
Uri uri = resolver.insert(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, values);
/* Creating the playlist may fail due to race conditions or silly
* android bugs (i am looking at you, kitkat!). In this case, id will stay -1
*/
if (uri != null) {
id = Long.parseLong(uri.getLastPathSegment());
}
} else {
// We are overwriting an existing playlist. Clear existing songs.
Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", id);
resolver.delete(uri, null, null);
}
long id = getPlaylist(context, name);
if (id != -1)
deletePlaylist(context, id);
id = MediaLibrary.createPlaylist(context, name);
return id;
}
@ -113,122 +94,78 @@ public class Playlist {
* Run the given query and add the results to the given playlist. Should be
* run on a background thread.
*
* @param resolver A ContentResolver to use.
* @param playlistId The MediaStore.Audio.Playlist id of the playlist to
* @param context the context to use
* @param playlistId The playlist id of the playlist to
* modify.
* @param query The query to run. The audio id should be the first column.
* @return The number of songs that were added to the playlist.
*/
public static int addToPlaylist(ContentResolver resolver, long playlistId, QueryTask query) {
public static int addToPlaylist(Context context, long playlistId, QueryTask query) {
ArrayList<Long> result = new ArrayList<Long>();
Cursor cursor = query.runQuery(resolver);
Cursor cursor = query.runQuery(context);
if (cursor != null) {
while (cursor.moveToNext()) {
result.add(cursor.getLong(0));
}
}
return addToPlaylist(resolver, playlistId, result);
return addToPlaylist(context, playlistId, result);
}
/**
* Adds a set of audioIds to the given playlist. Should be
* run on a background thread.
*
* @param resolver A ContentResolver to use.
* @param playlistId The MediaStore.Audio.Playlist id of the playlist to
* @param context the context to use
* @param playlistId The playlist id of the playlist to
* modify.
* @param audioIds An ArrayList with all IDs to add
* @return The number of songs that were added to the playlist.
*/
public static int addToPlaylist(ContentResolver resolver, long playlistId, ArrayList<Long> audioIds) {
public static int addToPlaylist(Context context, long playlistId, ArrayList<Long> audioIds) {
if (playlistId == -1)
return 0;
// Find the greatest PLAY_ORDER in the playlist
Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId);
String[] projection = new String[] { MediaStore.Audio.Playlists.Members.PLAY_ORDER };
Cursor cursor = MediaUtils.queryResolver(resolver, uri, projection, null, null, null);
int base = 0;
if (cursor.moveToLast())
base = cursor.getInt(0) + 1;
cursor.close();
int count = audioIds.size();
if (count > 0) {
ContentValues[] values = new ContentValues[count];
for (int i = 0; i != count; ++i) {
ContentValues value = new ContentValues(2);
value.put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, Integer.valueOf(base + i));
value.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, audioIds.get(i));
values[i] = value;
}
resolver.bulkInsert(uri, values);
}
return count;
return MediaLibrary.addToPlaylist(context, playlistId, audioIds);
}
/**
* Removes a set of audioIds from the given playlist. Should be
* run on a background thread.
*
* @param resolver A ContentResolver to use.
* @param playlistId The MediaStore.Audio.Playlist id of the playlist to
* @param context the context to use
* @param playlistId id of the playlist to
* modify.
* @param audioIds An ArrayList with all IDs to add
* @return The number of songs that were added to the playlist.
* @param audioIds An ArrayList with all IDs to drop
* @return The number of songs that were removed from the playlist
*/
public static int removeFromPlaylist(ContentResolver resolver, long playlistId, ArrayList<Long> audioIds) {
public static int removeFromPlaylist(Context context, long playlistId, ArrayList<Long> audioIds) {
if (playlistId == -1)
return 0;
int count = 0;
Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", playlistId);
for (long id : audioIds) {
String where = MediaStore.Audio.Playlists.Members.AUDIO_ID + "=" + id;
count += resolver.delete(uri, where, null);
}
return count;
String idList = TextUtils.join(", ", audioIds);
String selection = MediaLibrary.PlaylistSongColumns.SONG_ID+" IN ("+idList+") AND "+MediaLibrary.PlaylistSongColumns.PLAYLIST_ID+"="+playlistId;
return MediaLibrary.removeFromPlaylist(context, selection, null);
}
/**
* Delete the playlist with the given id.
*
* @param resolver A ContentResolver to use.
* @param id The Media.Audio.Playlists id of the playlist.
* @param context the context to use
* @param id the id of the playlist.
*/
public static void deletePlaylist(ContentResolver resolver, long id)
{
Uri uri = ContentUris.withAppendedId(MediaStore.Audio.Playlists.EXTERNAL_CONTENT_URI, id);
resolver.delete(uri, null, null);
public static void deletePlaylist(Context context, long id) {
MediaLibrary.removePlaylist(context, id);
}
/**
* Copy content from one playlist to another
*
* @param resolver A ContentResolver to use.
* @param sourceId The Media.Audio.Playlists id of the source playlist
* @param destinationId The Media.Audio.Playlists id of the destination playlist
*/
private static void _copyToPlaylist(ContentResolver resolver, long sourceId, long destinationId) {
QueryTask query = MediaUtils.buildPlaylistQuery(sourceId, Song.FILLED_PLAYLIST_PROJECTION, null);
addToPlaylist(resolver, destinationId, query);
}
/**
* Rename the playlist with the given id.
*
* @param resolver A ContentResolver to use.
* @param context the context to use
* @param id The Media.Audio.Playlists id of the playlist.
* @param newName The new name for the playlist.
*/
public static void renamePlaylist(ContentResolver resolver, long id, String newName)
{
long newId = createPlaylist(resolver, newName);
if (newId != -1) { // new playlist created -> move stuff over
_copyToPlaylist(resolver, id, newId);
deletePlaylist(resolver, id);
}
public static void renamePlaylist(Context context, long id, String newName) {
MediaLibrary.renamePlaylist(context, id, newName);
}
/**
@ -240,10 +177,10 @@ public class Playlist {
*/
public static long getFavoritesId(Context context, boolean create) {
String playlistName = context.getString(R.string.playlist_favorites);
long playlistId = getPlaylist(context.getContentResolver(), playlistName);
long playlistId = getPlaylist(context, playlistName);
if (playlistId == -1 && create == true)
playlistId = createPlaylist(context.getContentResolver(), playlistName);
playlistId = createPlaylist(context, playlistName);
return playlistId;
}
@ -251,19 +188,20 @@ public class Playlist {
/**
* Searches for given song in given playlist
*
* @param resolver A ContentResolver to use.
* @param context the context to use
* @param playlistId The ID of the Playlist to query
* @param song The Song to search in given playlistId
* @return true if `song' was found in `playlistId'
*/
public static boolean isInPlaylist(ContentResolver resolver, long playlistId, Song song) {
public static boolean isInPlaylist(Context context, long playlistId, Song song) {
if (playlistId == -1 || song == null)
return false;
boolean found = false;
String where = MediaStore.Audio.Playlists.Members.AUDIO_ID + "=" + song.id;
QueryTask query = MediaUtils.buildPlaylistQuery(playlistId, Song.EMPTY_PLAYLIST_PROJECTION, where);
Cursor cursor = query.runQuery(resolver);
String selection = MediaLibrary.PlaylistSongColumns.PLAYLIST_ID+"=? AND "+MediaLibrary.PlaylistSongColumns.SONG_ID+"=?";
String[] selectionArgs = { Long.toString(playlistId), Long.toString(song.id) };
Cursor cursor = MediaLibrary.queryLibrary(context, MediaLibrary.TABLE_PLAYLISTS_SONGS, Song.EMPTY_PLAYLIST_PROJECTION, selection, selectionArgs, null);
if (cursor != null) {
found = cursor.getCount() != 0;
cursor.close();

View File

@ -250,7 +250,7 @@ public class PlaylistActivity extends Activity
}
case LibraryActivity.ACTION_PLAY_ALL:
case LibraryActivity.ACTION_ENQUEUE_ALL: {
QueryTask query = MediaUtils.buildPlaylistQuery(mPlaylistId, Song.FILLED_PLAYLIST_PROJECTION, null);
QueryTask query = MediaUtils.buildPlaylistQuery(mPlaylistId, Song.FILLED_PLAYLIST_PROJECTION);
query.mode = MODE_FOR_ACTION[action];
query.data = position - mListView.getHeaderViewsCount();
PlaybackService.get(this).addSongs(query);
@ -275,7 +275,7 @@ public class PlaylistActivity extends Activity
public void onClick(DialogInterface dialog, int which)
{
if (which == DialogInterface.BUTTON_POSITIVE) {
Playlist.deletePlaylist(getContentResolver(), mPlaylistId);
Playlist.deletePlaylist(this, mPlaylistId);
finish();
}
dialog.dismiss();

View File

@ -1,6 +1,6 @@
/*
* Copyright (C) 2011 Christopher Eby <kreed@kreed.org>
* Copyright (C) 2015 Adrian Ulrich <adrian@blinkenlights.ch>
* Copyright (C) 2015-2016 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
@ -23,8 +23,8 @@
package ch.blinkenlights.android.vanilla;
import android.content.ContentResolver;
import android.content.ContentUris;
import ch.blinkenlights.android.medialibrary.MediaLibrary;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
@ -32,25 +32,24 @@ import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.provider.MediaStore;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CursorAdapter;
import android.widget.TextView;
import android.provider.MediaStore.Audio.Playlists.Members;
/**
* CursorAdapter backed by MediaStore playlists.
* CursorAdapter backed by MediaLibrary playlists.
*/
public class PlaylistAdapter extends CursorAdapter implements Handler.Callback {
private static final String[] PROJECTION = new String[] {
MediaStore.Audio.Playlists.Members._ID,
MediaStore.Audio.Playlists.Members.TITLE,
MediaStore.Audio.Playlists.Members.ARTIST,
MediaStore.Audio.Playlists.Members.AUDIO_ID,
MediaStore.Audio.Playlists.Members.ALBUM_ID,
MediaStore.Audio.Playlists.Members.PLAY_ORDER,
MediaLibrary.PlaylistSongColumns._ID,
MediaLibrary.SongColumns.TITLE,
MediaLibrary.ContributorColumns.ARTIST,
MediaLibrary.PlaylistSongColumns.SONG_ID,
MediaLibrary.SongColumns.ALBUM_ID,
MediaLibrary.PlaylistSongColumns.POSITION,
};
private final Context mContext;
@ -81,7 +80,7 @@ public class PlaylistAdapter extends CursorAdapter implements Handler.Callback {
/**
* Set the id of the backing playlist.
*
* @param id The MediaStore id of a playlist.
* @param id The id of a playlist.
*/
public void setPlaylistId(long id)
{
@ -142,7 +141,7 @@ public class PlaylistAdapter extends CursorAdapter implements Handler.Callback {
{
switch (message.what) {
case MSG_RUN_QUERY: {
Cursor cursor = runQuery(mContext.getContentResolver());
Cursor cursor = runQuery();
mUiHandler.sendMessage(mUiHandler.obtainMessage(MSG_UPDATE_CURSOR, cursor));
break;
}
@ -159,13 +158,12 @@ public class PlaylistAdapter extends CursorAdapter implements Handler.Callback {
/**
* Query the playlist songs.
*
* @param resolver A ContentResolver to query with.
* @return The resulting cursor.
*/
private Cursor runQuery(ContentResolver resolver)
private Cursor runQuery()
{
QueryTask query = MediaUtils.buildPlaylistQuery(mPlaylistId, PROJECTION, null);
return query.runQuery(resolver);
QueryTask query = MediaUtils.buildPlaylistQuery(mPlaylistId, PROJECTION);
return query.runQuery(mContext);
}
/**
@ -184,53 +182,12 @@ public class PlaylistAdapter extends CursorAdapter implements Handler.Callback {
// this can happen when the adapter changes during the drag
return;
// The Android API contains a method to move a playlist item, however,
// it has only been available since Froyo and doesn't seem to work
// after a song has been removed from the playlist (I think?).
ContentResolver resolver = mContext.getContentResolver();
Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", mPlaylistId);
Cursor cursor = getCursor();
int start = Math.min(from, to);
int end = Math.max(from, to);
long order;
if (start == 0) {
order = 0;
} else {
cursor.moveToPosition(start - 1);
order = cursor.getLong(5) + 1;
}
cursor.moveToPosition(end);
long endOrder = cursor.getLong(5);
// clear the rows we are replacing
String[] args = new String[] { Long.toString(order), Long.toString(endOrder) };
resolver.delete(uri, "play_order >= ? AND play_order <= ?", args);
// create the new rows
ContentValues[] values = new ContentValues[end - start + 1];
for (int i = start, j = 0; i <= end; ++i, ++j, ++order) {
cursor.moveToPosition(i == to ? from : i > to ? i - 1 : i + 1);
ContentValues value = new ContentValues(2);
value.put(MediaStore.Audio.Playlists.Members.PLAY_ORDER, Long.valueOf(order));
value.put(MediaStore.Audio.Playlists.Members.AUDIO_ID, cursor.getLong(3));
values[j] = value;
}
// insert the new rows
resolver.bulkInsert(uri, values);
changeCursor(runQuery(resolver));
MediaLibrary.movePlaylistItem(mContext, getItemId(from), getItemId(to));
changeCursor(runQuery());
}
public void removeItem(int position)
{
ContentResolver resolver = mContext.getContentResolver();
Uri uri = MediaStore.Audio.Playlists.Members.getContentUri("external", mPlaylistId);
resolver.delete(ContentUris.withAppendedId(uri, getItemId(position)), null, null);
public void removeItem(int position) {
MediaLibrary.removeFromPlaylist(mContext, MediaLibrary.PlaylistSongColumns._ID+"="+getItemId(position), null);
mUiHandler.sendEmptyMessage(MSG_RUN_QUERY);
}

View File

@ -83,7 +83,7 @@ public class PlaylistDialog extends DialogFragment
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
Cursor cursor = Playlist.queryPlaylists(getActivity().getContentResolver());
Cursor cursor = Playlist.queryPlaylists(getActivity());
if (cursor == null)
return null;

View File

@ -25,7 +25,6 @@ package ch.blinkenlights.android.vanilla;
import android.app.AlertDialog;
import android.app.Dialog;
import android.app.DialogFragment;
import android.content.ContentResolver;
import android.content.DialogInterface;
import android.content.Intent;
import android.os.Bundle;
@ -128,8 +127,7 @@ public class PlaylistInputDialog extends DialogFragment
mDialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(false);
} else {
mDialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(true);
ContentResolver resolver = getActivity().getContentResolver();
int res = Playlist.getPlaylist(resolver, string) == -1 ? mActionRes : R.string.overwrite;
int res = Playlist.getPlaylist(getActivity(), string) == -1 ? mActionRes : R.string.overwrite;
mDialog.getButton(DialogInterface.BUTTON_POSITIVE).setText(res);
}
}

View File

@ -55,7 +55,7 @@ public class PreferencesTheme extends PreferenceFragment
pref.setPersistent(false);
pref.setOnPreferenceClickListener(this);
pref.setTitle(entries[i]);
pref.setKey(""+attrs[0]); // that's actually our value
pref.setKey(Long.toString(attrs[0])); // that's actually our value
pref.setIcon(generateThemePreview(attrs));
screen.addPreference(pref);
}

View File

@ -1,36 +1,33 @@
/*
* Copyright (C) 2011 Christopher Eby <kreed@kreed.org>
* Copyright (C) 2016 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:
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* 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.
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package ch.blinkenlights.android.vanilla;
import android.content.ContentResolver;
import ch.blinkenlights.android.medialibrary.MediaLibrary;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
/**
* Represents a pending query.
*/
public class QueryTask {
public Uri uri;
public final String table;
public final String[] projection;
public final String selection;
public final String[] selectionArgs;
@ -55,11 +52,10 @@ public class QueryTask {
/**
* Create the tasks. All arguments are passed directly to
* ContentResolver.query().
* MediaLibrary.runQuery().
*/
public QueryTask(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)
{
this.uri = uri;
public QueryTask(String table, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
this.table = table;
this.projection = projection;
this.selection = selection;
this.selectionArgs = selectionArgs;
@ -69,10 +65,9 @@ public class QueryTask {
/**
* Run the query. Should be called on a background thread.
*
* @param resolver The ContentResolver to query with.
* @param context The Context to use
*/
public Cursor runQuery(ContentResolver resolver)
{
return MediaUtils.queryResolver(resolver, uri, projection, selection, selectionArgs, sortOrder);
public Cursor runQuery(Context context) {
return MediaLibrary.queryLibrary(context, table, projection, selection, selectionArgs, sortOrder);
}
}

View File

@ -1,8 +1,6 @@
/* SD Scanner - A manual implementation of the SD rescan process, compatible
* with Android 4.4
*
* Copyright (C) 2013-2014 Jeremy Erickson
/*
* Copyright (C) 2016 Xiao Bao Clark
* Copyright (C) 2016 Adrian Ulrich
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,73 +15,22 @@
package ch.blinkenlights.android.vanilla;
import ch.blinkenlights.android.medialibrary.MediaLibrary;
import android.app.Fragment;
import android.app.FragmentManager;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.text.method.ScrollingMovementMethod;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.gmail.jerickson314.sdscanner.ScanFragment;
import com.gmail.jerickson314.sdscanner.UIStringGenerator;
import java.util.Timer;
import java.util.TimerTask;
import android.util.Log;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
/**
* Fragment version of the MainActivity from the SD Scanner app
*/
public class SDScannerFragment extends Fragment
implements ScanFragment.ScanProgressCallbacks
{
private static ScanFragment mScanFragment;
/**
* List of common directories with media files
*/
private File[] mScanTargetStages = {};
@Override
public void updateProgressNum(int progressNum) {
ProgressBar progressBar = (ProgressBar)findViewById(R.id.progress_bar);
progressBar.setProgress(progressNum);
}
@Override
public void updateProgressText(UIStringGenerator progressText) {
TextView progressLabel = (TextView)findViewById(R.id.progress_label);
progressLabel.setText(progressText.toString(getActivity()));
}
@Override
public void updateDebugMessages(UIStringGenerator debugMessages) {
TextView debugLabel = (TextView)findViewById(R.id.debug_label);
debugLabel.setText(debugMessages.toString(getActivity()));
}
@Override
public void updateStartButtonEnabled(boolean startButtonEnabled) {
Button startButton = (Button)findViewById(R.id.start_button);
startButton.setEnabled(startButtonEnabled);
}
@Override
public void updatePath(String path) {
}
@Override
public void signalFinished() {
}
private Timer mTimer;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
@ -93,110 +40,53 @@ public class SDScannerFragment extends Fragment
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
// Setup with values from fragment.
updateProgressNum(mScanFragment.getProgressNum());
updateProgressText(mScanFragment.getProgressText());
updateDebugMessages(mScanFragment.getDebugMessages());
updateStartButtonEnabled(mScanFragment.getStartButtonEnabled());
// Make debug output scrollable.
TextView debugLabel = (TextView)findViewById(R.id.debug_label);
debugLabel.setMovementMethod(new ScrollingMovementMethod());
view.findViewById(R.id.start_button).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
try {
startButtonPressed(v);
} catch (IOException e) {
throw new RuntimeException(e);
}
startButtonPressed(v);
}
});
}
/** Called when the activity is first created. */
@Override
public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
public void onResume() {
super.onResume();
Log.v("VanillaMusic", "onResume! "+mTimer);
mScanTargetStages = getScanTargets(getActivity().getApplicationContext());
FragmentManager fm = getFragmentManager();
if (mScanFragment == null)
mScanFragment = (ScanFragment) fm.findFragmentByTag("scan");
if (mScanFragment == null)
mScanFragment = new ScanFragment();
fm.beginTransaction().add(mScanFragment, "scan").commit();
mScanFragment.setScanProgressCallbacks(this);
mTimer = new Timer();
mTimer.scheduleAtFixedRate((new TimerTask() {
@Override
public void run() {
getActivity().runOnUiThread(new Runnable(){
public void run() {
updateProgress();
}
});
}}), 0, 120);
}
@Override
public void onDestroy() {
super.onDestroy();
mScanFragment.setScanProgressCallbacks(null);
public void onPause() {
super.onPause();
Log.v("VanillaMusic", "onPause "+mTimer);
if (mTimer != null) {
mTimer.cancel();
mTimer = null;
}
}
private View findViewById(int viewId) {
return getView().findViewById(viewId);
private void updateProgress() {
View button = getActivity().findViewById(R.id.start_button);
TextView progress = (TextView)getActivity().findViewById(R.id.progress_label);
String scanText = MediaLibrary.describeScanProgress(getActivity());
progress.setText(scanText);
button.setEnabled(scanText == null);
}
public void startButtonPressed(View view) throws IOException {
mScanFragment.startScan(mScanTargetStages);
public void startButtonPressed(View view) {
MediaLibrary.scanLibrary(getActivity(), true, false);
updateProgress();
}
/**
* Creates a list of directories that can be scanned. Uses
* {@link Environment#getExternalStorageDirectory} and {@link Context#getExternalFilesDirs}
* (on KITKAT+).
*
* @param context The context
* @return A list of unique directories that can be scanned.
*/
private File[] getScanTargets(Context context) {
ArrayList<File> scanTargets = new ArrayList<>();
// Put canonical paths into a HashSet to avoid duplicates
HashSet<String> possibleTargets = new HashSet<>();
try {
possibleTargets.add(Environment.getExternalStorageDirectory().getCanonicalPath());
} catch (IOException e) {
// Shouldn't happen, but just in case it does add the external storage dir directly
scanTargets.add(Environment.getExternalStorageDirectory());
}
File sdcard1 = new File("/storage/sdcard1");
if (sdcard1.exists()) {
try {
possibleTargets.add(sdcard1.getCanonicalPath());
} catch (IOException e) {
// Shouldn't happen, but just in case it does add the "storage/sdcard1" dir directly
scanTargets.add(sdcard1);
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
// Attempt to divine external storage directories from 'getExternalFilesDirs'
File[] externalFilesDirs = context.getExternalFilesDirs(null);
String packageDirSuffix = "/Android/data/" + context.getPackageName() + "/files";
for (int i = 0; i < externalFilesDirs.length; i++) {
try {
String storageDir = externalFilesDirs[i].getCanonicalPath().replace(packageDirSuffix, "");
possibleTargets.add(storageDir);
} catch (IOException e) {
}
}
}
for (String possibleTarget : possibleTargets) {
File file = new File(possibleTarget);
if (file.exists()) {
scanTargets.add(file);
}
}
return scanTargets.toArray(new File[scanTargets.size()]);
}
}

View File

@ -22,6 +22,8 @@
package ch.blinkenlights.android.vanilla;
import ch.blinkenlights.android.medialibrary.MediaLibrary;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
@ -47,38 +49,37 @@ public class Song implements Comparable<Song> {
public static final String[] EMPTY_PROJECTION = {
MediaStore.Audio.Media._ID,
MediaLibrary.SongColumns._ID,
};
public static final String[] FILLED_PROJECTION = {
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DATA,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM_ID,
MediaStore.Audio.Media.ARTIST_ID,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.TRACK,
MediaLibrary.SongColumns._ID,
MediaLibrary.SongColumns.PATH,
MediaLibrary.SongColumns.TITLE,
MediaLibrary.AlbumColumns.ALBUM,
MediaLibrary.ContributorColumns.ARTIST,
MediaLibrary.SongColumns.ALBUM_ID,
MediaLibrary.ContributorColumns.ARTIST_ID,
MediaLibrary.SongColumns.DURATION,
MediaLibrary.SongColumns.SONG_NUMBER,
};
public static final String[] EMPTY_PLAYLIST_PROJECTION = {
MediaStore.Audio.Playlists.Members.AUDIO_ID,
MediaLibrary.PlaylistSongColumns.SONG_ID,
};
public static final String[] FILLED_PLAYLIST_PROJECTION = {
MediaStore.Audio.Playlists.Members.AUDIO_ID,
MediaStore.Audio.Playlists.Members.DATA,
MediaStore.Audio.Playlists.Members.TITLE,
MediaStore.Audio.Playlists.Members.ALBUM,
MediaStore.Audio.Playlists.Members.ARTIST,
MediaStore.Audio.Playlists.Members.ALBUM_ID,
MediaStore.Audio.Playlists.Members.ARTIST_ID,
MediaStore.Audio.Playlists.Members.DURATION,
MediaStore.Audio.Playlists.Members.TRACK,
MediaLibrary.PlaylistSongColumns.SONG_ID,
MediaLibrary.SongColumns.PATH,
MediaLibrary.SongColumns.TITLE,
MediaLibrary.AlbumColumns.ALBUM,
MediaLibrary.ContributorColumns.ARTIST,
MediaLibrary.SongColumns.ALBUM_ID,
MediaLibrary.ContributorColumns.ARTIST_ID,
MediaLibrary.SongColumns.DURATION,
MediaLibrary.SongColumns.SONG_NUMBER,
};
/**
* The cache instance.
*/

View File

@ -23,7 +23,8 @@
package ch.blinkenlights.android.vanilla;
import android.content.ContentResolver;
import ch.blinkenlights.android.medialibrary.MediaLibrary;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
@ -326,7 +327,7 @@ public final class SongTimeline {
// Fill the selection with the ids of all the saved songs
// and initialize the timeline with unpopulated songs.
StringBuilder selection = new StringBuilder("_ID IN (");
StringBuilder selection = new StringBuilder(MediaLibrary.SongColumns._ID+" IN (");
for (int i = 0; i != n; ++i) {
long id = in.readLong();
if (id == -1)
@ -346,10 +347,8 @@ public final class SongTimeline {
// return its results in.
Collections.sort(songs, new IdComparator());
ContentResolver resolver = mContext.getContentResolver();
Uri media = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
Cursor cursor = MediaUtils.queryResolver(resolver, media, Song.FILLED_PROJECTION, selection.toString(), null, "_id");
QueryTask query = new QueryTask(MediaLibrary.VIEW_SONGS_ALBUMS_ARTISTS, Song.FILLED_PROJECTION, selection.toString(), null, MediaLibrary.SongColumns._ID);
Cursor cursor = query.runQuery(mContext);
if (cursor != null) {
if (cursor.getCount() != 0) {
cursor.moveToNext();
@ -576,7 +575,7 @@ public final class SongTimeline {
return null;
} else if (pos == size) {
if (mFinishAction == FINISH_RANDOM) {
song = MediaUtils.getRandomSong(mContext.getContentResolver());
song = MediaUtils.getRandomSong(mContext);
if (song == null)
return null;
timeline.add(song);
@ -700,7 +699,7 @@ public final class SongTimeline {
*/
public int addSongs(Context context, QueryTask query)
{
Cursor cursor = query.runQuery(context.getContentResolver());
Cursor cursor = query.runQuery(context);
if (cursor == null) {
return 0;
}

View File

@ -1,555 +0,0 @@
/* SD Scanner - A manual implementation of the SD rescan process, compatible
* with Android 4.4.
*
* This file contains the fragment that actually performs all scan activity
* and retains state across configuration changes.
*
* Copyright (C) 2013-2014 Jeremy Erickson
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
package com.gmail.jerickson314.sdscanner;
import android.app.Activity;
import android.app.Fragment;
import android.content.ContentUris;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.SystemClock;
import android.provider.MediaStore;
import android.util.Log;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.TreeSet;
import ch.blinkenlights.android.vanilla.R;
public class ScanFragment extends Fragment {
private static final String[] MEDIA_PROJECTION =
{MediaStore.MediaColumns.DATA,
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.SIZE};
private static final String[] STAR = {"*"};
private static final int DB_RETRIES = 3;
Context mApplicationContext;
ArrayList<String> mPathNames;
TreeSet<File> mFilesToProcess;
int mLastGoodProcessedIndex;
private Handler mHandler = new Handler();
int mProgressNum;
UIStringGenerator mProgressText =
new UIStringGenerator(R.string.progress_unstarted_label);
UIStringGenerator mDebugMessages = new UIStringGenerator();
boolean mStartButtonEnabled;
boolean mHasStarted = false;
ArrayList<File> mDirectoryScanList;
/**
* Callback interface used by the fragment to update the Activity.
*/
public static interface ScanProgressCallbacks {
void updateProgressNum(int progressNum);
void updateProgressText(UIStringGenerator progressText);
void updateDebugMessages(UIStringGenerator debugMessages);
void updatePath(String path);
void updateStartButtonEnabled(boolean startButtonEnabled);
void signalFinished();
}
private ScanProgressCallbacks mCallbacks;
private void updateProgressNum(int progressNum) {
mProgressNum = progressNum;
if (mCallbacks != null) {
mCallbacks.updateProgressNum(mProgressNum);
}
}
private void updateProgressText(int resId) {
updateProgressText(new UIStringGenerator(resId));
}
private void updateProgressText(int resId, String string) {
updateProgressText(new UIStringGenerator(resId, string));
}
private void updateProgressText(UIStringGenerator progressText) {
mProgressText = progressText;
if (mCallbacks != null) {
mCallbacks.updateProgressText(mProgressText);
}
}
private void addDebugMessage(int resId, String string) {
mDebugMessages.addSubGenerator(resId);
mDebugMessages.addSubGenerator(string + "\n");
if (mCallbacks != null) {
mCallbacks.updateDebugMessages(mDebugMessages);
}
}
private void addDebugMessage(String debugMessage) {
mDebugMessages.addSubGenerator(debugMessage + "\n");
if (mCallbacks != null) {
mCallbacks.updateDebugMessages(mDebugMessages);
}
}
private void resetDebugMessages() {
mDebugMessages = new UIStringGenerator();
if (mCallbacks != null) {
mCallbacks.updateDebugMessages(mDebugMessages);
}
}
private void updateStartButtonEnabled(boolean startButtonEnabled) {
mStartButtonEnabled = startButtonEnabled;
if (mCallbacks != null) {
mCallbacks.updateStartButtonEnabled(mStartButtonEnabled);
}
}
private void signalFinished() {
if (mCallbacks != null) {
mCallbacks.signalFinished();
}
}
public int getProgressNum() {
return mProgressNum;
}
public UIStringGenerator getProgressText() {
return mProgressText;
}
public UIStringGenerator getDebugMessages() {
return mDebugMessages;
}
public boolean getStartButtonEnabled() {
return mStartButtonEnabled;
}
public boolean getHasStarted() {
return mHasStarted;
}
@Override
public void onAttach(Activity activity) {
super.onAttach(activity);
if(activity instanceof ScanProgressCallbacks) {
mCallbacks = (ScanProgressCallbacks) activity;
}
mApplicationContext = activity.getApplicationContext();
}
public void setScanProgressCallbacks(ScanProgressCallbacks callbacks) {
mCallbacks = callbacks;
}
public ScanFragment() {
super();
// Set correct initial values.
mProgressNum = 0;
mStartButtonEnabled = true;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Retain this fragment across configuration changes.
setRetainInstance(true);
}
// Purely for debugging and not normally used, so does not translate
// strings.
public void listPathNamesOnDebug() {
StringBuffer listString = new StringBuffer();
listString.append("\n\nScanning paths:\n");
Iterator<String> iterator = mPathNames.iterator();
while (iterator.hasNext()) {
listString.append(iterator.next() + "\n");
}
addDebugMessage(listString.toString());
}
public void advanceScanner() {
if (mDirectoryScanList != null && mDirectoryScanList.isEmpty() == false) {
File nextDir = mDirectoryScanList.remove(0);
startScan(nextDir, false);
} else {
updateProgressNum(0);
updateProgressText(R.string.progress_completed_label);
updateStartButtonEnabled(true);
signalFinished();
}
}
public void startMediaScanner(){
//listPathNamesOnDebug();
if (mPathNames.size() == 0) {
advanceScanner();
}
else {
MediaScannerConnection.scanFile(
mApplicationContext,
mPathNames.toArray(new String[mPathNames.size()]),
null,
new MediaScannerConnection.OnScanCompletedListener() {
public void onScanCompleted(String path, Uri uri) {
mHandler.post(new Updater(path));
}
});
}
}
public void startScan(File[] pathList) {
mDirectoryScanList = new ArrayList<File>();
for (File f : pathList) {
if (f.exists() && f.isDirectory())
mDirectoryScanList.add(f);
}
advanceScanner();
}
public void startScan(File path, boolean restrictDbUpdate) {
mHasStarted = true;
updateStartButtonEnabled(false);
updateProgressText(R.string.progress_filelist_label);
mFilesToProcess = new TreeSet<File>();
resetDebugMessages();
if (path.exists()) {
this.new PreprocessTask().execute(new ScanParameters(path, restrictDbUpdate));
}
else {
updateProgressText(R.string.progress_error_bad_path_label);
updateStartButtonEnabled(true);
signalFinished();
}
}
static class ProgressUpdate {
public enum Type {
DATABASE, STATE, DEBUG
}
Type mType;
public Type getType() {
return mType;
}
int mResId;
public int getResId() {
return mResId;
}
String mString;
public String getString() {
return mString;
}
int mProgress;
public int getProgress() {
return mProgress;
}
public ProgressUpdate(Type type, int resId, String string,
int progress) {
mType = type;
mResId = resId;
mString = string;
mProgress = progress;
}
}
static ProgressUpdate debugUpdate(int resId, String string) {
return new ProgressUpdate(ProgressUpdate.Type.DEBUG, resId, string, 0);
}
static ProgressUpdate debugUpdate(int resId) {
return debugUpdate(resId, "");
}
static ProgressUpdate databaseUpdate(String file, int progress) {
return new ProgressUpdate(ProgressUpdate.Type.DATABASE, 0, file,
progress);
}
static ProgressUpdate stateUpdate(int resId) {
return new ProgressUpdate(ProgressUpdate.Type.STATE, resId, "", 0);
}
static class ScanParameters {
File mPath;
boolean mRestrictDbUpdate;
public ScanParameters(File path, boolean restrictDbUpdate) {
mPath = path;
mRestrictDbUpdate = restrictDbUpdate;
}
public File getPath() {
return mPath;
}
public boolean shouldScan(File file, boolean fromDb)
throws IOException {
// Empty directory check.
if (file.isDirectory()) {
File[] files = file.listFiles();
if (files == null || files.length == 0) {
Log.w("SDScanner", "Scan of empty directory " +
file.getCanonicalPath() + " skipped to avoid bug.");
return false;
}
}
if (!mRestrictDbUpdate && fromDb) {
return true;
}
while (file != null) {
if (file.equals(mPath)) {
return true;
}
file = file.getParentFile();
}
// If we fell through here, got up to root without encountering the
// path to scan.
if (!fromDb) {
Log.w("SDScanner", "File " + file.getCanonicalPath() +
" outside of scan directory skipped.");
}
return false;
}
}
class PreprocessTask extends AsyncTask<ScanParameters, ProgressUpdate, Void> {
private void recursiveAddFiles(File file, ScanParameters scanParameters)
throws IOException {
if (!scanParameters.shouldScan(file, false)) {
// If we got here, there file was either outside the scan
// directory, or was an empty directory.
return;
}
if (!mFilesToProcess.add(file)) {
// Avoid infinite recursion caused by symlinks.
// If mFilesToProcess already contains this file, add() will
// return false.
return;
}
if (file.isDirectory()) {
boolean nomedia = new File(file, ".nomedia").exists();
// Only recurse downward if not blocked by nomedia.
if (!nomedia) {
File[] files = file.listFiles();
if (files != null) {
for (File nextFile : files) {
recursiveAddFiles(nextFile.getCanonicalFile(),
scanParameters);
}
}
else {
publishProgress(debugUpdate(
R.string.skipping_folder_label,
" " + file.getPath()));
}
}
}
}
protected void dbOneTry(ScanParameters parameters) {
Cursor cursor = mApplicationContext.getContentResolver().query(
MediaStore.Files.getContentUri("external"),
MEDIA_PROJECTION,
//STAR,
null,
null,
null);
int data_column =
cursor.getColumnIndex(MediaStore.MediaColumns.DATA);
int modified_column =
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED);
int size_column =
cursor.getColumnIndex(MediaStore.MediaColumns.SIZE);
int totalSize = cursor.getCount();
int currentItem = 0;
int reportFreq = 0;
// Used to calibrate reporting frequency
long startTime = SystemClock.currentThreadTimeMillis();
while (cursor.moveToNext()) {
currentItem++;
try {
File file = new File(cursor.getString(data_column)).getCanonicalFile();
// Ignore non-file backed playlists (size == 0). Fixes playlist removal on scan
// for 4.1
boolean validSize = cursor.getInt(size_column) > 0;
if (validSize && (!file.exists() ||
file.lastModified() / 1000L >
cursor.getLong(modified_column))
&& parameters.shouldScan(file, true)) {
// Media scanner handles these cases.
// Is a set, so OK if already present.
mFilesToProcess.add(file);
}
else {
// Don't want to waste time scanning an up-to-date
// file.
mFilesToProcess.remove(file);
}
if (reportFreq == 0) {
// Calibration phase
if (SystemClock.currentThreadTimeMillis() - startTime > 25) {
reportFreq = currentItem + 1;
}
}
else if (currentItem % reportFreq == 0) {
publishProgress(databaseUpdate(file.getPath(),
(100 * currentItem) / totalSize));
}
}
catch (IOException ex) {
// Just ignore it for now.
}
}
// Don't need the cursor any more.
cursor.close();
}
@Override
protected Void doInBackground(ScanParameters... parameters) {
try {
recursiveAddFiles(parameters[0].getPath(), parameters[0]);
}
catch (IOException Ex) {
// Do nothing.
}
// Parse database
publishProgress(stateUpdate(R.string.progress_database_label));
boolean dbSuccess = false;
int numRetries = 0;
while (!dbSuccess && numRetries < DB_RETRIES) {
dbSuccess = true;
try {
dbOneTry(parameters[0]);
}
catch (Exception Ex) {
// For any of these errors, try again.
numRetries++;
dbSuccess = false;
if (numRetries < DB_RETRIES) {
publishProgress(stateUpdate(
R.string.db_error_retrying));
SystemClock.sleep(1000);
}
}
}
if (numRetries > 0) {
if (dbSuccess) {
publishProgress(debugUpdate(R.string.db_error_recovered));
}
else {
publishProgress(debugUpdate(R.string.db_error_failure));
}
}
// Prepare final path list for processing.
mPathNames = new ArrayList<String>(mFilesToProcess.size());
Iterator<File> iterator = mFilesToProcess.iterator();
while (iterator.hasNext()) {
mPathNames.add(iterator.next().getPath());
}
mLastGoodProcessedIndex = -1;
return null;
}
@Override
protected void onProgressUpdate(ProgressUpdate... progress) {
switch (progress[0].getType()) {
case DATABASE:
updateProgressText(R.string.database_proc,
" " + progress[0].getString());
updateProgressNum(progress[0].getProgress());
break;
case STATE:
updateProgressText(progress[0].getResId());
updateProgressNum(0);
break;
case DEBUG:
addDebugMessage(progress[0].getResId(), progress[0].getString());
}
}
@Override
protected void onPostExecute(Void result) {
startMediaScanner();
}
}
class Updater implements Runnable {
String mPathScanned;
public Updater(String path) {
mPathScanned = path;
}
public void run() {
if (mLastGoodProcessedIndex + 1 < mPathNames.size() &&
mPathNames.get(mLastGoodProcessedIndex
+ 1).equals(mPathScanned)) {
mLastGoodProcessedIndex++;
}
else {
int newIndex = mPathNames.indexOf(mPathScanned);
if (newIndex > -1) {
mLastGoodProcessedIndex = newIndex;
}
}
int progress = (100 * (mLastGoodProcessedIndex + 1))
/ mPathNames.size();
if (progress == 100) {
advanceScanner();
}
else {
updateProgressNum(progress);
updateProgressText(R.string.final_proc, " " + mPathScanned);
}
}
}
}

View File

@ -1,87 +0,0 @@
/* SD Scanner - A manual implementation of the SD rescan process, compatible
* with Android 4.4
*
* Copyright (C) 2013-2014 Jeremy Erickson
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
package com.gmail.jerickson314.sdscanner;
import android.app.Activity;
import java.util.ArrayList;
import java.util.Iterator;
public class UIStringGenerator {
private static interface SubGenerator {
public String toString(Activity activity);
}
private static class ResourceSubGenerator implements SubGenerator {
int mResId;
public ResourceSubGenerator(int resId) {
mResId = resId;
}
public String toString(Activity activity) {
return activity.getString(mResId);
}
}
private static class StringSubGenerator implements SubGenerator {
String mString;
public StringSubGenerator(String string) {
mString = string;
}
public String toString(Activity activity) {
return mString;
}
}
ArrayList<SubGenerator> mSubGenerators = new ArrayList<SubGenerator>();
public void addSubGenerator(int resId) {
mSubGenerators.add(new ResourceSubGenerator(resId));
}
public void addSubGenerator(String string) {
mSubGenerators.add(new StringSubGenerator(string));
}
public UIStringGenerator(int resId) {
addSubGenerator(resId);
}
public UIStringGenerator(int resId, String string) {
addSubGenerator(resId);
addSubGenerator(string);
}
public UIStringGenerator(String string) {
addSubGenerator(string);
}
public UIStringGenerator() {
// Doing nothing results in empty string.
}
public String toString(Activity activity) {
StringBuilder toReturn = new StringBuilder();
Iterator<SubGenerator> iterator = mSubGenerators.iterator();
while (iterator.hasNext()) {
toReturn.append(iterator.next().toString(activity));
}
return toReturn.toString();
}
}